Skip to main content

chant/
ui.rs

1//! Centralized UI formatting and color utilities
2//!
3//! This module provides a unified interface for status colors, icons, and
4//! formatting patterns used throughout the chant CLI.
5
6use colored::{ColoredString, Colorize};
7
8use crate::spec::SpecStatus;
9
10/// Check if quiet mode is enabled via environment variable, --quiet flag, or silent mode
11pub fn is_quiet() -> bool {
12    // Check CHANT_QUIET env var (deprecated, for backwards compatibility)
13    if std::env::var("CHANT_QUIET")
14        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
15        .unwrap_or(false)
16    {
17        return true;
18    }
19
20    // Check if silent mode is enabled in config
21    is_silent_mode()
22}
23
24/// Check if silent mode is enabled in project or global config
25pub fn is_silent_mode() -> bool {
26    // Try to load config and check silent flag
27    crate::config::Config::load()
28        .ok()
29        .map(|config| config.project.silent)
30        .unwrap_or(false)
31}
32
33/// Returns a colored status icon for the given spec status.
34///
35/// Icons:
36/// - Pending: ○ (white)
37/// - InProgress: ◐ (yellow)
38/// - Completed: ● (green)
39/// - Failed: ✗ (red)
40/// - NeedsAttention: ⚠ (yellow)
41/// - Ready: ◕ (cyan)
42/// - Blocked: ⊗ (red)
43/// - Cancelled: ✓ (dimmed)
44pub fn status_icon(status: &SpecStatus) -> ColoredString {
45    match status {
46        SpecStatus::Pending => "○".white(),
47        SpecStatus::InProgress => "◐".yellow(),
48        SpecStatus::Paused => "◑".cyan(),
49        SpecStatus::Completed => "●".green(),
50        SpecStatus::Failed => "✗".red(),
51        SpecStatus::NeedsAttention => "⚠".yellow(),
52        SpecStatus::Ready => "◕".cyan(),
53        SpecStatus::Blocked => "⊗".red(),
54        SpecStatus::Cancelled => "✓".dimmed(),
55    }
56}
57
58/// Returns a colored status symbol for attention items (failed/blocked).
59///
60/// Symbols:
61/// - Failed/NeedsAttention: ✗ (red)
62/// - Blocked: ◌ (yellow)
63/// - Other: ? (normal)
64pub fn attention_symbol(status: &SpecStatus) -> ColoredString {
65    match status {
66        SpecStatus::Failed | SpecStatus::NeedsAttention => "✗".red(),
67        SpecStatus::Blocked => "◌".yellow(),
68        _ => "?".normal(),
69    }
70}
71
72/// Color scheme for status-related text output
73pub mod colors {
74    use colored::{Color, ColoredString, Colorize};
75
76    /// Green for success/completion
77    pub fn success(text: &str) -> ColoredString {
78        text.green()
79    }
80
81    /// Yellow for in-progress/warnings
82    pub fn warning(text: &str) -> ColoredString {
83        text.yellow()
84    }
85
86    /// Red for errors/failures
87    pub fn error(text: &str) -> ColoredString {
88        text.red()
89    }
90
91    /// Cyan for identifiers (spec IDs, etc.)
92    pub fn identifier(text: &str) -> ColoredString {
93        text.cyan()
94    }
95
96    /// Blue for informational text
97    pub fn info(text: &str) -> ColoredString {
98        text.blue()
99    }
100
101    /// Dimmed for secondary text
102    pub fn secondary(text: &str) -> ColoredString {
103        text.dimmed()
104    }
105
106    /// Bold for headings
107    pub fn heading(text: &str) -> ColoredString {
108        text.bold()
109    }
110
111    /// Color for markdown heading levels
112    pub fn markdown_heading(text: &str, level: usize) -> ColoredString {
113        match level {
114            1 => text.bold(),
115            2 => text.bold().cyan(),
116            3 => text.bold().blue(),
117            4 => text.bold().magenta(),
118            _ => text.bold(),
119        }
120    }
121
122    /// Generic colored text
123    pub fn colored(text: &str, color: Color) -> ColoredString {
124        text.color(color)
125    }
126}
127
128/// Common text formatting patterns
129pub mod format {
130    /// Truncate a title to fit terminal width
131    pub fn truncate_title(title: &str, max_len: usize) -> String {
132        if title.len() <= max_len {
133            title.to_string()
134        } else {
135            format!("{}...", &title[..max_len.saturating_sub(3)])
136        }
137    }
138
139    /// Format elapsed time in minutes to human-readable string
140    pub fn elapsed_minutes(minutes: i64) -> String {
141        if minutes < 1 {
142            "just now".to_string()
143        } else if minutes < 60 {
144            format!("{}m", minutes)
145        } else if minutes < 1440 {
146            // Less than 24 hours
147            let hours = minutes / 60;
148            let mins = minutes % 60;
149            if mins == 0 {
150                format!("{}h", hours)
151            } else {
152                format!("{}h {}m", hours, mins)
153            }
154        } else {
155            // 24 hours or more
156            let days = minutes / 1440;
157            format!("{}d", days)
158        }
159    }
160
161    /// Format a separator line for sections
162    pub fn separator(width: usize) -> String {
163        "─".repeat(width)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_status_icon_all_statuses() {
173        status_icon(&SpecStatus::Pending);
174        status_icon(&SpecStatus::InProgress);
175        status_icon(&SpecStatus::Completed);
176        status_icon(&SpecStatus::Failed);
177        status_icon(&SpecStatus::NeedsAttention);
178        status_icon(&SpecStatus::Ready);
179        status_icon(&SpecStatus::Blocked);
180        status_icon(&SpecStatus::Cancelled);
181    }
182
183    #[test]
184    fn test_attention_symbol() {
185        attention_symbol(&SpecStatus::Failed);
186        attention_symbol(&SpecStatus::NeedsAttention);
187        attention_symbol(&SpecStatus::Blocked);
188        attention_symbol(&SpecStatus::Pending);
189    }
190
191    #[test]
192    fn test_truncate_title() {
193        assert_eq!(format::truncate_title("short", 10), "short");
194        assert_eq!(format::truncate_title("exactly ten", 11), "exactly ten");
195        assert_eq!(
196            format::truncate_title("this is a very long title", 10),
197            "this is..."
198        );
199    }
200
201    #[test]
202    fn test_elapsed_minutes() {
203        assert_eq!(format::elapsed_minutes(0), "just now");
204        assert_eq!(format::elapsed_minutes(30), "30m");
205        assert_eq!(format::elapsed_minutes(60), "1h");
206        assert_eq!(format::elapsed_minutes(90), "1h 30m");
207        assert_eq!(format::elapsed_minutes(120), "2h");
208        assert_eq!(format::elapsed_minutes(1440), "1d");
209        assert_eq!(format::elapsed_minutes(2880), "2d");
210    }
211
212    #[test]
213    fn test_separator() {
214        assert_eq!(format::separator(5), "─────");
215        assert_eq!(format::separator(10), "──────────");
216    }
217}