1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use anyhow::{anyhow, Result};
5
6use crate::index::Index;
7
8pub fn detect_cycle(index: &Index, from_id: &str, to_id: &str) -> Result<bool> {
13 if from_id == to_id {
15 return Ok(true);
16 }
17
18 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
20 for entry in &index.beans {
21 graph.insert(entry.id.clone(), entry.dependencies.clone());
22 }
23
24 let mut visited = HashSet::new();
26 let mut stack = vec![to_id.to_string()];
27
28 while let Some(current) = stack.pop() {
29 if current == from_id {
30 return Ok(true);
31 }
32
33 if visited.contains(¤t) {
34 continue;
35 }
36 visited.insert(current.clone());
37
38 if let Some(deps) = graph.get(¤t) {
39 for dep in deps {
40 if !visited.contains(dep) {
41 stack.push(dep.clone());
42 }
43 }
44 }
45 }
46
47 Ok(false)
48}
49
50pub fn build_dependency_tree(index: &Index, id: &str) -> Result<String> {
53 let root_entry = index
55 .beans
56 .iter()
57 .find(|e| e.id == id)
58 .ok_or_else(|| anyhow!("Bean {} not found", id))?;
59
60 let mut output = String::new();
61 output.push_str(&format!("{} {}\n", root_entry.id, root_entry.title));
62
63 let graph: HashMap<String, Vec<String>> = index
65 .beans
66 .iter()
67 .map(|e| (e.id.clone(), e.dependencies.clone()))
68 .collect();
69
70 let mut reverse_graph: HashMap<String, Vec<String>> = HashMap::new();
72 for (id, deps) in &graph {
73 for dep in deps {
74 reverse_graph
75 .entry(dep.clone())
76 .or_default()
77 .push(id.clone());
78 }
79 }
80
81 let id_map: HashMap<String, &crate::index::IndexEntry> =
83 index.beans.iter().map(|e| (e.id.clone(), e)).collect();
84
85 let mut visited = HashSet::new();
87 build_tree_recursive(&mut output, id, &reverse_graph, &id_map, &mut visited, "");
88
89 Ok(output)
90}
91
92fn build_tree_recursive(
93 output: &mut String,
94 current_id: &str,
95 reverse_graph: &HashMap<String, Vec<String>>,
96 id_map: &HashMap<String, &crate::index::IndexEntry>,
97 visited: &mut HashSet<String>,
98 prefix: &str,
99) {
100 if visited.contains(current_id) {
101 return;
102 }
103 visited.insert(current_id.to_string());
104
105 if let Some(dependents) = reverse_graph.get(current_id) {
106 for (i, dependent_id) in dependents.iter().enumerate() {
107 let is_last_dependent = i == dependents.len() - 1;
108
109 let connector = if is_last_dependent {
110 "└── "
111 } else {
112 "├── "
113 };
114 output.push_str(prefix);
115 output.push_str(connector);
116
117 if let Some(entry) = id_map.get(dependent_id) {
118 output.push_str(&format!("{} {}\n", entry.id, entry.title));
119 } else {
120 output.push_str(&format!("{}\n", dependent_id));
121 }
122
123 let new_prefix = if is_last_dependent {
124 format!("{} ", prefix)
125 } else {
126 format!("{}│ ", prefix)
127 };
128
129 build_tree_recursive(
130 output,
131 dependent_id,
132 reverse_graph,
133 id_map,
134 visited,
135 &new_prefix,
136 );
137 }
138 }
139}
140
141pub fn build_full_graph(index: &Index) -> Result<String> {
144 let root_beans: Vec<_> = index.beans.iter().filter(|e| e.parent.is_none()).collect();
146
147 if root_beans.is_empty() {
148 return Ok("No beans found.".to_string());
149 }
150
151 let mut output = String::new();
152
153 let mut reverse_graph: HashMap<String, Vec<String>> = HashMap::new();
155 for entry in &index.beans {
156 for dep in &entry.dependencies {
157 reverse_graph
158 .entry(dep.clone())
159 .or_default()
160 .push(entry.id.clone());
161 }
162 }
163
164 let id_map: HashMap<String, &crate::index::IndexEntry> =
166 index.beans.iter().map(|e| (e.id.clone(), e)).collect();
167
168 let mut visited = HashSet::new();
169 for root in root_beans {
170 output.push_str(&format!("{} {}\n", root.id, root.title));
171 build_tree_recursive(
172 &mut output,
173 &root.id,
174 &reverse_graph,
175 &id_map,
176 &mut visited,
177 "",
178 );
179 }
180
181 Ok(output)
182}
183
184#[must_use = "returns the total attempt count"]
189pub fn count_subtree_attempts(beans_dir: &Path, root_id: &str) -> Result<u32> {
190 let index = Index::build(beans_dir)?;
191 let archived = Index::collect_archived(beans_dir).unwrap_or_default();
192
193 let mut all_beans = index.beans;
195 all_beans.extend(archived);
196
197 let mut total = 0u32;
198 let mut stack = vec![root_id.to_string()];
199 let mut visited = HashSet::new();
200
201 while let Some(id) = stack.pop() {
202 if !visited.insert(id.clone()) {
203 continue;
204 }
205 if let Some(entry) = all_beans.iter().find(|b| b.id == id) {
206 total += entry.attempts;
207 for child in all_beans
209 .iter()
210 .filter(|b| b.parent.as_deref() == Some(id.as_str()))
211 {
212 if !visited.contains(&child.id) {
213 stack.push(child.id.clone());
214 }
215 }
216 }
217 }
218 Ok(total)
219}
220
221pub fn find_all_cycles(index: &Index) -> Result<Vec<Vec<String>>> {
224 let mut cycles = Vec::new();
225
226 let graph: HashMap<String, Vec<String>> = index
228 .beans
229 .iter()
230 .map(|e| (e.id.clone(), e.dependencies.clone()))
231 .collect();
232
233 let mut visited = HashSet::new();
234
235 for start_id in graph.keys() {
237 if !visited.contains(start_id) {
238 let mut path = Vec::new();
239 find_cycle_dfs(&graph, start_id, &mut visited, &mut path, &mut cycles);
240 }
241 }
242
243 Ok(cycles)
244}
245
246fn find_cycle_dfs(
247 graph: &HashMap<String, Vec<String>>,
248 current: &str,
249 visited: &mut HashSet<String>,
250 path: &mut Vec<String>,
251 cycles: &mut Vec<Vec<String>>,
252) {
253 if let Some(pos) = path.iter().position(|id| id == current) {
255 let cycle = path[pos..].to_vec();
256 if !cycles.contains(&cycle) {
257 cycles.push(cycle);
258 }
259 return;
260 }
261
262 if visited.contains(current) {
264 return;
265 }
266
267 path.push(current.to_string());
268
269 if let Some(deps) = graph.get(current) {
270 for dep in deps {
271 find_cycle_dfs(graph, dep, visited, path, cycles);
272 }
273 }
274
275 path.pop();
276 visited.insert(current.to_string());
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::bean::Bean;
283 use std::fs;
284 use tempfile::TempDir;
285
286 fn setup_test_beans(specs: Vec<(&str, Vec<&str>)>) -> (TempDir, std::path::PathBuf) {
287 let dir = TempDir::new().unwrap();
288 let beans_dir = dir.path().join(".beans");
289 fs::create_dir(&beans_dir).unwrap();
290
291 for (id, deps) in specs {
292 let mut bean = Bean::new(id, format!("Task {}", id));
293 bean.dependencies = deps.iter().map(|s| s.to_string()).collect();
294 bean.to_file(beans_dir.join(format!("{}.yaml", id)))
295 .unwrap();
296 }
297
298 (dir, beans_dir)
299 }
300
301 #[test]
302 fn detect_self_cycle() {
303 let (_dir, beans_dir) = setup_test_beans(vec![("1", vec![])]);
304 let index = Index::build(&beans_dir).unwrap();
305 assert!(detect_cycle(&index, "1", "1").unwrap());
306 }
307
308 #[test]
309 fn detect_two_node_cycle() {
310 let (_dir, beans_dir) = setup_test_beans(vec![("1", vec!["2"]), ("2", vec![])]);
311 let index = Index::build(&beans_dir).unwrap();
312 assert!(detect_cycle(&index, "2", "1").unwrap());
313 assert!(!detect_cycle(&index, "1", "2").unwrap());
314 }
315
316 #[test]
317 fn detect_three_node_cycle() {
318 let (_dir, beans_dir) =
319 setup_test_beans(vec![("1", vec!["2"]), ("2", vec!["3"]), ("3", vec![])]);
320 let index = Index::build(&beans_dir).unwrap();
321 assert!(detect_cycle(&index, "3", "1").unwrap());
323 assert!(!detect_cycle(&index, "1", "3").unwrap());
324 }
325
326 #[test]
327 fn no_cycle_linear_chain() {
328 let (_dir, beans_dir) =
329 setup_test_beans(vec![("1", vec!["2"]), ("2", vec!["3"]), ("3", vec![])]);
330 let index = Index::build(&beans_dir).unwrap();
331 assert!(!detect_cycle(&index, "1", "2").unwrap());
332 assert!(!detect_cycle(&index, "2", "3").unwrap());
333 }
334
335 fn setup_subtree_beans(specs: Vec<(&str, Option<&str>, u32)>) -> (TempDir, std::path::PathBuf) {
342 let dir = TempDir::new().unwrap();
343 let beans_dir = dir.path().join(".beans");
344 fs::create_dir(&beans_dir).unwrap();
345
346 for (id, parent, attempts) in specs {
347 let mut bean = Bean::new(id, format!("Task {}", id));
348 bean.parent = parent.map(|s| s.to_string());
349 bean.attempts = attempts;
350 let slug = crate::util::title_to_slug(&bean.title);
351 bean.to_file(beans_dir.join(format!("{}-{}.md", id, slug)))
352 .unwrap();
353 }
354
355 (dir, beans_dir)
356 }
357
358 #[test]
359 fn subtree_attempts_single_bean_no_children() {
360 let (_dir, beans_dir) = setup_subtree_beans(vec![("1", None, 5)]);
361 let total = count_subtree_attempts(&beans_dir, "1").unwrap();
362 assert_eq!(total, 5);
363 }
364
365 #[test]
366 fn subtree_attempts_includes_root() {
367 let (_dir, beans_dir) = setup_subtree_beans(vec![
368 ("1", None, 3),
369 ("1.1", Some("1"), 2),
370 ("1.2", Some("1"), 1),
371 ]);
372 let total = count_subtree_attempts(&beans_dir, "1").unwrap();
373 assert_eq!(total, 6);
375 }
376
377 #[test]
378 fn subtree_attempts_sums_all_descendants() {
379 let (_dir, beans_dir) = setup_subtree_beans(vec![
380 ("1", None, 0),
381 ("1.1", Some("1"), 2),
382 ("1.2", Some("1"), 3),
383 ("1.1.1", Some("1.1"), 1),
384 ("1.1.2", Some("1.1"), 4),
385 ]);
386 let total = count_subtree_attempts(&beans_dir, "1").unwrap();
387 assert_eq!(total, 10);
389 }
390
391 #[test]
392 fn subtree_attempts_subtree_only() {
393 let (_dir, beans_dir) = setup_subtree_beans(vec![
395 ("1", None, 1),
396 ("1.1", Some("1"), 5),
397 ("2", None, 10),
398 ("2.1", Some("2"), 20),
399 ]);
400 let total = count_subtree_attempts(&beans_dir, "1").unwrap();
401 assert_eq!(total, 6);
403 }
404
405 #[test]
406 fn subtree_attempts_unknown_root_returns_zero() {
407 let (_dir, beans_dir) = setup_subtree_beans(vec![("1", None, 5)]);
408 let total = count_subtree_attempts(&beans_dir, "999").unwrap();
409 assert_eq!(total, 0);
410 }
411
412 #[test]
413 fn subtree_attempts_zero_attempts_everywhere() {
414 let (_dir, beans_dir) = setup_subtree_beans(vec![
415 ("1", None, 0),
416 ("1.1", Some("1"), 0),
417 ("1.2", Some("1"), 0),
418 ]);
419 let total = count_subtree_attempts(&beans_dir, "1").unwrap();
420 assert_eq!(total, 0);
421 }
422
423 #[test]
424 fn subtree_attempts_includes_archived_beans() {
425 let (_dir, beans_dir) = setup_subtree_beans(vec![("1", None, 1), ("1.2", Some("1"), 2)]);
426
427 let archive_dir = beans_dir.join("archive").join("2026").join("02");
429 fs::create_dir_all(&archive_dir).unwrap();
430 let mut archived_bean = Bean::new("1.1", "Archived Child");
431 archived_bean.parent = Some("1".to_string());
432 archived_bean.attempts = 3;
433 archived_bean.status = crate::bean::Status::Closed;
434 archived_bean.is_archived = true;
435 archived_bean
436 .to_file(archive_dir.join("1.1-archived-child.md"))
437 .unwrap();
438
439 let total = count_subtree_attempts(&beans_dir, "1").unwrap();
440 assert_eq!(total, 6);
442 }
443}