Skip to main content

chant/
formatters.rs

1//! Output formatters for status data
2//!
3//! Provides formatters that transform StatusData into different output formats.
4
5use crate::status::{AttentionItem, InProgressItem, ReadyItem, StatusData, TodayActivity};
6use crate::ui;
7
8/// Format StatusData as regular multi-section text output
9pub fn format_regular_status(data: &StatusData) -> String {
10    let mut output = vec![
11        ui::colors::heading("Chant Status").to_string(),
12        ui::format::separator(12),
13        String::new(),
14        format_counts(&data.counts),
15        String::new(),
16    ];
17
18    // Today section
19    if data.today.completed > 0 || data.today.started > 0 || data.today.created > 0 {
20        output.push(ui::colors::heading("Today").to_string());
21        output.push(ui::format::separator(5));
22        output.push(format_today(&data.today));
23        output.push(String::new());
24    }
25
26    // Attention section (only if there are items)
27    if !data.attention.is_empty() {
28        output.push(ui::colors::heading("Attention").to_string());
29        output.push(ui::format::separator(9));
30        for item in &data.attention {
31            output.push(format_attention_item(item));
32        }
33        output.push(String::new());
34    }
35
36    // In Progress section (only if there are items)
37    if !data.in_progress.is_empty() {
38        output.push(ui::colors::heading("In Progress").to_string());
39        output.push(ui::format::separator(11));
40        for item in &data.in_progress {
41            output.push(format_in_progress_item(item));
42        }
43        output.push(String::new());
44    }
45
46    // Ready section
47    output.push(ui::colors::heading(&format!("Ready ({})", data.ready_count)).to_string());
48    output.push(ui::format::separator(6));
49    if data.ready_count == 0 {
50        output.push(ui::colors::secondary("  (no specs ready)").to_string());
51    } else {
52        for item in &data.ready {
53            output.push(format_ready_item(item));
54        }
55        if data.ready_count > 5 {
56            let remaining = data.ready_count - 5;
57            output
58                .push(ui::colors::secondary(&format!("  ... and {} more", remaining)).to_string());
59        }
60    }
61
62    output.join("\n")
63}
64
65/// Format counts section with aligned numbers
66fn format_counts(counts: &std::collections::HashMap<String, usize>) -> String {
67    let pending = counts.get("pending").copied().unwrap_or(0);
68    let in_progress = counts.get("in_progress").copied().unwrap_or(0);
69    let completed = counts.get("completed").copied().unwrap_or(0);
70    let failed = counts.get("failed").copied().unwrap_or(0);
71    let blocked = counts.get("blocked").copied().unwrap_or(0);
72    let ready = counts.get("ready").copied().unwrap_or(0);
73
74    format!(
75        "  {:<12} {}\n  {:<12} {}\n  {:<12} {}\n  {:<12} {}\n  {:<12} {}\n  {:<12} {}",
76        "Pending:",
77        pending,
78        "Ready:",
79        ready,
80        "In Progress:",
81        in_progress,
82        "Completed:",
83        completed,
84        "Failed:",
85        failed,
86        "Blocked:",
87        blocked,
88    )
89}
90
91/// Format today's activity
92fn format_today(today: &TodayActivity) -> String {
93    let mut parts = Vec::new();
94
95    if today.completed > 0 {
96        parts.push(ui::colors::success(&format!("+{} completed", today.completed)).to_string());
97    }
98    if today.started > 0 {
99        parts.push(ui::colors::warning(&format!("+{} started", today.started)).to_string());
100    }
101    if today.created > 0 {
102        parts.push(ui::colors::info(&format!("+{} created", today.created)).to_string());
103    }
104
105    if parts.is_empty() {
106        ui::colors::secondary("  (no activity today)").to_string()
107    } else {
108        format!("  {}", parts.join(", "))
109    }
110}
111
112/// Format an attention item (failed or blocked)
113fn format_attention_item(item: &AttentionItem) -> String {
114    let symbol = ui::attention_symbol(&item.status);
115
116    let title = item.title.as_deref().unwrap_or("(untitled)");
117    let truncated_title = ui::format::truncate_title(title, 60);
118
119    format!(
120        "  {} {}  {} ({})",
121        symbol,
122        ui::colors::identifier(&item.id),
123        truncated_title,
124        ui::colors::secondary(&item.ago)
125    )
126}
127
128/// Format an in-progress item
129fn format_in_progress_item(item: &InProgressItem) -> String {
130    let title = item.title.as_deref().unwrap_or("(untitled)");
131    let truncated_title = ui::format::truncate_title(title, 60);
132
133    let elapsed_str = ui::format::elapsed_minutes(item.elapsed_minutes);
134
135    format!(
136        "  {} {}  ({})",
137        ui::colors::identifier(&item.id),
138        truncated_title,
139        ui::colors::secondary(&elapsed_str)
140    )
141}
142
143/// Format a ready item
144fn format_ready_item(item: &ReadyItem) -> String {
145    let title = item.title.as_deref().unwrap_or("(untitled)");
146    let truncated_title = ui::format::truncate_title(title, 60);
147
148    format!("  {} {}", ui::colors::identifier(&item.id), truncated_title)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::collections::HashMap;
155
156    #[test]
157    fn test_format_counts() {
158        let mut counts = HashMap::new();
159        counts.insert("pending".to_string(), 5);
160        counts.insert("in_progress".to_string(), 2);
161        counts.insert("completed".to_string(), 10);
162        counts.insert("failed".to_string(), 1);
163        counts.insert("blocked".to_string(), 0);
164        counts.insert("ready".to_string(), 3);
165
166        let result = format_counts(&counts);
167        assert!(result.contains("Pending:"));
168        assert!(result.contains("5"));
169        assert!(result.contains("Ready:"));
170        assert!(result.contains("3"));
171    }
172
173    #[test]
174    fn test_format_today_all_activity() {
175        let today = TodayActivity {
176            completed: 2,
177            started: 1,
178            created: 3,
179        };
180
181        let result = format_today(&today);
182        assert!(result.contains("+2 completed"));
183        assert!(result.contains("+1 started"));
184        assert!(result.contains("+3 created"));
185    }
186
187    #[test]
188    fn test_format_today_no_activity() {
189        let today = TodayActivity {
190            completed: 0,
191            started: 0,
192            created: 0,
193        };
194
195        let result = format_today(&today);
196        assert!(result.contains("no activity"));
197    }
198
199    #[test]
200    fn test_format_regular_status_empty() {
201        let data = StatusData::default();
202        let result = format_regular_status(&data);
203
204        assert!(result.contains("Chant Status"));
205        assert!(result.contains("Ready (0)"));
206        assert!(result.contains("no specs ready"));
207        // Should not contain Attention section header
208        assert!(!result.contains("Attention\n─────────"));
209        // Should contain In Progress in counts but not as a section header with underline
210        assert!(result.contains("In Progress:"));
211        assert!(!result.contains("In Progress\n───────────"));
212    }
213}