scud/commands/
list.rs

1use anyhow::Result;
2use colored::Colorize;
3use std::path::PathBuf;
4
5use crate::commands::helpers::resolve_group_tag;
6use crate::formats::{natural_sort_ids, serialize_scg};
7use crate::models::{Phase, Priority, TaskStatus};
8use crate::storage::Storage;
9
10/// Format status for human display
11fn format_status(status: &TaskStatus) -> String {
12    match status {
13        TaskStatus::Pending => "○ Pending".normal().to_string(),
14        TaskStatus::InProgress => "◐ In Progress".yellow().to_string(),
15        TaskStatus::Done => "● Done".green().to_string(),
16        TaskStatus::Review => "◑ Review".cyan().to_string(),
17        TaskStatus::Blocked => "✗ Blocked".red().to_string(),
18        TaskStatus::Deferred => "◌ Deferred".dimmed().to_string(),
19        TaskStatus::Cancelled => "⊘ Cancelled".dimmed().to_string(),
20        TaskStatus::Expanded => "◈ Expanded".blue().to_string(),
21        TaskStatus::Failed => "✗ Failed".red().bold().to_string(),
22    }
23}
24
25/// Format priority for human display
26fn format_priority(priority: &Priority) -> String {
27    match priority {
28        Priority::Critical => "Crit".red().bold().to_string(),
29        Priority::High => "High".yellow().to_string(),
30        Priority::Medium => "Med".normal().to_string(),
31        Priority::Low => "Low".dimmed().to_string(),
32    }
33}
34
35/// Format agent type for human display
36fn format_agent_type(agent_type: &Option<String>) -> String {
37    match agent_type {
38        Some(at) => at.clone(),
39        None => "-".to_string(),
40    }
41}
42
43/// Truncate long task IDs for display (e.g., UUIDs)
44/// Shows first 8 chars with "..." for IDs longer than 12 chars
45fn format_task_id(id: &str) -> String {
46    if id.len() > 12 {
47        format!("{}...", &id[..8])
48    } else {
49        id.to_string()
50    }
51}
52
53/// Print human-readable task list
54fn print_human_readable(phase: &Phase, phase_tag: &str) {
55    println!("{} {}\n", "Phase:".blue().bold(), phase_tag.cyan());
56
57    if phase.tasks.is_empty() {
58        println!("{}", "(no tasks)".dimmed());
59        return;
60    }
61
62    // Header - use 11 char width for ID column to fit "8chars..." format
63    println!(
64        "{:>4}  {:<11} {:<32} {:<14} {:>4}  {:<5} {}",
65        "#".dimmed(),
66        "ID".dimmed(),
67        "Title".dimmed(),
68        "Status".dimmed(),
69        "Cplx".dimmed(),
70        "Pri".dimmed(),
71        "Agent".dimmed()
72    );
73    println!("{}", "─".repeat(90).dimmed());
74
75    // Sort tasks by ID for display
76    let mut sorted_tasks = phase.tasks.clone();
77    sorted_tasks.sort_by(|a, b| natural_sort_ids(&a.id, &b.id));
78
79    for (idx, task) in sorted_tasks.iter().enumerate() {
80        let title = if task.title.len() > 30 {
81            format!("{}...", &task.title[..27])
82        } else {
83            task.title.clone()
84        };
85
86        println!(
87            "{:>4}  {:<11} {:<32} {:<14} {:>4}  {:<5} {}",
88            (idx + 1).to_string().dimmed(),
89            format_task_id(&task.id).cyan(),
90            title,
91            format_status(&task.status),
92            task.complexity,
93            format_priority(&task.priority),
94            format_agent_type(&task.agent_type).dimmed()
95        );
96    }
97
98    println!();
99    println!("{} {} tasks", "Total:".dimmed(), phase.tasks.len());
100}
101
102pub fn run(
103    project_root: Option<PathBuf>,
104    status_filter: Option<&str>,
105    tag: Option<&str>,
106    json_output: bool,
107    verbose: bool,
108) -> Result<()> {
109    let storage = Storage::new(project_root);
110
111    let phase_tag = resolve_group_tag(&storage, tag, true)?;
112    let tasks = storage.load_tasks()?;
113    let phase = tasks
114        .get(&phase_tag)
115        .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
116
117    let filter_status = status_filter
118        .map(|s| {
119            TaskStatus::from_str(s).ok_or_else(|| {
120                anyhow::anyhow!("Invalid status: {}. Valid: {:?}", s, TaskStatus::all())
121            })
122        })
123        .transpose()?;
124
125    let filtered_phase = if filter_status.is_some() {
126        let filtered_tasks: Vec<_> = phase
127            .tasks
128            .iter()
129            .filter(|t| {
130                filter_status
131                    .as_ref()
132                    .map(|fs| t.status == *fs)
133                    .unwrap_or(true)
134            })
135            .cloned()
136            .collect();
137
138        let mut filtered = Phase::new(phase.name.clone());
139        filtered.tasks = filtered_tasks;
140        filtered
141    } else {
142        phase.clone()
143    };
144
145    if filtered_phase.tasks.is_empty() {
146        if json_output {
147            println!("[]");
148        } else if verbose {
149            println!("# SCUD Graph v1");
150            println!("# Phase: {}", phase_tag);
151            println!();
152            println!("@nodes");
153            println!("# id | title | status | complexity | priority");
154            println!("# (no tasks)");
155        } else {
156            println!("{} {}\n", "Phase:".blue().bold(), phase_tag.cyan());
157            println!("{}", "(no tasks)".dimmed());
158        }
159        return Ok(());
160    }
161
162    if json_output {
163        let json = serde_json::to_string_pretty(&filtered_phase.tasks)?;
164        println!("{}", json);
165    } else if verbose {
166        // Raw SCG format
167        let scg = serialize_scg(&filtered_phase);
168        print!("{}", scg);
169    } else {
170        // Human-readable format (default)
171        print_human_readable(&filtered_phase, &phase_tag);
172    }
173
174    Ok(())
175}