Skip to main content

bn/commands/
tree.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::bean::Status;
6use crate::index::Index;
7use crate::util::natural_cmp;
8
9/// Show hierarchical tree of beans with status indicators
10/// If id provided: show subtree rooted at that bean
11/// If no id: show full project tree
12pub fn cmd_tree(beans_dir: &Path, id: Option<&str>) -> Result<()> {
13    let index = Index::load_or_rebuild(beans_dir)?;
14
15    if let Some(bean_id) = id {
16        // Show subtree rooted at bean_id
17        print_subtree(&index, bean_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 beans (those with no parent)
28    let root_beans: Vec<_> = index.beans.iter().filter(|e| e.parent.is_none()).collect();
29
30    if root_beans.is_empty() {
31        println!("No beans found.");
32        return;
33    }
34
35    let mut visited = std::collections::HashSet::new();
36    for root in root_beans {
37        print_tree_node(index, &root.id, "", &mut visited);
38    }
39}
40
41fn print_subtree(index: &Index, bean_id: &str) -> Result<()> {
42    let _entry = index
43        .beans
44        .iter()
45        .find(|e| e.id == bean_id)
46        .ok_or_else(|| anyhow::anyhow!("Bean {} not found", bean_id))?;
47
48    let mut visited = std::collections::HashSet::new();
49    print_tree_node(index, bean_id, "", &mut visited);
50
51    Ok(())
52}
53
54fn print_tree_node(
55    index: &Index,
56    bean_id: &str,
57    prefix: &str,
58    visited: &mut std::collections::HashSet<String>,
59) {
60    if visited.contains(bean_id) {
61        return;
62    }
63    visited.insert(bean_id.to_string());
64
65    // Find the bean
66    if let Some(entry) = index.beans.iter().find(|e| e.id == bean_id) {
67        let status_indicator = match entry.status {
68            Status::Open => "[ ]",
69            Status::InProgress => "[-]",
70            Status::Closed => "[x]",
71        };
72
73        println!(
74            "{}{} {} {}",
75            prefix, status_indicator, entry.id, entry.title
76        );
77    } else {
78        println!("{}[!] {}", prefix, bean_id);
79        return;
80    }
81
82    // Find children (beans with this bean as parent)
83    let children: Vec<_> = index
84        .beans
85        .iter()
86        .filter(|e| e.parent.as_ref() == Some(&bean_id.to_string()))
87        .collect();
88
89    // Also find dependents (beans that depend on this one)
90    let dependents: Vec<_> = index
91        .beans
92        .iter()
93        .filter(|e| e.dependencies.contains(&bean_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::bean::Bean;
129    use std::fs;
130    use tempfile::TempDir;
131
132    fn setup_test_beans() -> (TempDir, std::path::PathBuf) {
133        let dir = TempDir::new().unwrap();
134        let beans_dir = dir.path().join(".beans");
135        fs::create_dir(&beans_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 bean1 = Bean::new("1", "Root task");
145        let mut bean1_1 = Bean::new("1.1", "Subtask");
146        bean1_1.parent = Some("1".to_string());
147        let mut bean1_2 = Bean::new("1.2", "Another subtask");
148        bean1_2.parent = Some("1".to_string());
149        let bean2 = Bean::new("2", "Another root");
150        let mut bean3 = Bean::new("3", "Depends on 1");
151        bean3.dependencies = vec!["1".to_string()];
152
153        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
154        bean1_1.to_file(beans_dir.join("1.1.yaml")).unwrap();
155        bean1_2.to_file(beans_dir.join("1.2.yaml")).unwrap();
156        bean2.to_file(beans_dir.join("2.yaml")).unwrap();
157        bean3.to_file(beans_dir.join("3.yaml")).unwrap();
158
159        (dir, beans_dir)
160    }
161
162    #[test]
163    fn full_tree_displays() {
164        let (_dir, beans_dir) = setup_test_beans();
165        let index = Index::load_or_rebuild(&beans_dir).unwrap();
166
167        // Just verify no panic
168        print_full_tree(&index);
169    }
170
171    #[test]
172    fn subtree_works() {
173        let (_dir, beans_dir) = setup_test_beans();
174        let index = Index::load_or_rebuild(&beans_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, beans_dir) = setup_test_beans();
183        let index = Index::load_or_rebuild(&beans_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 beans_dir = dir.path().join(".beans");
193        fs::create_dir(&beans_dir).unwrap();
194
195        let b1 = Bean::new("1", "Open task");
196        let mut b2 = Bean::new("2", "In progress");
197        b2.status = crate::bean::Status::InProgress;
198        let mut b3 = Bean::new("3", "Closed");
199        b3.status = crate::bean::Status::Closed;
200
201        b1.to_file(beans_dir.join("1.yaml")).unwrap();
202        b2.to_file(beans_dir.join("2.yaml")).unwrap();
203        b3.to_file(beans_dir.join("3.yaml")).unwrap();
204
205        let index = Index::load_or_rebuild(&beans_dir).unwrap();
206        print_full_tree(&index);
207    }
208}