Skip to main content

mana/commands/
tree.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::index::Index;
6use crate::unit::Status;
7use crate::util::natural_cmp;
8
9/// Show hierarchical tree of units with status indicators
10/// If id provided: show subtree rooted at that unit
11/// If no id: show full project tree
12pub fn cmd_tree(mana_dir: &Path, id: Option<&str>) -> Result<()> {
13    let index = Index::load_or_rebuild(mana_dir)?;
14
15    if let Some(unit_id) = id {
16        // Show subtree rooted at unit_id
17        print_subtree(&index, unit_id)?;
18    } else {
19        // Show full project tree
20        print_full_tree(&index);
21    }
22
23    Ok(())
24}
25
26fn print_full_tree(index: &Index) {
27    // Find root units (those with no parent)
28    let root_units: Vec<_> = index.units.iter().filter(|e| e.parent.is_none()).collect();
29
30    if root_units.is_empty() {
31        println!("No units found.");
32        return;
33    }
34
35    let mut visited = std::collections::HashSet::new();
36    for root in root_units {
37        print_tree_node(index, &root.id, "", &mut visited);
38    }
39}
40
41fn print_subtree(index: &Index, unit_id: &str) -> Result<()> {
42    let _entry = index
43        .units
44        .iter()
45        .find(|e| e.id == unit_id)
46        .ok_or_else(|| anyhow::anyhow!("Unit {} not found", unit_id))?;
47
48    let mut visited = std::collections::HashSet::new();
49    print_tree_node(index, unit_id, "", &mut visited);
50
51    Ok(())
52}
53
54fn print_tree_node(
55    index: &Index,
56    unit_id: &str,
57    prefix: &str,
58    visited: &mut std::collections::HashSet<String>,
59) {
60    if visited.contains(unit_id) {
61        return;
62    }
63    visited.insert(unit_id.to_string());
64
65    // Find the unit
66    if let Some(entry) = index.units.iter().find(|e| e.id == unit_id) {
67        let status_indicator = match entry.status {
68            Status::Open => "[ ]",
69            Status::InProgress | Status::AwaitingVerify => "[-]",
70            Status::Closed => "[x]",
71        };
72
73        println!(
74            "{}{} {} {}",
75            prefix, status_indicator, entry.id, entry.title
76        );
77    } else {
78        println!("{}[!] {}", prefix, unit_id);
79        return;
80    }
81
82    // Find children (units with this unit as parent)
83    let children: Vec<_> = index
84        .units
85        .iter()
86        .filter(|e| e.parent.as_ref() == Some(&unit_id.to_string()))
87        .collect();
88
89    // Also find dependents (units that depend on this one)
90    let dependents: Vec<_> = index
91        .units
92        .iter()
93        .filter(|e| e.dependencies.contains(&unit_id.to_string()))
94        .collect();
95
96    // Combine and deduplicate
97    let mut all_children = children;
98    for dep in dependents {
99        if !all_children.iter().any(|e| e.id == dep.id) {
100            all_children.push(dep);
101        }
102    }
103
104    // Sort by natural order
105    all_children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
106
107    for (i, child) in all_children.iter().enumerate() {
108        let is_last_child = i == all_children.len() - 1;
109        let connector = if is_last_child {
110            "└── "
111        } else {
112            "├── "
113        };
114        let new_prefix = if is_last_child {
115            format!("{}    ", prefix)
116        } else {
117            format!("{}│   ", prefix)
118        };
119
120        print!("{}{}", prefix, connector);
121        print_tree_node(index, &child.id, &new_prefix, visited);
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::unit::Unit;
129    use std::fs;
130    use tempfile::TempDir;
131
132    fn setup_test_units() -> (TempDir, std::path::PathBuf) {
133        let dir = TempDir::new().unwrap();
134        let mana_dir = dir.path().join(".mana");
135        fs::create_dir(&mana_dir).unwrap();
136
137        // Create a hierarchy:
138        // 1 (root)
139        // ├── 1.1 (child)
140        // ├── 1.2 (child)
141        // 2 (root)
142        // 3 (depends on 1)
143
144        let unit1 = Unit::new("1", "Root task");
145        let mut unit1_1 = Unit::new("1.1", "Subtask");
146        unit1_1.parent = Some("1".to_string());
147        let mut unit1_2 = Unit::new("1.2", "Another subtask");
148        unit1_2.parent = Some("1".to_string());
149        let unit2 = Unit::new("2", "Another root");
150        let mut unit3 = Unit::new("3", "Depends on 1");
151        unit3.dependencies = vec!["1".to_string()];
152
153        unit1.to_file(mana_dir.join("1.yaml")).unwrap();
154        unit1_1.to_file(mana_dir.join("1.1.yaml")).unwrap();
155        unit1_2.to_file(mana_dir.join("1.2.yaml")).unwrap();
156        unit2.to_file(mana_dir.join("2.yaml")).unwrap();
157        unit3.to_file(mana_dir.join("3.yaml")).unwrap();
158
159        (dir, mana_dir)
160    }
161
162    #[test]
163    fn full_tree_displays() {
164        let (_dir, mana_dir) = setup_test_units();
165        let index = Index::load_or_rebuild(&mana_dir).unwrap();
166
167        // Just verify no panic
168        print_full_tree(&index);
169    }
170
171    #[test]
172    fn subtree_works() {
173        let (_dir, mana_dir) = setup_test_units();
174        let index = Index::load_or_rebuild(&mana_dir).unwrap();
175
176        // Just verify no panic
177        let _ = print_subtree(&index, "1");
178    }
179
180    #[test]
181    fn subtree_not_found() {
182        let (_dir, mana_dir) = setup_test_units();
183        let index = Index::load_or_rebuild(&mana_dir).unwrap();
184
185        let result = print_subtree(&index, "nonexistent");
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn status_indicators() {
191        let dir = TempDir::new().unwrap();
192        let mana_dir = dir.path().join(".mana");
193        fs::create_dir(&mana_dir).unwrap();
194
195        let b1 = Unit::new("1", "Open task");
196        let mut b2 = Unit::new("2", "In progress");
197        b2.status = crate::unit::Status::InProgress;
198        let mut b3 = Unit::new("3", "Closed");
199        b3.status = crate::unit::Status::Closed;
200
201        b1.to_file(mana_dir.join("1.yaml")).unwrap();
202        b2.to_file(mana_dir.join("2.yaml")).unwrap();
203        b3.to_file(mana_dir.join("3.yaml")).unwrap();
204
205        let index = Index::load_or_rebuild(&mana_dir).unwrap();
206        print_full_tree(&index);
207    }
208}