Skip to main content

mana/commands/
list.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5use mana_core::ops::list as ops_list;
6
7use crate::blocking::check_blocked;
8use crate::config::resolve_identity;
9use crate::index::{Index, IndexEntry};
10use crate::unit::Status;
11use crate::util::natural_cmp;
12
13/// List units with optional filtering.
14/// - Default: tree-format with status indicators
15/// - --status: filter by status (open, in_progress, closed)
16/// - --priority: filter by priority (0-4)
17/// - --parent: show only children of this parent
18/// - --label: filter by label
19/// - --assignee: filter by assignee
20/// - --all: include closed units (default excludes closed)
21/// - --json: JSON array output
22/// - Shows [!] for blocked units
23///
24/// When --status closed is specified, also searches archived units.
25#[allow(clippy::too_many_arguments)]
26pub fn cmd_list(
27    status_filter: Option<&str>,
28    priority_filter: Option<u8>,
29    parent_filter: Option<&str>,
30    label_filter: Option<&str>,
31    assignee_filter: Option<&str>,
32    mine: bool,
33    all: bool,
34    json: bool,
35    ids: bool,
36    format_str: Option<&str>,
37    mana_dir: &Path,
38) -> Result<()> {
39    let current_user = if mine {
40        let user = resolve_identity(mana_dir);
41        if user.is_none() {
42            anyhow::bail!(
43                "Cannot use --mine: no identity configured.\n\
44                 Set one with: mana config set user <name>"
45            );
46        }
47        user
48    } else {
49        None
50    };
51
52    let filtered = ops_list::list(
53        mana_dir,
54        &ops_list::ListParams {
55            status: status_filter.map(str::to_string),
56            priority: priority_filter,
57            parent: parent_filter.map(str::to_string),
58            label: label_filter.map(str::to_string),
59            assignee: assignee_filter.map(str::to_string),
60            current_user: current_user.clone(),
61            include_closed: all,
62        },
63    )?;
64
65    if json {
66        let json_str = serde_json::to_string_pretty(&filtered)?;
67        println!("{}", json_str);
68    } else if ids {
69        for entry in &filtered {
70            println!("{}", entry.id);
71        }
72    } else if let Some(fmt) = format_str {
73        for entry in &filtered {
74            let line = fmt
75                .replace("{id}", &entry.id)
76                .replace("{title}", &entry.title)
77                .replace("{status}", &format!("{}", entry.status))
78                .replace("{priority}", &format!("P{}", entry.priority))
79                .replace("{parent}", entry.parent.as_deref().unwrap_or(""))
80                .replace("{assignee}", entry.assignee.as_deref().unwrap_or(""))
81                .replace("{labels}", &entry.labels.join(","))
82                .replace("\\t", "\t")
83                .replace("\\n", "\n");
84            println!("{}", line);
85        }
86    } else {
87        let index = Index::load_or_rebuild(mana_dir)?;
88        let include_archived = status_filter == Some("closed") || all;
89        let combined_index = if include_archived {
90            let mut all_units = index.units.clone();
91            if let Ok(archived) = Index::collect_archived(mana_dir) {
92                all_units.extend(archived);
93            }
94            Index { units: all_units }
95        } else {
96            index.clone()
97        };
98
99        let tree = render_tree(&filtered, &combined_index);
100        println!("{}", tree);
101        println!("Legend: [ ] open  [-] in_progress  [x] closed  [!] blocked  [?] has decisions");
102    }
103
104    Ok(())
105}
106
107/// Render units as a hierarchical tree.
108/// - Root units have no parent
109/// - Children indented 2 spaces per level
110/// - Status: [ ] open, [-] in_progress, [x] closed, [!] blocked
111fn render_tree(entries: &[IndexEntry], index: &Index) -> String {
112    let mut output = String::new();
113
114    // Build parent -> children map
115    let mut children_map: HashMap<Option<String>, Vec<&IndexEntry>> = HashMap::new();
116    for entry in entries {
117        children_map
118            .entry(entry.parent.clone())
119            .or_default()
120            .push(entry);
121    }
122
123    // Sort children by id within each parent
124    for children in children_map.values_mut() {
125        children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
126    }
127
128    // Render root entries
129    if let Some(roots) = children_map.get(&None) {
130        for root in roots {
131            render_entry(&mut output, root, 0, &children_map, index);
132        }
133    }
134
135    output
136}
137
138/// Recursively render an entry and its children
139fn render_entry(
140    output: &mut String,
141    entry: &IndexEntry,
142    depth: u32,
143    children_map: &HashMap<Option<String>, Vec<&IndexEntry>>,
144    index: &Index,
145) {
146    let indent = "  ".repeat(depth as usize);
147    let (status_indicator, reason_suffix) = get_status_indicator(entry, index);
148    output.push_str(&format!(
149        "{}{} {}. {}{}\n",
150        indent, status_indicator, entry.id, entry.title, reason_suffix
151    ));
152
153    // Render children
154    if let Some(children) = children_map.get(&Some(entry.id.clone())) {
155        for child in children {
156            render_entry(output, child, depth + 1, children_map, index);
157        }
158    }
159}
160
161/// Get status indicator and optional suffix for an entry.
162/// Returns (indicator, suffix) where suffix is e.g. " (waiting on 3.1)" or " (⚠ oversized)".
163fn get_status_indicator(entry: &IndexEntry, index: &Index) -> (String, String) {
164    if let Some(reason) = check_blocked(entry, index) {
165        ("[!]".to_string(), format!("  ({})", reason))
166    } else {
167        let indicator = match entry.status {
168            Status::Open => "[ ]",
169            Status::InProgress | Status::AwaitingVerify => "[-]",
170            Status::Closed => "[x]",
171        };
172        // Scope warnings are non-blocking annotations
173        let mut suffix = crate::blocking::check_scope_warning(entry)
174            .map(|w| format!("  (⚠ {})", w))
175            .unwrap_or_default();
176        // Add decisions indicator
177        if entry.has_decisions {
178            suffix.push_str("  [?]");
179        }
180        (indicator.to_string(), suffix)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::fs;
188
189    use crate::index::{Index, IndexEntry};
190    use crate::unit::Status;
191    use crate::util::{parse_status, title_to_slug};
192    use tempfile::TempDir;
193
194    fn setup_test_units() -> (TempDir, std::path::PathBuf) {
195        let dir = TempDir::new().unwrap();
196        let mana_dir = dir.path().join(".mana");
197        fs::create_dir(&mana_dir).unwrap();
198
199        // Create some test units
200        let unit1 = crate::unit::Unit::new("1", "First task");
201        let mut unit2 = crate::unit::Unit::new("2", "Second task");
202        unit2.status = Status::InProgress;
203        let mut unit3 = crate::unit::Unit::new("3", "Parent task");
204        unit3.dependencies = vec!["1".to_string()];
205
206        let mut unit3_1 = crate::unit::Unit::new("3.1", "Subtask");
207        unit3_1.parent = Some("3".to_string());
208
209        let slug1 = title_to_slug(&unit1.title);
210        let slug2 = title_to_slug(&unit2.title);
211        let slug3 = title_to_slug(&unit3.title);
212        let slug3_1 = title_to_slug(&unit3_1.title);
213
214        unit1
215            .to_file(mana_dir.join(format!("1-{}.md", slug1)))
216            .unwrap();
217        unit2
218            .to_file(mana_dir.join(format!("2-{}.md", slug2)))
219            .unwrap();
220        unit3
221            .to_file(mana_dir.join(format!("3-{}.md", slug3)))
222            .unwrap();
223        unit3_1
224            .to_file(mana_dir.join(format!("3.1-{}.md", slug3_1)))
225            .unwrap();
226
227        // Create config
228        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 4\n").unwrap();
229
230        (dir, mana_dir)
231    }
232
233    #[test]
234    fn parse_status_valid() {
235        assert_eq!(parse_status("open"), Some(Status::Open));
236        assert_eq!(parse_status("in_progress"), Some(Status::InProgress));
237        assert_eq!(parse_status("closed"), Some(Status::Closed));
238    }
239
240    #[test]
241    fn parse_status_invalid() {
242        assert_eq!(parse_status("invalid"), None);
243        assert_eq!(parse_status(""), None);
244    }
245
246    #[test]
247    fn blocked_by_open_dependency() {
248        let index = Index::build(&setup_test_units().1).unwrap();
249        let entry = index.units.iter().find(|e| e.id == "3").unwrap();
250        // unit 3 depends on unit 1 which is open, so unit 3 is blocked
251        assert!(check_blocked(entry, &index).is_some());
252    }
253
254    #[test]
255    fn not_blocked_when_no_dependencies() {
256        let index = Index::build(&setup_test_units().1).unwrap();
257        let entry = index.units.iter().find(|e| e.id == "1").unwrap();
258        // unit 1 has no deps — unscoped units are no longer blocked
259        let reason = check_blocked(entry, &index);
260        assert!(reason.is_none(), "should not be blocked: {:?}", reason);
261    }
262
263    fn make_scoped_entry(id: &str, status: Status) -> IndexEntry {
264        IndexEntry {
265            id: id.to_string(),
266            title: "Test".to_string(),
267            status,
268            priority: 2,
269            parent: None,
270            dependencies: Vec::new(),
271            labels: Vec::new(),
272            assignee: None,
273            updated_at: chrono::Utc::now(),
274            produces: vec!["Artifact".to_string()],
275            requires: Vec::new(),
276            has_verify: true,
277            verify: None,
278            created_at: chrono::Utc::now(),
279            claimed_by: None,
280            attempts: 0,
281            paths: vec!["src/test.rs".to_string()],
282            feature: false,
283            has_decisions: false,
284        }
285    }
286
287    #[test]
288    fn status_indicator_open() {
289        let entry = make_scoped_entry("1", Status::Open);
290        let index = Index {
291            units: vec![entry.clone()],
292        };
293        assert_eq!(
294            get_status_indicator(&entry, &index),
295            ("[ ]".to_string(), String::new())
296        );
297    }
298
299    #[test]
300    fn status_indicator_in_progress() {
301        let entry = make_scoped_entry("1", Status::InProgress);
302        let index = Index {
303            units: vec![entry.clone()],
304        };
305        assert_eq!(
306            get_status_indicator(&entry, &index),
307            ("[-]".to_string(), String::new())
308        );
309    }
310
311    #[test]
312    fn status_indicator_closed() {
313        let entry = make_scoped_entry("1", Status::Closed);
314        let index = Index {
315            units: vec![entry.clone()],
316        };
317        assert_eq!(
318            get_status_indicator(&entry, &index),
319            ("[x]".to_string(), String::new())
320        );
321    }
322
323    #[test]
324    fn status_indicator_oversized_shows_warning() {
325        let mut entry = make_scoped_entry("1", Status::Open);
326        entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()];
327        let index = Index {
328            units: vec![entry.clone()],
329        };
330        let (indicator, suffix) = get_status_indicator(&entry, &index);
331        // Not blocked — still shows [ ] with a warning suffix
332        assert_eq!(indicator, "[ ]");
333        assert!(suffix.contains("oversized"));
334    }
335
336    #[test]
337    fn status_indicator_unscoped_no_warning() {
338        let mut entry = make_scoped_entry("1", Status::Open);
339        entry.produces = Vec::new();
340        entry.paths = Vec::new();
341        let index = Index {
342            units: vec![entry.clone()],
343        };
344        let (indicator, suffix) = get_status_indicator(&entry, &index);
345        // Unscoped is totally fine — no warning, no blocking
346        assert_eq!(indicator, "[ ]");
347        assert!(suffix.is_empty());
348    }
349
350    #[test]
351    fn render_tree_hierarchy() {
352        let (_dir, mana_dir) = setup_test_units();
353        let index = Index::build(&mana_dir).unwrap();
354        let tree = render_tree(&index.units, &index);
355
356        // Should contain entries
357        assert!(tree.contains("1. First task"));
358        assert!(tree.contains("2. Second task"));
359        assert!(tree.contains("3. Parent task"));
360        assert!(tree.contains("3.1. Subtask"));
361
362        // 3.1 should be indented (child of 3)
363        let lines: Vec<&str> = tree.lines().collect();
364        let line_3 = lines.iter().find(|l| l.contains("3. Parent task")).unwrap();
365        let line_3_1 = lines.iter().find(|l| l.contains("3.1. Subtask")).unwrap();
366
367        // 3.1 should have more indentation than 3
368        let indent_3 = line_3.len() - line_3.trim_start().len();
369        let indent_3_1 = line_3_1.len() - line_3_1.trim_start().len();
370        assert!(indent_3_1 > indent_3);
371    }
372}