1use 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#[derive(Debug, Clone, Default)]
17pub struct TodayActivity {
18 pub completed: usize,
20 pub started: usize,
22 pub created: usize,
24}
25
26#[derive(Debug, Clone)]
28pub struct AttentionItem {
29 pub id: String,
31 pub title: Option<String>,
33 pub status: SpecStatus,
35 pub ago: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct InProgressItem {
42 pub id: String,
44 pub title: Option<String>,
46 pub elapsed_minutes: i64,
48}
49
50#[derive(Debug, Clone)]
52pub struct ReadyItem {
53 pub id: String,
55 pub title: Option<String>,
57}
58
59#[derive(Debug, Clone, Default)]
61pub struct StatusData {
62 pub counts: HashMap<String, usize>,
64 pub today: TodayActivity,
66 pub attention: Vec<AttentionItem>,
68 pub in_progress: Vec<InProgressItem>,
70 pub ready: Vec<ReadyItem>,
72 pub ready_count: usize,
74}
75
76fn 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
98fn parse_timestamp(timestamp: &str) -> Option<DateTime<Local>> {
100 DateTime::parse_from_rfc3339(timestamp)
101 .ok()
102 .map(|dt| dt.with_timezone(&Local))
103}
104
105fn 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
112pub fn aggregate_status(specs_dir: &Path) -> Result<StatusData> {
114 let mut data = StatusData::default();
115
116 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 if !specs_dir.exists() {
127 return Ok(data);
128 }
129
130 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 let today_cutoff = Local::now() - Duration::hours(24);
141
142 let mut ready_specs = Vec::new();
144
145 for spec in &specs {
146 if spec.frontmatter.status == SpecStatus::Cancelled {
148 continue;
149 }
150
151 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, };
162
163 if let Some(count) = data.counts.get_mut(status_key) {
164 *count += 1;
165 }
166
167 if spec.is_ready(&specs) {
169 ready_specs.push(spec);
170 }
171
172 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 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 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 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 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 data.ready_count = ready_specs.len();
237 *data.counts.get_mut("ready").unwrap() = ready_specs.len();
238
239 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
252pub fn format_status_as_json(data: &StatusData) -> Result<String> {
254 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 let json_string = serde_json::to_string_pretty(&json_value)?;
293 Ok(json_string)
294}
295
296impl StatusData {
297 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 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 let recent = now - Duration::seconds(30);
342 assert_eq!(format_ago(recent), "now ago");
343
344 let mins = now - Duration::minutes(45);
346 assert_eq!(format_ago(mins), "45m ago");
347
348 let hours = now - Duration::hours(5);
350 assert_eq!(format_ago(hours), "5h ago");
351
352 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 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
463
464 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 assert_eq!(parsed["counts"]["pending"], 5);
474 assert_eq!(parsed["counts"]["in_progress"], 2);
475 assert_eq!(parsed["counts"]["completed"], 10);
476
477 assert_eq!(parsed["today"]["completed"], 2);
479 assert_eq!(parsed["today"]["started"], 1);
480 assert_eq!(parsed["today"]["created"], 3);
481
482 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 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 assert!(parsed["ready"].is_array());
495 assert_eq!(parsed["ready"][0]["id"], "2026-01-30-ghi");
496
497 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 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 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}