worktrunk/styling/
constants.rs

1//! Style constants and emojis for terminal output
2//!
3//! # Styling with color-print
4//!
5//! Use `cformat!` with HTML-like tags for all user-facing messages:
6//!
7//! ```
8//! use color_print::cformat;
9//!
10//! // Simple styling
11//! let msg = cformat!("<green>Success message</>");
12//!
13//! // Nested styles - bold inherits green
14//! let branch = "feature";
15//! let msg = cformat!("<green>Removed branch <bold>{branch}</> successfully</>");
16//!
17//! // Semantic mapping:
18//! // - Errors: <red>...</>
19//! // - Warnings: <yellow>...</>
20//! // - Hints: <dim>...</>
21//! // - Progress: <cyan>...</>
22//! // - Success: <green>...</>
23//! // - Secondary: <bright-black>...</>
24//! ```
25//!
26//! # anstyle constants
27//!
28//! A few `Style` constants remain for programmatic use with `StyledLine` and
29//! table rendering where computed styles are needed at runtime.
30
31use anstyle::{AnsiColor, Color, Style};
32
33// ============================================================================
34// Programmatic Style Constants (for StyledLine, tables, computed styles)
35// ============================================================================
36
37/// Addition style for diffs (green) - used in table rendering
38pub const ADDITION: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
39
40/// Deletion style for diffs (red) - used in table rendering
41pub const DELETION: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
42
43/// Gutter style for quoted content (commands, config, error details)
44///
45/// We wanted the dimmest/most subtle background that works on both dark and light
46/// terminals. BrightWhite was the best we could find among basic ANSI colors, but
47/// we're open to better ideas. Options considered:
48/// - Black/BrightBlack: too dark on light terminals
49/// - Reverse video: just flips which terminal looks good
50/// - 256-color grays: better but not universally supported
51/// - No background: loses the visual separation we want
52pub const GUTTER: Style = Style::new().bg_color(Some(Color::Ansi(AnsiColor::BrightWhite)));
53
54// ============================================================================
55// Message Emojis
56// ============================================================================
57
58/// Progress emoji: `cformat!("{PROGRESS_EMOJI} <cyan>message</>")`
59pub const PROGRESS_EMOJI: &str = "🔄";
60
61/// Success emoji: `cformat!("{SUCCESS_EMOJI} <green>message</>")`
62pub const SUCCESS_EMOJI: &str = "✅";
63
64/// Error emoji: `cformat!("{ERROR_EMOJI} <red>message</>")`
65pub const ERROR_EMOJI: &str = "❌";
66
67/// Warning emoji: `cformat!("{WARNING_EMOJI} <yellow>message</>")`
68pub const WARNING_EMOJI: &str = "🟡";
69
70/// Hint emoji: `cformat!("{HINT_EMOJI} <dim>message</>")`
71pub const HINT_EMOJI: &str = "💡";
72
73/// Info emoji - use for neutral status (primary status NOT dimmed, metadata may be dimmed)
74/// Primary status: `output::info("All commands already approved")?;`
75/// Metadata: `cformat!("{INFO_EMOJI} <dim>Showing 5 worktrees...</>")`
76pub const INFO_EMOJI: &str = "⚪";
77
78/// Prompt emoji - use for questions requiring user input
79/// `eprint!("{PROMPT_EMOJI} Proceed? [y/N] ")`
80pub const PROMPT_EMOJI: &str = "❓";
81
82// ============================================================================
83// Formatted Message Type
84// ============================================================================
85
86use std::fmt;
87
88/// A message that has already been formatted with emoji and styling.
89///
90/// This type provides compile-time prevention of double-formatting. Message
91/// functions like `error_message()` take `impl AsRef<str>` and return
92/// `FormattedMessage`. Since `FormattedMessage` does NOT implement `AsRef<str>`,
93/// passing it to a message function is a compile error.
94///
95/// # Type Safety
96///
97/// ```compile_fail
98/// use worktrunk::styling::{error_message, FormattedMessage};
99///
100/// let msg = error_message("first error");
101/// // This won't compile - FormattedMessage doesn't implement AsRef<str>
102/// let double = error_message(msg);
103/// ```
104///
105/// # Usage
106///
107/// ```
108/// use worktrunk::styling::error_message;
109///
110/// let msg = error_message("Something went wrong");
111/// println!("{}", msg);  // Uses Display
112/// ```
113#[derive(Debug, Clone)]
114pub struct FormattedMessage(String);
115
116impl FormattedMessage {
117    /// Create a formatted message from a pre-formatted string.
118    ///
119    /// Use this when implementing `Into<FormattedMessage>` for error types
120    /// that format themselves (like `GitError`).
121    pub fn new(content: String) -> Self {
122        Self(content)
123    }
124
125    /// Get the inner string for output.
126    pub fn into_inner(self) -> String {
127        self.0
128    }
129
130    /// Borrow the inner string for inspection (e.g., in tests).
131    ///
132    /// Note: This does NOT implement `AsRef<str>` to prevent accidentally
133    /// passing a `FormattedMessage` to message functions like `error_message()`.
134    pub fn as_str(&self) -> &str {
135        &self.0
136    }
137}
138
139impl fmt::Display for FormattedMessage {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        write!(f, "{}", self.0)
142    }
143}
144
145impl From<FormattedMessage> for String {
146    fn from(msg: FormattedMessage) -> String {
147        msg.0
148    }
149}
150
151// ============================================================================
152// Message Formatting Functions
153// ============================================================================
154//
155// These functions provide the canonical formatting for each message type.
156// Used by both the output system (output::error, etc.) and Display impls
157// (GitError, WorktrunkError) to ensure consistent styling.
158//
159// All functions take `impl AsRef<str>` (which FormattedMessage does NOT
160// implement) and return `FormattedMessage`, preventing double-formatting.
161
162use color_print::cformat;
163
164/// Format an error message with emoji and red styling
165///
166/// Content can include inner styling like `<bold>`:
167/// ```
168/// use color_print::cformat;
169/// use worktrunk::styling::error_message;
170///
171/// let name = "feature";
172/// println!("{}", error_message(cformat!("Branch <bold>{name}</> not found")));
173/// ```
174pub fn error_message(content: impl AsRef<str>) -> FormattedMessage {
175    FormattedMessage(cformat!("{ERROR_EMOJI} <red>{}</>", content.as_ref()))
176}
177
178/// Format a hint message with emoji and dim styling
179pub fn hint_message(content: impl AsRef<str>) -> FormattedMessage {
180    FormattedMessage(cformat!("{HINT_EMOJI} <dim>{}</>", content.as_ref()))
181}
182
183/// Format a warning message with emoji and yellow styling
184pub fn warning_message(content: impl AsRef<str>) -> FormattedMessage {
185    FormattedMessage(cformat!("{WARNING_EMOJI} <yellow>{}</>", content.as_ref()))
186}
187
188/// Format a success message with emoji and green styling
189pub fn success_message(content: impl AsRef<str>) -> FormattedMessage {
190    FormattedMessage(cformat!("{SUCCESS_EMOJI} <green>{}</>", content.as_ref()))
191}
192
193/// Format a progress message with emoji and cyan styling
194pub fn progress_message(content: impl AsRef<str>) -> FormattedMessage {
195    FormattedMessage(cformat!("{PROGRESS_EMOJI} <cyan>{}</>", content.as_ref()))
196}
197
198/// Format an info message with emoji (no color - neutral status)
199pub fn info_message(content: impl AsRef<str>) -> FormattedMessage {
200    FormattedMessage(cformat!("{INFO_EMOJI} {}", content.as_ref()))
201}
202
203/// Format a section heading (cyan uppercase text, no emoji)
204///
205/// Used for organizing output into distinct sections. Headings can have
206/// optional suffix info (e.g., path, location).
207///
208/// ```
209/// use worktrunk::styling::format_heading;
210///
211/// // Plain heading
212/// let h = format_heading("BINARIES", None);
213/// // => "BINARIES"
214///
215/// // Heading with suffix
216/// let h = format_heading("USER CONFIG", Some("~/.config/wt.toml"));
217/// // => "USER CONFIG  ~/.config/wt.toml"
218/// ```
219pub fn format_heading(title: &str, suffix: Option<&str>) -> String {
220    match suffix {
221        Some(s) => cformat!("<cyan>{}</>  {}", title, s),
222        None => cformat!("<cyan>{}</>", title),
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // ============================================================================
231    // Style Constants Tests
232    // ============================================================================
233
234    #[test]
235    fn test_addition_style() {
236        // ADDITION should be green foreground
237        let rendered = ADDITION.render().to_string();
238        // Green is ANSI 32
239        assert!(rendered.contains("32"));
240    }
241
242    #[test]
243    fn test_deletion_style() {
244        // DELETION should be red foreground
245        let rendered = DELETION.render().to_string();
246        // Red is ANSI 31
247        assert!(rendered.contains("31"));
248    }
249
250    #[test]
251    fn test_gutter_style() {
252        // GUTTER should have bright white background
253        let rendered = GUTTER.render().to_string();
254        // BrightWhite background is ANSI 107
255        assert!(rendered.contains("107"));
256    }
257
258    // ============================================================================
259    // Emoji Constants Tests
260    // ============================================================================
261
262    #[test]
263    fn test_emoji_constants() {
264        assert_eq!(PROGRESS_EMOJI, "🔄");
265        assert_eq!(SUCCESS_EMOJI, "✅");
266        assert_eq!(ERROR_EMOJI, "❌");
267        assert_eq!(WARNING_EMOJI, "🟡");
268        assert_eq!(HINT_EMOJI, "💡");
269        assert_eq!(INFO_EMOJI, "⚪");
270        assert_eq!(PROMPT_EMOJI, "❓");
271    }
272
273    // ============================================================================
274    // Message Formatting Functions Tests
275    // ============================================================================
276
277    #[test]
278    fn test_error_message() {
279        let msg = error_message("Something went wrong");
280        assert!(msg.as_str().contains("❌"));
281        assert!(msg.as_str().contains("Something went wrong"));
282    }
283
284    #[test]
285    fn test_error_message_with_inner_styling() {
286        let name = "feature";
287        let msg = error_message(cformat!("Branch <bold>{name}</> not found"));
288        assert!(msg.as_str().contains("❌"));
289        assert!(msg.as_str().contains("Branch"));
290        assert!(msg.as_str().contains("feature"));
291    }
292
293    #[test]
294    fn test_hint_message() {
295        let msg = hint_message("Try running --help");
296        assert!(msg.as_str().contains("💡"));
297        assert!(msg.as_str().contains("Try running --help"));
298    }
299
300    #[test]
301    fn test_warning_message() {
302        let msg = warning_message("Deprecated option");
303        assert!(msg.as_str().contains("🟡"));
304        assert!(msg.as_str().contains("Deprecated option"));
305    }
306
307    #[test]
308    fn test_success_message() {
309        let msg = success_message("Operation completed");
310        assert!(msg.as_str().contains("✅"));
311        assert!(msg.as_str().contains("Operation completed"));
312    }
313
314    #[test]
315    fn test_progress_message() {
316        let msg = progress_message("Loading data...");
317        assert!(msg.as_str().contains("🔄"));
318        assert!(msg.as_str().contains("Loading data..."));
319    }
320
321    #[test]
322    fn test_info_message() {
323        let msg = info_message("5 items found");
324        assert!(msg.as_str().contains("⚪"));
325        assert!(msg.as_str().contains("5 items found"));
326    }
327
328    // ============================================================================
329    // format_heading Tests
330    // ============================================================================
331
332    #[test]
333    fn test_format_heading_without_suffix() {
334        let heading = format_heading("BINARIES", None);
335        assert!(heading.contains("BINARIES"));
336        // Should NOT contain extra spacing for suffix
337        assert!(!heading.ends_with("  "));
338    }
339
340    #[test]
341    fn test_format_heading_with_suffix() {
342        let heading = format_heading("USER CONFIG", Some("~/.config/wt.toml"));
343        assert!(heading.contains("USER CONFIG"));
344        assert!(heading.contains("~/.config/wt.toml"));
345        // Should have double-space separator
346        assert!(heading.contains("  "));
347    }
348
349    #[test]
350    fn test_format_heading_empty_title() {
351        let heading = format_heading("", None);
352        // Empty string, still formatted
353        assert!(heading.is_empty() || heading.contains('\u{1b}'));
354    }
355}