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}