Skip to main content

chant/
status.rs

1//! Status data aggregation for specs
2//!
3//! Provides functionality to aggregate status information across all specs,
4//! including counts by status, today's activity, attention items, and ready queue.
5
6use anyhow::Result;
7use chrono::{DateTime, Duration, Local};
8use serde_json::json;
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13use crate::spec::{self, SpecStatus};
14
15/// Activity data for today (last 24 hours)
16#[derive(Debug, Clone, Default)]
17pub struct TodayActivity {
18    /// Number of specs completed today
19    pub completed: usize,
20    /// Number of specs started (moved to in_progress) today
21    pub started: usize,
22    /// Number of specs created today
23    pub created: usize,
24}
25
26/// A spec requiring attention (failed or blocked)
27#[derive(Debug, Clone)]
28pub struct AttentionItem {
29    /// Spec ID
30    pub id: String,
31    /// Spec title
32    pub title: Option<String>,
33    /// Current status
34    pub status: SpecStatus,
35    /// How long ago the status changed (e.g., "2h ago", "3d ago")
36    pub ago: String,
37}
38
39/// A spec currently in progress
40#[derive(Debug, Clone)]
41pub struct InProgressItem {
42    /// Spec ID
43    pub id: String,
44    /// Spec title
45    pub title: Option<String>,
46    /// Elapsed time in minutes since status changed to in_progress
47    pub elapsed_minutes: i64,
48}
49
50/// A spec in the ready queue
51#[derive(Debug, Clone)]
52pub struct ReadyItem {
53    /// Spec ID
54    pub id: String,
55    /// Spec title
56    pub title: Option<String>,
57}
58
59/// Aggregated status data for all specs
60#[derive(Debug, Clone, Default)]
61pub struct StatusData {
62    /// Counts by status
63    pub counts: HashMap<String, usize>,
64    /// Today's activity
65    pub today: TodayActivity,
66    /// Items requiring attention
67    pub attention: Vec<AttentionItem>,
68    /// In-progress items
69    pub in_progress: Vec<InProgressItem>,
70    /// Ready queue items (first 5)
71    pub ready: Vec<ReadyItem>,
72    /// Total count of ready items
73    pub ready_count: usize,
74}
75
76/// Format a duration as a relative time string (e.g., "2h ago", "3d ago")
77fn format_ago(datetime: DateTime<Local>) -> String {
78    let now = Local::now();
79    let duration = now.signed_duration_since(datetime);
80
81    let time_str = if duration.num_minutes() < 1 {
82        "now".to_string()
83    } else if duration.num_minutes() < 60 {
84        format!("{}m", duration.num_minutes())
85    } else if duration.num_hours() < 24 {
86        format!("{}h", duration.num_hours())
87    } else if duration.num_days() < 7 {
88        format!("{}d", duration.num_days())
89    } else if duration.num_weeks() < 4 {
90        format!("{}w", duration.num_weeks())
91    } else {
92        format!("{}mo", duration.num_days() / 30)
93    };
94
95    format!("{} ago", time_str)
96}
97
98/// Parse an ISO 8601 timestamp string to Local datetime
99fn parse_timestamp(timestamp: &str) -> Option<DateTime<Local>> {
100    DateTime::parse_from_rfc3339(timestamp)
101        .ok()
102        .map(|dt| dt.with_timezone(&Local))
103}
104
105/// Get the last modification time of a file
106fn get_file_modified_time(path: &Path) -> Option<DateTime<Local>> {
107    fs::metadata(path)
108        .ok()
109        .and_then(|metadata| metadata.modified().ok().map(DateTime::<Local>::from))
110}
111
112/// Aggregate status data from all specs in the specs directory
113pub fn aggregate_status(specs_dir: &Path) -> Result<StatusData> {
114    let mut data = StatusData::default();
115
116    // Initialize all status counts to 0
117    data.counts.insert("pending".to_string(), 0);
118    data.counts.insert("in_progress".to_string(), 0);
119    data.counts.insert("paused".to_string(), 0);
120    data.counts.insert("completed".to_string(), 0);
121    data.counts.insert("failed".to_string(), 0);
122    data.counts.insert("blocked".to_string(), 0);
123    data.counts.insert("ready".to_string(), 0);
124
125    // Empty specs directory - return early
126    if !specs_dir.exists() {
127        return Ok(data);
128    }
129
130    // Load all specs
131    let specs = match spec::load_all_specs(specs_dir) {
132        Ok(specs) => specs,
133        Err(e) => {
134            eprintln!("Warning: Failed to load specs: {}", e);
135            return Ok(data);
136        }
137    };
138
139    // Calculate today's cutoff (24 hours ago)
140    let today_cutoff = Local::now() - Duration::hours(24);
141
142    // Track which specs are ready (for ready queue)
143    let mut ready_specs = Vec::new();
144
145    for spec in &specs {
146        // Skip cancelled specs
147        if spec.frontmatter.status == SpecStatus::Cancelled {
148            continue;
149        }
150
151        // Count by status
152        let status_key = match spec.frontmatter.status {
153            SpecStatus::Pending => "pending",
154            SpecStatus::InProgress => "in_progress",
155            SpecStatus::Paused => "paused",
156            SpecStatus::Completed => "completed",
157            SpecStatus::Failed | SpecStatus::NeedsAttention => "failed",
158            SpecStatus::Blocked => "blocked",
159            SpecStatus::Ready => "ready",
160            SpecStatus::Cancelled => continue, // Already filtered above
161        };
162
163        if let Some(count) = data.counts.get_mut(status_key) {
164            *count += 1;
165        }
166
167        // Track ready specs
168        if spec.is_ready(&specs) {
169            ready_specs.push(spec);
170        }
171
172        // Today's activity - completed specs
173        if spec.frontmatter.status == SpecStatus::Completed {
174            if let Some(ref completed_at) = spec.frontmatter.completed_at {
175                if let Some(completed_time) = parse_timestamp(completed_at) {
176                    if completed_time >= today_cutoff {
177                        data.today.completed += 1;
178                    }
179                }
180            }
181        }
182
183        // Today's activity - started specs (moved to in_progress)
184        // We approximate this using file modification time since we don't track status change history
185        if spec.frontmatter.status == SpecStatus::InProgress {
186            let spec_path = specs_dir.join(format!("{}.md", spec.id));
187            if let Some(modified_time) = get_file_modified_time(&spec_path) {
188                if modified_time >= today_cutoff {
189                    data.today.started += 1;
190                }
191            }
192        }
193
194        // Today's activity - created specs
195        // Use file creation time as proxy for spec creation
196        let spec_path = specs_dir.join(format!("{}.md", spec.id));
197        if let Some(created_time) = get_file_modified_time(&spec_path) {
198            if created_time >= today_cutoff {
199                data.today.created += 1;
200            }
201        }
202
203        // Attention items (failed or blocked)
204        if matches!(
205            spec.frontmatter.status,
206            SpecStatus::Failed | SpecStatus::NeedsAttention | SpecStatus::Blocked
207        ) {
208            let spec_path = specs_dir.join(format!("{}.md", spec.id));
209            if let Some(modified_time) = get_file_modified_time(&spec_path) {
210                data.attention.push(AttentionItem {
211                    id: spec.id.clone(),
212                    title: spec.title.clone(),
213                    status: spec.frontmatter.status.clone(),
214                    ago: format_ago(modified_time),
215                });
216            }
217        }
218
219        // In-progress items
220        if spec.frontmatter.status == SpecStatus::InProgress {
221            let spec_path = specs_dir.join(format!("{}.md", spec.id));
222            if let Some(modified_time) = get_file_modified_time(&spec_path) {
223                let elapsed = Local::now()
224                    .signed_duration_since(modified_time)
225                    .num_minutes();
226                data.in_progress.push(InProgressItem {
227                    id: spec.id.clone(),
228                    title: spec.title.clone(),
229                    elapsed_minutes: elapsed,
230                });
231            }
232        }
233    }
234
235    // Update ready count
236    data.ready_count = ready_specs.len();
237    *data.counts.get_mut("ready").unwrap() = ready_specs.len();
238
239    // Ready queue (first 5)
240    data.ready = ready_specs
241        .iter()
242        .take(5)
243        .map(|spec| ReadyItem {
244            id: spec.id.clone(),
245            title: spec.title.clone(),
246        })
247        .collect();
248
249    Ok(data)
250}
251
252/// Format StatusData as pretty-printed JSON
253pub fn format_status_as_json(data: &StatusData) -> Result<String> {
254    // Build JSON structure matching the spec requirements
255    let json_value = json!({
256        "counts": data.counts,
257        "today": {
258            "completed": data.today.completed,
259            "started": data.today.started,
260            "created": data.today.created,
261        },
262        "attention": data.attention.iter().map(|item| {
263            json!({
264                "id": item.id,
265                "title": item.title,
266                "status": match item.status {
267                    SpecStatus::Failed => "failed",
268                    SpecStatus::NeedsAttention => "needs_attention",
269                    SpecStatus::Blocked => "blocked",
270                    _ => "unknown",
271                },
272                "ago": item.ago,
273            })
274        }).collect::<Vec<_>>(),
275        "in_progress": data.in_progress.iter().map(|item| {
276            json!({
277                "id": item.id,
278                "title": item.title,
279                "elapsed_minutes": item.elapsed_minutes,
280            })
281        }).collect::<Vec<_>>(),
282        "ready": data.ready.iter().map(|item| {
283            json!({
284                "id": item.id,
285                "title": item.title,
286            })
287        }).collect::<Vec<_>>(),
288        "ready_count": data.ready_count,
289    });
290
291    // Pretty-print with 2-space indentation
292    let json_string = serde_json::to_string_pretty(&json_value)?;
293    Ok(json_string)
294}
295
296impl StatusData {
297    /// Format status data as a brief single-line summary
298    ///
299    /// Output format: "chant: X done, Y running, Z ready, W failed"
300    /// Omits sections with 0 count.
301    /// Special case: if all counts are 0, returns "chant: no specs"
302    pub fn format_brief(&self) -> String {
303        let completed = *self.counts.get("completed").unwrap_or(&0);
304        let in_progress = *self.counts.get("in_progress").unwrap_or(&0);
305        let ready = *self.counts.get("ready").unwrap_or(&0);
306        let failed = *self.counts.get("failed").unwrap_or(&0);
307
308        // Special case: no specs at all
309        if completed == 0 && in_progress == 0 && ready == 0 && failed == 0 {
310            return "chant: no specs".to_string();
311        }
312
313        let mut parts = Vec::new();
314
315        if completed > 0 {
316            parts.push(format!("{} done", completed));
317        }
318        if in_progress > 0 {
319            parts.push(format!("{} running", in_progress));
320        }
321        if ready > 0 {
322            parts.push(format!("{} ready", ready));
323        }
324        if failed > 0 {
325            parts.push(format!("{} failed", failed));
326        }
327
328        format!("chant: {}", parts.join(", "))
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_format_ago() {
338        let now = Local::now();
339
340        // Less than a minute
341        let recent = now - Duration::seconds(30);
342        assert_eq!(format_ago(recent), "now ago");
343
344        // Minutes
345        let mins = now - Duration::minutes(45);
346        assert_eq!(format_ago(mins), "45m ago");
347
348        // Hours
349        let hours = now - Duration::hours(5);
350        assert_eq!(format_ago(hours), "5h ago");
351
352        // Days
353        let days = now - Duration::days(3);
354        assert_eq!(format_ago(days), "3d ago");
355    }
356
357    #[test]
358    fn test_empty_specs_directory() {
359        let temp_dir = tempfile::tempdir().unwrap();
360        let specs_dir = temp_dir.path().join("nonexistent");
361
362        let result = aggregate_status(&specs_dir).unwrap();
363
364        assert_eq!(*result.counts.get("pending").unwrap(), 0);
365        assert_eq!(*result.counts.get("in_progress").unwrap(), 0);
366        assert_eq!(*result.counts.get("completed").unwrap(), 0);
367        assert_eq!(*result.counts.get("failed").unwrap(), 0);
368        assert_eq!(result.today.completed, 0);
369        assert_eq!(result.today.started, 0);
370        assert_eq!(result.today.created, 0);
371        assert!(result.attention.is_empty());
372        assert!(result.in_progress.is_empty());
373        assert!(result.ready.is_empty());
374        assert_eq!(result.ready_count, 0);
375    }
376
377    #[test]
378    fn test_format_brief_no_specs() {
379        let data = StatusData::default();
380        assert_eq!(data.format_brief(), "chant: no specs");
381    }
382
383    #[test]
384    fn test_format_brief_all_statuses() {
385        let mut data = StatusData::default();
386        data.counts.insert("completed".to_string(), 45);
387        data.counts.insert("in_progress".to_string(), 3);
388        data.counts.insert("ready".to_string(), 8);
389        data.counts.insert("failed".to_string(), 1);
390
391        assert_eq!(
392            data.format_brief(),
393            "chant: 45 done, 3 running, 8 ready, 1 failed"
394        );
395    }
396
397    #[test]
398    fn test_format_brief_only_completed() {
399        let mut data = StatusData::default();
400        data.counts.insert("completed".to_string(), 10);
401
402        assert_eq!(data.format_brief(), "chant: 10 done");
403    }
404
405    #[test]
406    fn test_format_brief_omit_zero_counts() {
407        let mut data = StatusData::default();
408        data.counts.insert("completed".to_string(), 5);
409        data.counts.insert("in_progress".to_string(), 0);
410        data.counts.insert("ready".to_string(), 2);
411        data.counts.insert("failed".to_string(), 0);
412
413        assert_eq!(data.format_brief(), "chant: 5 done, 2 ready");
414    }
415
416    #[test]
417    fn test_format_brief_single_line() {
418        let mut data = StatusData::default();
419        data.counts.insert("completed".to_string(), 100);
420        data.counts.insert("in_progress".to_string(), 50);
421
422        let result = data.format_brief();
423        assert!(!result.contains('\n'));
424    }
425
426    #[test]
427    fn test_format_status_as_json_all_fields() {
428        let mut data = StatusData::default();
429        data.counts.insert("pending".to_string(), 5);
430        data.counts.insert("in_progress".to_string(), 2);
431        data.counts.insert("completed".to_string(), 10);
432        data.counts.insert("failed".to_string(), 1);
433        data.counts.insert("blocked".to_string(), 0);
434        data.counts.insert("ready".to_string(), 3);
435
436        data.today.completed = 2;
437        data.today.started = 1;
438        data.today.created = 3;
439
440        data.attention.push(AttentionItem {
441            id: "2026-01-30-abc".to_string(),
442            title: Some("Fix bug".to_string()),
443            status: SpecStatus::Failed,
444            ago: "2h ago".to_string(),
445        });
446
447        data.in_progress.push(InProgressItem {
448            id: "2026-01-30-def".to_string(),
449            title: Some("Add feature".to_string()),
450            elapsed_minutes: 45,
451        });
452
453        data.ready.push(ReadyItem {
454            id: "2026-01-30-ghi".to_string(),
455            title: Some("Ready task".to_string()),
456        });
457        data.ready_count = 3;
458
459        let json_str = format_status_as_json(&data).unwrap();
460
461        // Verify it's valid JSON
462        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
463
464        // Verify top-level keys
465        assert!(parsed.get("counts").is_some());
466        assert!(parsed.get("today").is_some());
467        assert!(parsed.get("attention").is_some());
468        assert!(parsed.get("in_progress").is_some());
469        assert!(parsed.get("ready").is_some());
470        assert!(parsed.get("ready_count").is_some());
471
472        // Verify counts structure
473        assert_eq!(parsed["counts"]["pending"], 5);
474        assert_eq!(parsed["counts"]["in_progress"], 2);
475        assert_eq!(parsed["counts"]["completed"], 10);
476
477        // Verify today structure
478        assert_eq!(parsed["today"]["completed"], 2);
479        assert_eq!(parsed["today"]["started"], 1);
480        assert_eq!(parsed["today"]["created"], 3);
481
482        // Verify attention array
483        assert!(parsed["attention"].is_array());
484        assert_eq!(parsed["attention"][0]["id"], "2026-01-30-abc");
485        assert_eq!(parsed["attention"][0]["status"], "failed");
486        assert_eq!(parsed["attention"][0]["ago"], "2h ago");
487
488        // Verify in_progress array
489        assert!(parsed["in_progress"].is_array());
490        assert_eq!(parsed["in_progress"][0]["id"], "2026-01-30-def");
491        assert_eq!(parsed["in_progress"][0]["elapsed_minutes"], 45);
492
493        // Verify ready array
494        assert!(parsed["ready"].is_array());
495        assert_eq!(parsed["ready"][0]["id"], "2026-01-30-ghi");
496
497        // Verify ready_count
498        assert_eq!(parsed["ready_count"], 3);
499    }
500
501    #[test]
502    fn test_format_status_as_json_empty_lists() {
503        let mut data = StatusData::default();
504        data.counts.insert("pending".to_string(), 0);
505        data.counts.insert("in_progress".to_string(), 0);
506
507        let json_str = format_status_as_json(&data).unwrap();
508        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
509
510        // Empty lists should be empty arrays, not null
511        assert!(parsed["attention"].is_array());
512        assert_eq!(parsed["attention"].as_array().unwrap().len(), 0);
513        assert!(parsed["in_progress"].is_array());
514        assert_eq!(parsed["in_progress"].as_array().unwrap().len(), 0);
515        assert!(parsed["ready"].is_array());
516        assert_eq!(parsed["ready"].as_array().unwrap().len(), 0);
517    }
518
519    #[test]
520    fn test_format_status_as_json_special_characters() {
521        let mut data = StatusData::default();
522        data.ready.push(ReadyItem {
523            id: "2026-01-30-xyz".to_string(),
524            title: Some("Title with \"quotes\" and \\ backslash".to_string()),
525        });
526
527        let json_str = format_status_as_json(&data).unwrap();
528
529        // Verify it's valid JSON (should properly escape special chars)
530        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
531        assert_eq!(
532            parsed["ready"][0]["title"],
533            "Title with \"quotes\" and \\ backslash"
534        );
535    }
536}