Skip to main content

rusty_commit/output/
styling.rs

1//! Styling constants and helpers for Rusty Commit CLI.
2//!
3//! Provides a consistent color scheme and styling across all output.
4
5use colored::{Color as ColoredColor, Colorize};
6
7/// Primary color palette for Rusty Commit.
8#[derive(Debug, Clone, Copy)]
9#[allow(dead_code)]
10pub struct Palette {
11    /// Primary accent color (used for headers and emphasis).
12    pub primary: Color,
13    /// Secondary accent color (used for subheaders).
14    pub secondary: Color,
15    /// Success color (green).
16    pub success: Color,
17    /// Warning color (amber/yellow).
18    pub warning: Color,
19    /// Error color (red).
20    pub error: Color,
21    /// Neutral/gray color for borders and dividers.
22    pub neutral: Color,
23    /// Dimmed text color.
24    pub dimmed: Color,
25    /// Highlight color for selections.
26    pub highlight: Color,
27}
28
29impl Default for Palette {
30    fn default() -> Self {
31        Self {
32            primary: Color::MutedBlue,
33            secondary: Color::Purple,
34            success: Color::Green,
35            warning: Color::Amber,
36            error: Color::Red,
37            neutral: Color::Gray,
38            dimmed: Color::Gray,
39            highlight: Color::Cyan,
40        }
41    }
42}
43
44/// Extended color definitions beyond standard colored::Color.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[allow(dead_code)]
47pub enum Color {
48    /// Standard colors from colored crate.
49    Standard(ColoredColor),
50    /// Muted blue (primary accent).
51    MutedBlue,
52    /// Muted purple (secondary accent).
53    Purple,
54    /// Muted amber (warning - not bright yellow).
55    Amber,
56    /// Muted red (error - not harsh red).
57    Red,
58    /// Muted green (success - not harsh green).
59    Green,
60    /// Gray (neutral).
61    Gray,
62    /// Bright cyan.
63    Cyan,
64}
65
66impl Color {
67    /// Apply this color to a colored::Colorize string.
68    #[allow(dead_code)]
69    pub fn apply<T: colored::Colorize>(&self, text: T) -> colored::ColoredString {
70        match self {
71            Color::Standard(c) => text.color(*c),
72            Color::MutedBlue => text.cyan(),
73            Color::Purple => text.purple(),
74            Color::Amber => text.yellow(),
75            Color::Red => text.red(),
76            Color::Green => text.green(),
77            Color::Gray => text.dimmed(),
78            Color::Cyan => text.cyan(),
79        }
80    }
81
82    /// Get the underlying colored::Color.
83    #[allow(dead_code)]
84    pub fn to_colored(self) -> ColoredColor {
85        match self {
86            Color::Standard(c) => c,
87            Color::MutedBlue => ColoredColor::Cyan,
88            Color::Purple => ColoredColor::Magenta,
89            Color::Amber => ColoredColor::Yellow,
90            Color::Red => ColoredColor::Red,
91            Color::Green => ColoredColor::Green,
92            Color::Gray => ColoredColor::White,
93            Color::Cyan => ColoredColor::Cyan,
94        }
95    }
96}
97
98/// Output theme configuration.
99#[derive(Debug, Clone, Copy, Default)]
100#[allow(dead_code)]
101pub struct Theme {
102    /// Whether to use colors in output.
103    pub use_colors: bool,
104    /// Whether to use emojis.
105    pub use_emoji: bool,
106    /// Character to use for dividers.
107    pub divider_char: char,
108    /// Box drawing characters.
109    pub box_chars: BoxStyle,
110    /// Color palette.
111    pub palette: Palette,
112}
113
114impl Theme {
115    /// Create a new theme with default settings.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Create a theme for minimal output (no colors, no emoji).
121    #[allow(dead_code)]
122    pub fn minimal() -> Self {
123        Self {
124            use_colors: false,
125            use_emoji: false,
126            divider_char: '-',
127            box_chars: BoxStyle::Ascii,
128            palette: Palette::default(),
129        }
130    }
131
132    /// Create a theme for JSON output.
133    #[allow(dead_code)]
134    pub fn json() -> Self {
135        Self {
136            use_colors: false,
137            use_emoji: false,
138            divider_char: '-',
139            box_chars: BoxStyle::None,
140            palette: Palette::default(),
141        }
142    }
143
144    /// Create a theme for markdown output.
145    #[allow(dead_code)]
146    pub fn markdown() -> Self {
147        Self {
148            use_colors: false,
149            use_emoji: true,
150            divider_char: '-',
151            box_chars: BoxStyle::None,
152            palette: Palette::default(),
153        }
154    }
155}
156
157/// Box drawing style for panels and sections.
158#[derive(Debug, Clone, Copy, Default)]
159#[allow(dead_code)]
160pub enum BoxStyle {
161    /// No box drawing characters.
162    #[default]
163    None,
164    /// ASCII characters only.
165    Ascii,
166    /// Unicode box drawing characters (rounded corners).
167    Unicode,
168    /// Unicode box drawing characters (sharp corners).
169    UnicodeSharp,
170}
171
172impl BoxStyle {
173    /// Get the corner characters for this box style.
174    pub fn corners(&self) -> (char, char, char, char) {
175        match self {
176            BoxStyle::None => (' ', ' ', ' ', ' '),
177            BoxStyle::Ascii => ('+', '+', '+', '+'),
178            BoxStyle::Unicode => ('╭', '╮', '╰', '╯'),
179            BoxStyle::UnicodeSharp => ('┌', '┐', '└', '┘'),
180        }
181    }
182
183    /// Get the horizontal line character.
184    pub fn horizontal(&self) -> char {
185        match self {
186            BoxStyle::None => ' ',
187            BoxStyle::Ascii => '-',
188            BoxStyle::Unicode | BoxStyle::UnicodeSharp => '─',
189        }
190    }
191
192    /// Get the vertical line character.
193    pub fn vertical(&self) -> char {
194        match self {
195            BoxStyle::None | BoxStyle::Ascii => '|',
196            BoxStyle::Unicode | BoxStyle::UnicodeSharp => '│',
197        }
198    }
199}
200
201/// Styling utilities and helpers.
202#[derive(Debug, Clone, Default)]
203pub struct Styling;
204
205#[allow(dead_code)]
206impl Styling {
207    /// Get the styled header format.
208    pub fn header(text: &str) -> String {
209        format!("{}", text.bold())
210    }
211
212    /// Get the styled subheader format.
213    pub fn subheader(text: &str) -> String {
214        format!("{}", text.dimmed())
215    }
216
217    /// Get the styled success format.
218    pub fn success(text: &str) -> String {
219        format!("{}", text.green())
220    }
221
222    /// Get the styled warning format.
223    pub fn warning(text: &str) -> String {
224        format!("{}", text.yellow())
225    }
226
227    /// Get the styled error format.
228    pub fn error(text: &str) -> String {
229        format!("{}", text.red())
230    }
231
232    /// Get the styled info format.
233    pub fn info(text: &str) -> String {
234        format!("{}", text.cyan())
235    }
236
237    /// Get the styled hint format.
238    pub fn hint(text: &str) -> String {
239        format!("{}", text.dimmed())
240    }
241
242    /// Create a divider line of specified length.
243    pub fn divider(length: usize) -> String {
244        "─".repeat(length)
245    }
246
247    /// Create a section box with title.
248    pub fn section_box(title: &str, content: &str, theme: &Theme) -> String {
249        let width = 60;
250        let horizontal = theme.box_chars.horizontal();
251        let (tl, tr, bl, br) = theme.box_chars.corners();
252
253        let mut result = String::new();
254
255        // Top border
256        result.push(tl);
257        result.push_str(&format!("{} ", title).bold().to_string());
258        for _ in title.len() + 2..width - 1 {
259            result.push(horizontal);
260        }
261        result.push(tr);
262        result.push('\n');
263
264        // Content
265        for line in content.lines() {
266            result.push(theme.box_chars.vertical());
267            result.push(' ');
268            result.push_str(line);
269            // Pad to width
270            for _ in line.len() + 1..width - 1 {
271                result.push(' ');
272            }
273            result.push(theme.box_chars.vertical());
274            result.push('\n');
275        }
276
277        // Bottom border
278        result.push(bl);
279        for _ in 0..width - 1 {
280            result.push(horizontal);
281        }
282        result.push(br);
283
284        result
285    }
286
287    /// Format a key-value pair.
288    pub fn key_value(key: &str, value: &str) -> String {
289        format!("{}: {}", key.dimmed(), value)
290    }
291
292    /// Format a timing entry.
293    pub fn timing(component: &str, duration_ms: u64) -> String {
294        let duration = if duration_ms < 1000 {
295            format!("{}ms", duration_ms)
296        } else {
297            format!("{:.1}s", duration_ms as f64 / 1000.0)
298        };
299        format!("{} {}", component.dimmed(), duration.green())
300    }
301
302    /// Print a section with header, divider, content, and closing divider.
303    pub fn print_section(title: &str, content: &str) {
304        let divider = Self::divider(50);
305        println!("\n{}", title.cyan().bold());
306        println!("{}", divider.dimmed());
307        println!("{}", content);
308        println!("{}", divider.dimmed());
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_divider_length() {
318        let d = Styling::divider(10);
319        // Use chars().count() for unicode-aware length
320        assert_eq!(d.chars().count(), 10);
321        assert!(d.chars().all(|c| c == '─'));
322    }
323
324    #[test]
325    fn test_key_value_format() {
326        let kv = Styling::key_value("Key", "Value");
327        assert!(kv.contains("Key:"));
328        assert!(kv.contains("Value"));
329    }
330
331    #[test]
332    fn test_timing_ms() {
333        let t = Styling::timing("test", 500);
334        assert!(t.contains("500ms"));
335    }
336
337    #[test]
338    fn test_timing_seconds() {
339        let t = Styling::timing("test", 2500);
340        assert!(t.contains("2.5s"));
341    }
342
343    #[test]
344    fn test_theme_has_colors_option() {
345        let theme = Theme::new();
346        // Theme should have a use_colors field (may be true or false depending on tty)
347        let _ = theme.use_colors;
348    }
349
350    #[test]
351    fn test_palette_colors() {
352        let palette = Palette::default();
353        // Verify palette has valid colors
354        match palette.primary {
355            Color::MutedBlue => {}
356            Color::Standard(_) => {}
357            _ => panic!("Expected MutedBlue or Standard color"),
358        }
359    }
360}
361
362/// Emoji constants for consistent usage.
363#[allow(dead_code)]
364pub mod emoji {
365    use once_cell::sync::Lazy;
366
367    /// Check mark for success.
368    pub static CHECK: Lazy<&'static str> = Lazy::new(|| "✓");
369    /// Cross mark for errors.
370    pub static CROSS: Lazy<&'static str> = Lazy::new(|| "✗");
371    /// Light bulb for hints.
372    pub static HINT: Lazy<&'static str> = Lazy::new(|| "💡");
373    /// Key for authentication.
374    pub static KEY: Lazy<&'static str> = Lazy::new(|| "🔐");
375}