Skip to main content

chant/domain/
dependency.rs

1//! Pure dependency graph functions for topological sorting and cycle detection.
2
3use crate::spec::Spec;
4use anyhow::{anyhow, Result};
5use std::collections::{HashMap, HashSet, VecDeque};
6
7/// Detects cycles in the dependency graph.
8///
9/// Returns a list of cycles, where each cycle is represented as a list of spec IDs.
10/// If no cycles exist, returns an empty vector.
11///
12/// # Arguments
13///
14/// * `specs` - Slice of specs to analyze
15///
16/// # Returns
17///
18/// Vector of cycles, each represented as a vector of spec IDs forming the cycle
19pub fn detect_cycles(specs: &[Spec]) -> Vec<Vec<String>> {
20    let mut cycles = Vec::new();
21    let mut visited = HashSet::new();
22    let mut rec_stack = HashSet::new();
23    let mut path = Vec::new();
24
25    // Build adjacency map
26    let adj_map = build_adjacency_map(specs);
27
28    for spec in specs {
29        if !visited.contains(&spec.id) {
30            if let Some(cycle) =
31                detect_cycle_dfs(&spec.id, &adj_map, &mut visited, &mut rec_stack, &mut path)
32            {
33                cycles.push(cycle);
34            }
35        }
36    }
37
38    cycles
39}
40
41/// Performs depth-first search to detect cycles.
42fn detect_cycle_dfs(
43    node: &str,
44    adj_map: &HashMap<String, Vec<String>>,
45    visited: &mut HashSet<String>,
46    rec_stack: &mut HashSet<String>,
47    path: &mut Vec<String>,
48) -> Option<Vec<String>> {
49    visited.insert(node.to_string());
50    rec_stack.insert(node.to_string());
51    path.push(node.to_string());
52
53    if let Some(deps) = adj_map.get(node) {
54        for dep in deps {
55            if !visited.contains(dep) {
56                if let Some(cycle) = detect_cycle_dfs(dep, adj_map, visited, rec_stack, path) {
57                    return Some(cycle);
58                }
59            } else if rec_stack.contains(dep) {
60                // Found a cycle - extract it from path
61                let cycle_start = path.iter().position(|id| id == dep).unwrap();
62                let cycle = path[cycle_start..].to_vec();
63                return Some(cycle);
64            }
65        }
66    }
67
68    rec_stack.remove(node);
69    path.pop();
70    None
71}
72
73/// Builds an adjacency map from specs.
74fn build_adjacency_map(specs: &[Spec]) -> HashMap<String, Vec<String>> {
75    let mut adj_map = HashMap::new();
76
77    for spec in specs {
78        let deps = spec.frontmatter.depends_on.clone().unwrap_or_default();
79        adj_map.insert(spec.id.clone(), deps);
80    }
81
82    adj_map
83}
84
85/// Performs a topological sort on the specs based on their dependencies.
86///
87/// Returns a vector of spec IDs in topologically sorted order (dependencies before dependents).
88/// Returns an error if cycles are detected.
89///
90/// # Arguments
91///
92/// * `specs` - Slice of specs to sort
93///
94/// # Returns
95///
96/// Result containing either:
97/// - Ok: Vector of spec IDs in topologically sorted order
98/// - Err: Error describing the cycle found
99pub fn topological_sort(specs: &[Spec]) -> Result<Vec<String>> {
100    // First check for cycles
101    let cycles = detect_cycles(specs);
102    if !cycles.is_empty() {
103        let cycle_str = cycles[0].join(" -> ");
104        return Err(anyhow!("Circular dependency detected: {}", cycle_str));
105    }
106
107    let spec_ids: HashSet<String> = specs.iter().map(|s| s.id.clone()).collect();
108
109    // Build reverse adjacency map: for each spec, who depends on it?
110    // If A depends_on B, then B -> A (B must come before A)
111    let mut dependents_of: HashMap<String, Vec<String>> = HashMap::new();
112    let mut in_degree: HashMap<String, usize> = HashMap::new();
113
114    // Initialize
115    for spec in specs {
116        dependents_of.entry(spec.id.clone()).or_default();
117        in_degree.entry(spec.id.clone()).or_insert(0);
118    }
119
120    // Build the reverse adjacency and in-degree maps
121    for spec in specs {
122        if let Some(deps) = &spec.frontmatter.depends_on {
123            for dep in deps {
124                // Only count dependencies that exist in our spec set
125                if spec_ids.contains(dep) {
126                    // dep -> spec.id (dep must come before spec)
127                    dependents_of
128                        .entry(dep.clone())
129                        .or_default()
130                        .push(spec.id.clone());
131                    // spec has one more incoming edge (dependency)
132                    *in_degree.entry(spec.id.clone()).or_insert(0) += 1;
133                }
134            }
135        }
136    }
137
138    // Kahn's algorithm for topological sort
139    let mut queue = VecDeque::new();
140    let mut result = Vec::new();
141
142    // Start with nodes that have no dependencies (in_degree = 0)
143    for (id, &degree) in &in_degree {
144        if degree == 0 {
145            queue.push_back(id.clone());
146        }
147    }
148
149    while let Some(node) = queue.pop_front() {
150        result.push(node.clone());
151
152        // For each spec that depends on this node, decrement their in-degree
153        if let Some(dependents) = dependents_of.get(&node) {
154            for dependent in dependents {
155                if let Some(degree) = in_degree.get_mut(dependent) {
156                    *degree -= 1;
157                    if *degree == 0 {
158                        queue.push_back(dependent.clone());
159                    }
160                }
161            }
162        }
163    }
164
165    // If we processed all nodes, we have a valid topological order
166    if result.len() == specs.len() {
167        Ok(result)
168    } else {
169        Err(anyhow!(
170            "Failed to produce topological sort (this shouldn't happen after cycle check)"
171        ))
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::spec::{SpecFrontmatter, SpecStatus};
179
180    fn make_spec(id: &str, depends_on: Option<Vec<String>>) -> Spec {
181        Spec {
182            id: id.to_string(),
183            frontmatter: SpecFrontmatter {
184                status: SpecStatus::Pending,
185                depends_on,
186                ..Default::default()
187            },
188            title: Some(format!("Test {}", id)),
189            body: format!("# Test {}\n\nBody.", id),
190        }
191    }
192
193    #[test]
194    fn test_detect_cycles_no_cycles() {
195        let specs = vec![
196            make_spec("001", None),
197            make_spec("002", Some(vec!["001".to_string()])),
198            make_spec("003", Some(vec!["002".to_string()])),
199        ];
200
201        let cycles = detect_cycles(&specs);
202        assert!(cycles.is_empty());
203    }
204
205    #[test]
206    fn test_detect_cycles_simple_cycle() {
207        let specs = vec![
208            make_spec("001", Some(vec!["002".to_string()])),
209            make_spec("002", Some(vec!["001".to_string()])),
210        ];
211
212        let cycles = detect_cycles(&specs);
213        assert_eq!(cycles.len(), 1);
214        assert!(cycles[0].contains(&"001".to_string()));
215        assert!(cycles[0].contains(&"002".to_string()));
216    }
217
218    #[test]
219    fn test_detect_cycles_self_cycle() {
220        let specs = vec![make_spec("001", Some(vec!["001".to_string()]))];
221
222        let cycles = detect_cycles(&specs);
223        assert_eq!(cycles.len(), 1);
224        assert_eq!(cycles[0], vec!["001"]);
225    }
226
227    #[test]
228    fn test_detect_cycles_linear_chain() {
229        let specs = vec![
230            make_spec("A", Some(vec!["B".to_string()])),
231            make_spec("B", Some(vec!["C".to_string()])),
232            make_spec("C", None),
233        ];
234
235        let cycles = detect_cycles(&specs);
236        assert!(
237            cycles.is_empty(),
238            "Linear chain A->B->C should have no cycles"
239        );
240    }
241
242    #[test]
243    fn test_detect_cycles_simple_cycle_abc() {
244        let specs = vec![
245            make_spec("A", Some(vec!["B".to_string()])),
246            make_spec("B", Some(vec!["A".to_string()])),
247        ];
248
249        let cycles = detect_cycles(&specs);
250        assert_eq!(cycles.len(), 1, "Should detect one cycle");
251        assert!(
252            cycles[0].contains(&"A".to_string()),
253            "Cycle should contain A"
254        );
255        assert!(
256            cycles[0].contains(&"B".to_string()),
257            "Cycle should contain B"
258        );
259    }
260
261    #[test]
262    fn test_detect_cycles_three_node() {
263        let specs = vec![
264            make_spec("A", Some(vec!["B".to_string()])),
265            make_spec("B", Some(vec!["C".to_string()])),
266            make_spec("C", Some(vec!["A".to_string()])),
267        ];
268
269        let cycles = detect_cycles(&specs);
270        assert_eq!(cycles.len(), 1, "Should detect one cycle");
271        assert!(
272            cycles[0].contains(&"A".to_string()),
273            "Cycle should contain A"
274        );
275        assert!(
276            cycles[0].contains(&"B".to_string()),
277            "Cycle should contain B"
278        );
279        assert!(
280            cycles[0].contains(&"C".to_string()),
281            "Cycle should contain C"
282        );
283    }
284
285    #[test]
286    fn test_detect_cycles_self_reference() {
287        let specs = vec![make_spec("A", Some(vec!["A".to_string()]))];
288
289        let cycles = detect_cycles(&specs);
290        assert_eq!(cycles.len(), 1, "Should detect one cycle");
291        assert_eq!(
292            cycles[0],
293            vec!["A"],
294            "Cycle should be just A referencing itself"
295        );
296    }
297
298    #[test]
299    fn test_topological_sort_linear() {
300        let specs = vec![
301            make_spec("001", None),
302            make_spec("002", Some(vec!["001".to_string()])),
303            make_spec("003", Some(vec!["002".to_string()])),
304        ];
305
306        let result = topological_sort(&specs).unwrap();
307        assert_eq!(result.len(), 3);
308
309        let pos_001 = result.iter().position(|id| id == "001").unwrap();
310        let pos_002 = result.iter().position(|id| id == "002").unwrap();
311        let pos_003 = result.iter().position(|id| id == "003").unwrap();
312
313        assert!(pos_001 < pos_002);
314        assert!(pos_002 < pos_003);
315    }
316
317    #[test]
318    fn test_topological_sort_diamond_numeric() {
319        let specs = vec![
320            make_spec("001", None),
321            make_spec("002", Some(vec!["001".to_string()])),
322            make_spec("003", Some(vec!["001".to_string()])),
323            make_spec("004", Some(vec!["002".to_string(), "003".to_string()])),
324        ];
325
326        let result = topological_sort(&specs).unwrap();
327        assert_eq!(result.len(), 4);
328
329        // 001 should come before all others
330        let pos_001 = result.iter().position(|id| id == "001").unwrap();
331        let pos_002 = result.iter().position(|id| id == "002").unwrap();
332        let pos_003 = result.iter().position(|id| id == "003").unwrap();
333        let pos_004 = result.iter().position(|id| id == "004").unwrap();
334
335        assert!(pos_001 < pos_002);
336        assert!(pos_001 < pos_003);
337        assert!(pos_002 < pos_004);
338        assert!(pos_003 < pos_004);
339    }
340
341    #[test]
342    fn test_topological_sort_with_cycle() {
343        let specs = vec![
344            make_spec("001", Some(vec!["002".to_string()])),
345            make_spec("002", Some(vec!["001".to_string()])),
346        ];
347
348        let result = topological_sort(&specs);
349        assert!(result.is_err());
350        assert!(result
351            .unwrap_err()
352            .to_string()
353            .contains("Circular dependency"));
354    }
355
356    #[test]
357    fn test_topological_sort_no_dependencies() {
358        let specs = vec![
359            make_spec("001", None),
360            make_spec("002", None),
361            make_spec("003", None),
362        ];
363
364        let result = topological_sort(&specs).unwrap();
365        assert_eq!(result.len(), 3);
366        // All specs should be included, order doesn't matter when no dependencies
367    }
368
369    #[test]
370    fn test_topological_sort_empty() {
371        let specs: Vec<Spec> = vec![];
372        let result = topological_sort(&specs).unwrap();
373        assert!(result.is_empty());
374    }
375
376    #[test]
377    fn test_topological_sort_single() {
378        let specs = vec![make_spec("A", None)];
379        let result = topological_sort(&specs).unwrap();
380        assert_eq!(result, vec!["A"]);
381    }
382
383    #[test]
384    fn test_topological_sort_linear_chain() {
385        let specs = vec![
386            make_spec("A", Some(vec!["B".to_string()])),
387            make_spec("B", Some(vec!["C".to_string()])),
388            make_spec("C", None),
389        ];
390
391        let result = topological_sort(&specs).unwrap();
392        assert_eq!(result.len(), 3);
393
394        let pos_a = result.iter().position(|id| id == "A").unwrap();
395        let pos_b = result.iter().position(|id| id == "B").unwrap();
396        let pos_c = result.iter().position(|id| id == "C").unwrap();
397
398        assert!(pos_c < pos_b, "C should come before B (dependencies first)");
399        assert!(pos_b < pos_a, "B should come before A (dependencies first)");
400    }
401
402    #[test]
403    fn test_topological_sort_diamond() {
404        let specs = vec![
405            make_spec("A", Some(vec!["B".to_string(), "C".to_string()])),
406            make_spec("B", Some(vec!["D".to_string()])),
407            make_spec("C", Some(vec!["D".to_string()])),
408            make_spec("D", None),
409        ];
410
411        let result = topological_sort(&specs).unwrap();
412        assert_eq!(result.len(), 4);
413
414        let pos_a = result.iter().position(|id| id == "A").unwrap();
415        let pos_b = result.iter().position(|id| id == "B").unwrap();
416        let pos_c = result.iter().position(|id| id == "C").unwrap();
417        let pos_d = result.iter().position(|id| id == "D").unwrap();
418
419        assert!(pos_d < pos_b, "D should come before B");
420        assert!(pos_d < pos_c, "D should come before C");
421        assert!(pos_b < pos_a, "B should come before A");
422        assert!(pos_c < pos_a, "C should come before A");
423    }
424
425    #[test]
426    fn test_topological_sort_cycle_error() {
427        let specs = vec![
428            make_spec("A", Some(vec!["B".to_string()])),
429            make_spec("B", Some(vec!["A".to_string()])),
430        ];
431
432        let result = topological_sort(&specs);
433        assert!(result.is_err());
434        let err = result.unwrap_err();
435        assert!(
436            err.to_string().contains("Circular dependency"),
437            "Error should mention circular dependency"
438        );
439    }
440}