1use crate::analyses::Analysis;
2use crate::analyses::AnalysisContext;
3use crate::analyses::scc::StronglyConnectedComponents;
4use crate::diagnostic::Diagnostic;
5use crate::rules::{Rule, RuleContext};
6
7pub struct CycleRule;
8
9impl Rule for CycleRule {
10 fn name(&self) -> &str {
11 "cycle"
12 }
13
14 fn evaluate(&self, ctx: &RuleContext) -> Vec<Diagnostic> {
15 let analysis_ctx = AnalysisContext {
16 graph: ctx.graph,
17 root: ctx.root,
18 config: ctx.config,
19 lockfile: ctx.lockfile,
20 };
21 let result = StronglyConnectedComponents.run(&analysis_ctx);
22
23 result
24 .sccs
25 .iter()
26 .map(|scc| {
27 let mut path = scc.members.clone();
28 if let Some(first) = path.first().cloned() {
29 path.push(first);
30 }
31
32 let fix = format!(
33 "circular dependency \u{2014} review whether one of these links can be removed or the content restructured: {}",
34 scc.members.join(" \u{2192} ")
35 );
36
37 Diagnostic {
38 rule: "cycle".into(),
39 message: "cycle detected".into(),
40 path: Some(path),
41 fix: Some(fix),
42 ..Default::default()
43 }
44 })
45 .collect()
46 }
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52 use crate::config::Config;
53 use crate::graph::Graph;
54 use crate::graph::test_helpers::{make_edge, make_node};
55 use crate::rules::RuleContext;
56 use std::path::Path;
57
58 fn make_ctx<'a>(graph: &'a Graph, config: &'a Config) -> RuleContext<'a> {
59 RuleContext {
60 graph,
61 root: Path::new("."),
62 config,
63 lockfile: None,
64 }
65 }
66
67 #[test]
68 fn detects_simple_cycle() {
69 let mut graph = Graph::new();
70 graph.add_node(make_node("a.md"));
71 graph.add_node(make_node("b.md"));
72 graph.add_node(make_node("c.md"));
73 graph.add_edge(make_edge("a.md", "b.md"));
74 graph.add_edge(make_edge("b.md", "c.md"));
75 graph.add_edge(make_edge("c.md", "a.md"));
76
77 let config = Config::defaults();
78 let diagnostics = CycleRule.evaluate(&make_ctx(&graph, &config));
79 assert_eq!(diagnostics.len(), 1);
80 assert_eq!(diagnostics[0].rule, "cycle");
81
82 let path = diagnostics[0].path.as_ref().unwrap();
83 assert_eq!(path.first(), path.last());
84 assert!(path.contains(&"a.md".to_string()));
85 assert!(path.contains(&"b.md".to_string()));
86 assert!(path.contains(&"c.md".to_string()));
87 }
88
89 #[test]
90 fn no_cycle_in_dag() {
91 let mut graph = Graph::new();
92 graph.add_node(make_node("a.md"));
93 graph.add_node(make_node("b.md"));
94 graph.add_node(make_node("c.md"));
95 graph.add_edge(make_edge("a.md", "b.md"));
96 graph.add_edge(make_edge("b.md", "c.md"));
97
98 let config = Config::defaults();
99 let diagnostics = CycleRule.evaluate(&make_ctx(&graph, &config));
100 assert!(diagnostics.is_empty());
101 }
102
103 #[test]
104 fn ignores_broken_link_edges() {
105 let mut graph = Graph::new();
106 graph.add_node(make_node("a.md"));
107 graph.add_edge(make_edge("a.md", "missing.md"));
108
109 let config = Config::defaults();
110 let diagnostics = CycleRule.evaluate(&make_ctx(&graph, &config));
111 assert!(diagnostics.is_empty());
112 }
113}