Skip to main content

mana_core/
graph.rs

1//! Dependency graph utilities.
2//!
3//! Functions in this module operate on the unit [`Index`] to answer
4//! graph-related questions: cycle detection, topological ordering,
5//! tree rendering, and subtree statistics.
6//!
7//! Most callers should use the wrappers in [`crate::api`] rather than
8//! calling these functions directly.
9//!
10//! ## Example
11//!
12//! ```rust,no_run
13//! use mana_core::api::{load_index, find_mana_dir};
14//! use mana_core::graph::{detect_cycle, build_dependency_tree};
15//! use std::path::Path;
16//!
17//! let mana_dir = find_mana_dir(Path::new(".")).unwrap();
18//! let index = load_index(&mana_dir).unwrap();
19//!
20//! // Check whether adding a dep would create a cycle
21//! if detect_cycle(&index, "5", "1").unwrap() {
22//!     eprintln!("Adding 5 -> 1 would create a cycle");
23//! }
24//!
25//! // Render a tree rooted at unit 1
26//! let tree = build_dependency_tree(&index, "1").unwrap();
27//! println!("{}", tree);
28//! ```
29
30use std::collections::{HashMap, HashSet};
31use std::path::Path;
32
33use anyhow::{anyhow, Result};
34
35use crate::index::Index;
36
37/// Detect a cycle in the dependency graph.
38///
39/// Uses DFS from `to_id` to check if `from_id` is reachable.
40/// If so, adding the edge from_id -> to_id would create a cycle.
41pub fn detect_cycle(index: &Index, from_id: &str, to_id: &str) -> Result<bool> {
42    // Quick check: self-dependency
43    if from_id == to_id {
44        return Ok(true);
45    }
46
47    // Build adjacency list from index
48    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
49    for entry in &index.units {
50        graph.insert(entry.id.clone(), entry.dependencies.clone());
51    }
52
53    // DFS from to_id: if we reach from_id, there's a cycle
54    let mut visited = HashSet::new();
55    let mut stack = vec![to_id.to_string()];
56
57    while let Some(current) = stack.pop() {
58        if current == from_id {
59            return Ok(true);
60        }
61
62        if visited.contains(&current) {
63            continue;
64        }
65        visited.insert(current.clone());
66
67        if let Some(deps) = graph.get(&current) {
68            for dep in deps {
69                if !visited.contains(dep) {
70                    stack.push(dep.clone());
71                }
72            }
73        }
74    }
75
76    Ok(false)
77}
78
79/// Build a dependency tree rooted at `id`.
80/// Returns a string representation with box-drawing characters.
81pub fn build_dependency_tree(index: &Index, id: &str) -> Result<String> {
82    // Find the root unit
83    let root_entry = index
84        .units
85        .iter()
86        .find(|e| e.id == id)
87        .ok_or_else(|| anyhow!("Unit {} not found", id))?;
88
89    let mut output = String::new();
90    output.push_str(&format!("{} {}\n", root_entry.id, root_entry.title));
91
92    // Build adjacency list
93    let graph: HashMap<String, Vec<String>> = index
94        .units
95        .iter()
96        .map(|e| (e.id.clone(), e.dependencies.clone()))
97        .collect();
98
99    // Build reverse graph (dependents)
100    let mut reverse_graph: HashMap<String, Vec<String>> = HashMap::new();
101    for (id, deps) in &graph {
102        for dep in deps {
103            reverse_graph
104                .entry(dep.clone())
105                .or_default()
106                .push(id.clone());
107        }
108    }
109
110    // Create index map
111    let id_map: HashMap<String, &crate::index::IndexEntry> =
112        index.units.iter().map(|e| (e.id.clone(), e)).collect();
113
114    // DFS to build tree
115    let mut visited = HashSet::new();
116    build_tree_recursive(&mut output, id, &reverse_graph, &id_map, &mut visited, "");
117
118    Ok(output)
119}
120
121fn build_tree_recursive(
122    output: &mut String,
123    current_id: &str,
124    reverse_graph: &HashMap<String, Vec<String>>,
125    id_map: &HashMap<String, &crate::index::IndexEntry>,
126    visited: &mut HashSet<String>,
127    prefix: &str,
128) {
129    if visited.contains(current_id) {
130        return;
131    }
132    visited.insert(current_id.to_string());
133
134    if let Some(dependents) = reverse_graph.get(current_id) {
135        for (i, dependent_id) in dependents.iter().enumerate() {
136            let is_last_dependent = i == dependents.len() - 1;
137
138            let connector = if is_last_dependent {
139                "└── "
140            } else {
141                "├── "
142            };
143            output.push_str(prefix);
144            output.push_str(connector);
145
146            if let Some(entry) = id_map.get(dependent_id) {
147                output.push_str(&format!("{} {}\n", entry.id, entry.title));
148            } else {
149                output.push_str(&format!("{}\n", dependent_id));
150            }
151
152            let new_prefix = if is_last_dependent {
153                format!("{}    ", prefix)
154            } else {
155                format!("{}│   ", prefix)
156            };
157
158            build_tree_recursive(
159                output,
160                dependent_id,
161                reverse_graph,
162                id_map,
163                visited,
164                &new_prefix,
165            );
166        }
167    }
168}
169
170/// Build a project-wide dependency graph as a text tree.
171/// Shows all dependencies rooted at units with no parents.
172pub fn build_full_graph(index: &Index) -> Result<String> {
173    // Find root units (those with no parent)
174    let root_units: Vec<_> = index.units.iter().filter(|e| e.parent.is_none()).collect();
175
176    if root_units.is_empty() {
177        return Ok("No units found.".to_string());
178    }
179
180    let mut output = String::new();
181
182    // Build reverse graph (dependents)
183    let mut reverse_graph: HashMap<String, Vec<String>> = HashMap::new();
184    for entry in &index.units {
185        for dep in &entry.dependencies {
186            reverse_graph
187                .entry(dep.clone())
188                .or_default()
189                .push(entry.id.clone());
190        }
191    }
192
193    // Create index map
194    let id_map: HashMap<String, &crate::index::IndexEntry> =
195        index.units.iter().map(|e| (e.id.clone(), e)).collect();
196
197    let mut visited = HashSet::new();
198    for root in root_units {
199        output.push_str(&format!("{} {}\n", root.id, root.title));
200        build_tree_recursive(
201            &mut output,
202            &root.id,
203            &reverse_graph,
204            &id_map,
205            &mut visited,
206            "",
207        );
208    }
209
210    Ok(output)
211}
212
213/// Count total verify attempts across all descendants of a unit.
214///
215/// Includes the unit itself and archived descendants.
216/// Used by the circuit breaker to detect runaway retry loops across a subtree.
217#[must_use = "returns the total attempt count"]
218pub fn count_subtree_attempts(mana_dir: &Path, root_id: &str) -> Result<u32> {
219    let index = Index::build(mana_dir)?;
220    let archived = Index::collect_archived(mana_dir).unwrap_or_default();
221
222    // Combine active and archived units
223    let mut all_units = index.units;
224    all_units.extend(archived);
225
226    let mut total = 0u32;
227    let mut stack = vec![root_id.to_string()];
228    let mut visited = HashSet::new();
229
230    while let Some(id) = stack.pop() {
231        if !visited.insert(id.clone()) {
232            continue;
233        }
234        if let Some(entry) = all_units.iter().find(|b| b.id == id) {
235            total += entry.attempts;
236            // Find children
237            for child in all_units
238                .iter()
239                .filter(|b| b.parent.as_deref() == Some(id.as_str()))
240            {
241                if !visited.contains(&child.id) {
242                    stack.push(child.id.clone());
243                }
244            }
245        }
246    }
247    Ok(total)
248}
249
250/// Find all cycles in the dependency graph.
251/// Returns a list of cycle paths.
252pub fn find_all_cycles(index: &Index) -> Result<Vec<Vec<String>>> {
253    let mut cycles = Vec::new();
254
255    // Build adjacency list
256    let graph: HashMap<String, Vec<String>> = index
257        .units
258        .iter()
259        .map(|e| (e.id.clone(), e.dependencies.clone()))
260        .collect();
261
262    let mut visited = HashSet::new();
263
264    // For each node, check if there's a cycle starting from it
265    for start_id in graph.keys() {
266        if !visited.contains(start_id) {
267            let mut path = Vec::new();
268            find_cycle_dfs(&graph, start_id, &mut visited, &mut path, &mut cycles);
269        }
270    }
271
272    Ok(cycles)
273}
274
275fn find_cycle_dfs(
276    graph: &HashMap<String, Vec<String>>,
277    current: &str,
278    visited: &mut HashSet<String>,
279    path: &mut Vec<String>,
280    cycles: &mut Vec<Vec<String>>,
281) {
282    // Check if current is already on the DFS path (back edge = cycle)
283    if let Some(pos) = path.iter().position(|id| id == current) {
284        let cycle = path[pos..].to_vec();
285        if !cycles.contains(&cycle) {
286            cycles.push(cycle);
287        }
288        return;
289    }
290
291    // Skip if already fully explored
292    if visited.contains(current) {
293        return;
294    }
295
296    path.push(current.to_string());
297
298    if let Some(deps) = graph.get(current) {
299        for dep in deps {
300            find_cycle_dfs(graph, dep, visited, path, cycles);
301        }
302    }
303
304    path.pop();
305    visited.insert(current.to_string());
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::unit::Unit;
312    use std::fs;
313    use tempfile::TempDir;
314
315    fn setup_test_units(specs: Vec<(&str, Vec<&str>)>) -> (TempDir, std::path::PathBuf) {
316        let dir = TempDir::new().unwrap();
317        let mana_dir = dir.path().join(".mana");
318        fs::create_dir(&mana_dir).unwrap();
319
320        for (id, deps) in specs {
321            let mut unit = Unit::new(id, format!("Task {}", id));
322            unit.dependencies = deps.iter().map(|s| s.to_string()).collect();
323            unit.to_file(mana_dir.join(format!("{}.yaml", id))).unwrap();
324        }
325
326        (dir, mana_dir)
327    }
328
329    #[test]
330    fn detect_self_cycle() {
331        let (_dir, mana_dir) = setup_test_units(vec![("1", vec![])]);
332        let index = Index::build(&mana_dir).unwrap();
333        assert!(detect_cycle(&index, "1", "1").unwrap());
334    }
335
336    #[test]
337    fn detect_two_node_cycle() {
338        let (_dir, mana_dir) = setup_test_units(vec![("1", vec!["2"]), ("2", vec![])]);
339        let index = Index::build(&mana_dir).unwrap();
340        assert!(detect_cycle(&index, "2", "1").unwrap());
341        assert!(!detect_cycle(&index, "1", "2").unwrap());
342    }
343
344    #[test]
345    fn detect_three_node_cycle() {
346        let (_dir, mana_dir) =
347            setup_test_units(vec![("1", vec!["2"]), ("2", vec!["3"]), ("3", vec![])]);
348        let index = Index::build(&mana_dir).unwrap();
349        // If we add 3 -> 1, it creates a cycle
350        assert!(detect_cycle(&index, "3", "1").unwrap());
351        assert!(!detect_cycle(&index, "1", "3").unwrap());
352    }
353
354    #[test]
355    fn no_cycle_linear_chain() {
356        let (_dir, mana_dir) =
357            setup_test_units(vec![("1", vec!["2"]), ("2", vec!["3"]), ("3", vec![])]);
358        let index = Index::build(&mana_dir).unwrap();
359        assert!(!detect_cycle(&index, "1", "2").unwrap());
360        assert!(!detect_cycle(&index, "2", "3").unwrap());
361    }
362
363    // =====================================================================
364    // Subtree Attempts Tests
365    // =====================================================================
366
367    /// Helper: create units with parent + attempts for subtree tests.
368    /// Each spec: (id, parent, attempts)
369    fn setup_subtree_units(specs: Vec<(&str, Option<&str>, u32)>) -> (TempDir, std::path::PathBuf) {
370        let dir = TempDir::new().unwrap();
371        let mana_dir = dir.path().join(".mana");
372        fs::create_dir(&mana_dir).unwrap();
373
374        for (id, parent, attempts) in specs {
375            let mut unit = Unit::new(id, format!("Task {}", id));
376            unit.parent = parent.map(|s| s.to_string());
377            unit.attempts = attempts;
378            let slug = crate::util::title_to_slug(&unit.title);
379            unit.to_file(mana_dir.join(format!("{}-{}.md", id, slug)))
380                .unwrap();
381        }
382
383        (dir, mana_dir)
384    }
385
386    #[test]
387    fn subtree_attempts_single_unit_no_children() {
388        let (_dir, mana_dir) = setup_subtree_units(vec![("1", None, 5)]);
389        let total = count_subtree_attempts(&mana_dir, "1").unwrap();
390        assert_eq!(total, 5);
391    }
392
393    #[test]
394    fn subtree_attempts_includes_root() {
395        let (_dir, mana_dir) = setup_subtree_units(vec![
396            ("1", None, 3),
397            ("1.1", Some("1"), 2),
398            ("1.2", Some("1"), 1),
399        ]);
400        let total = count_subtree_attempts(&mana_dir, "1").unwrap();
401        // Root(3) + 1.1(2) + 1.2(1) = 6
402        assert_eq!(total, 6);
403    }
404
405    #[test]
406    fn subtree_attempts_sums_all_descendants() {
407        let (_dir, mana_dir) = setup_subtree_units(vec![
408            ("1", None, 0),
409            ("1.1", Some("1"), 2),
410            ("1.2", Some("1"), 3),
411            ("1.1.1", Some("1.1"), 1),
412            ("1.1.2", Some("1.1"), 4),
413        ]);
414        let total = count_subtree_attempts(&mana_dir, "1").unwrap();
415        // 0 + 2 + 3 + 1 + 4 = 10
416        assert_eq!(total, 10);
417    }
418
419    #[test]
420    fn subtree_attempts_subtree_only() {
421        // Only counts descendants of the given root, not siblings
422        let (_dir, mana_dir) = setup_subtree_units(vec![
423            ("1", None, 1),
424            ("1.1", Some("1"), 5),
425            ("2", None, 10),
426            ("2.1", Some("2"), 20),
427        ]);
428        let total = count_subtree_attempts(&mana_dir, "1").unwrap();
429        // Only 1(1) + 1.1(5) = 6, not including "2" tree
430        assert_eq!(total, 6);
431    }
432
433    #[test]
434    fn subtree_attempts_unknown_root_returns_zero() {
435        let (_dir, mana_dir) = setup_subtree_units(vec![("1", None, 5)]);
436        let total = count_subtree_attempts(&mana_dir, "999").unwrap();
437        assert_eq!(total, 0);
438    }
439
440    #[test]
441    fn subtree_attempts_zero_attempts_everywhere() {
442        let (_dir, mana_dir) = setup_subtree_units(vec![
443            ("1", None, 0),
444            ("1.1", Some("1"), 0),
445            ("1.2", Some("1"), 0),
446        ]);
447        let total = count_subtree_attempts(&mana_dir, "1").unwrap();
448        assert_eq!(total, 0);
449    }
450
451    #[test]
452    fn subtree_attempts_includes_archived_units() {
453        let (_dir, mana_dir) = setup_subtree_units(vec![("1", None, 1), ("1.2", Some("1"), 2)]);
454
455        // Create an archived child with attempts
456        let archive_dir = mana_dir.join("archive").join("2026").join("02");
457        fs::create_dir_all(&archive_dir).unwrap();
458        let mut archived_unit = Unit::new("1.1", "Archived Child");
459        archived_unit.parent = Some("1".to_string());
460        archived_unit.attempts = 3;
461        archived_unit.status = crate::unit::Status::Closed;
462        archived_unit.is_archived = true;
463        archived_unit
464            .to_file(archive_dir.join("1.1-archived-child.md"))
465            .unwrap();
466
467        let total = count_subtree_attempts(&mana_dir, "1").unwrap();
468        // Root(1) + active 1.2(2) + archived 1.1(3) = 6
469        assert_eq!(total, 6);
470    }
471}