1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::bean::Status;
6use crate::index::Index;
7use crate::util::natural_cmp;
8
9pub 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 print_subtree(&index, bean_id)?;
18 } else {
19 print_full_tree(&index);
21 }
22
23 Ok(())
24}
25
26fn print_full_tree(index: &Index) {
27 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 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 let children: Vec<_> = index
84 .beans
85 .iter()
86 .filter(|e| e.parent.as_ref() == Some(&bean_id.to_string()))
87 .collect();
88
89 let dependents: Vec<_> = index
91 .beans
92 .iter()
93 .filter(|e| e.dependencies.contains(&bean_id.to_string()))
94 .collect();
95
96 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 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 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 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 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}