Skip to main content

bn/
graph.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use anyhow::{anyhow, Result};
5
6use crate::index::Index;
7
8/// Detect a cycle in the dependency graph.
9///
10/// Uses DFS from `to_id` to check if `from_id` is reachable.
11/// If so, adding the edge from_id -> to_id would create a cycle.
12pub fn detect_cycle(index: &Index, from_id: &str, to_id: &str) -> Result<bool> {
13    // Quick check: self-dependency
14    if from_id == to_id {
15        return Ok(true);
16    }
17
18    // Build adjacency list from index
19    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    // DFS from to_id: if we reach from_id, there's a cycle
25    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(&current) {
34            continue;
35        }
36        visited.insert(current.clone());
37
38        if let Some(deps) = graph.get(&current) {
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
50/// Build a dependency tree rooted at `id`.
51/// Returns a string representation with box-drawing characters.
52pub fn build_dependency_tree(index: &Index, id: &str) -> Result<String> {
53    // Find the root bean
54    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    // Build adjacency list
64    let graph: HashMap<String, Vec<String>> = index
65        .beans
66        .iter()
67        .map(|e| (e.id.clone(), e.dependencies.clone()))
68        .collect();
69
70    // Build reverse graph (dependents)
71    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    // Create index map
82    let id_map: HashMap<String, &crate::index::IndexEntry> =
83        index.beans.iter().map(|e| (e.id.clone(), e)).collect();
84
85    // DFS to build tree
86    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
141/// Build a project-wide dependency graph as a text tree.
142/// Shows all dependencies rooted at beans with no parents.
143pub fn build_full_graph(index: &Index) -> Result<String> {
144    // Find root beans (those with no parent)
145    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    // Build reverse graph (dependents)
154    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    // Create index map
165    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/// Count total verify attempts across all descendants of a bean.
185///
186/// Includes the bean itself and archived descendants.
187/// Used by the circuit breaker to detect runaway retry loops across a subtree.
188#[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    // Combine active and archived beans
194    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            // Find children
208            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
221/// Find all cycles in the dependency graph.
222/// Returns a list of cycle paths.
223pub fn find_all_cycles(index: &Index) -> Result<Vec<Vec<String>>> {
224    let mut cycles = Vec::new();
225
226    // Build adjacency list
227    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 each node, check if there's a cycle starting from it
236    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    // Check if current is already on the DFS path (back edge = cycle)
254    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    // Skip if already fully explored
263    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        // If we add 3 -> 1, it creates a cycle
322        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    // =====================================================================
336    // Subtree Attempts Tests
337    // =====================================================================
338
339    /// Helper: create beans with parent + attempts for subtree tests.
340    /// Each spec: (id, parent, attempts)
341    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        // Root(3) + 1.1(2) + 1.2(1) = 6
374        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        // 0 + 2 + 3 + 1 + 4 = 10
388        assert_eq!(total, 10);
389    }
390
391    #[test]
392    fn subtree_attempts_subtree_only() {
393        // Only counts descendants of the given root, not siblings
394        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        // Only 1(1) + 1.1(5) = 6, not including "2" tree
402        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        // Create an archived child with attempts
428        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        // Root(1) + active 1.2(2) + archived 1.1(3) = 6
441        assert_eq!(total, 6);
442    }
443}