1use crate::config::{Config, RuleSeverity};
10use crate::diagnostic::Finding;
11use crate::lock::Lock;
12use crate::model::Graph;
13use crate::rules::{staleness, structural};
14
15pub fn run(graph: &Graph, lock: Option<&Lock>, config: &Config) -> Vec<Finding> {
18 let mut findings = Vec::new();
19 if let Some(lock) = lock {
20 findings.extend(staleness::evaluate(graph, lock));
21 }
22 findings.extend(structural::evaluate(graph));
23
24 findings.retain_mut(|finding| {
25 let severity = config
27 .rules
28 .get(&finding.name)
29 .map(|r| r.severity)
30 .unwrap_or(RuleSeverity::Warn);
31 if severity == RuleSeverity::Off {
32 return false;
33 }
34 if config.is_rule_ignored(&finding.name, &finding.subject) {
35 return false;
36 }
37 finding.severity = severity;
38 true
39 });
40
41 findings.sort_by(|a, b| {
42 a.name
43 .cmp(&b.name)
44 .then_with(|| a.subject.cmp(&b.subject))
45 .then_with(|| a.message.cmp(&b.message))
46 });
47 findings
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use crate::compose::compose;
54 use crate::model::{Edge, GraphSet, Node};
55 use serde_json::json;
56
57 fn fs_node() -> Node {
58 node("b3:x")
59 }
60
61 fn node(hash: &str) -> Node {
62 Node::new(
63 json!({ "type": "file", "hash": hash })
64 .as_object()
65 .unwrap()
66 .clone(),
67 )
68 }
69
70 fn composed_unresolved() -> Graph {
71 let mut fs = Graph::labeled("fs");
72 fs.set_node("index.md", fs_node());
73 fs.add_edge(Edge::new("index.md", "gone.md"));
74 compose(&GraphSet::new(vec![fs]))
75 }
76
77 #[test]
78 fn applies_default_warn_severity() {
79 let graph = composed_unresolved();
80 let findings = run(&graph, None, &Config::defaults());
81 let unresolved = findings
82 .iter()
83 .find(|f| f.name == "unresolved-edge")
84 .unwrap();
85 assert_eq!(unresolved.severity, RuleSeverity::Warn);
86 }
87
88 #[test]
89 fn config_promotes_to_error() {
90 let dir = tempfile::TempDir::new().unwrap();
91 std::fs::write(
92 dir.path().join("drft.toml"),
93 "[rules]\nunresolved-edge = \"error\"\n",
94 )
95 .unwrap();
96 let config = Config::load(dir.path()).unwrap();
97 let findings = run(&composed_unresolved(), None, &config);
98 let unresolved = findings
99 .iter()
100 .find(|f| f.name == "unresolved-edge")
101 .unwrap();
102 assert_eq!(unresolved.severity, RuleSeverity::Error);
103 }
104
105 #[test]
106 fn config_off_silences_rule() {
107 let dir = tempfile::TempDir::new().unwrap();
108 std::fs::write(
109 dir.path().join("drft.toml"),
110 "[rules]\nunresolved-edge = \"off\"\n",
111 )
112 .unwrap();
113 let config = Config::load(dir.path()).unwrap();
114 let findings = run(&composed_unresolved(), None, &config);
115 assert!(!findings.iter().any(|f| f.name == "unresolved-edge"));
116 }
117
118 #[test]
119 fn global_rule_ignore_suppresses_group_but_keeps_transitive_signal() {
120 use crate::lock::Lock;
121 let mut locked = Graph::labeled("fs");
123 locked.set_node("yours.md", node("b3:yours"));
124 locked.set_node("vendor/x.md", node("b3:old"));
125 locked.add_edge(Edge::new("yours.md", "vendor/x.md"));
126 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![locked])));
127
128 let mut current = Graph::labeled("fs");
130 current.set_node("yours.md", node("b3:yours"));
131 current.set_node("vendor/x.md", node("b3:new"));
132 current.add_edge(Edge::new("yours.md", "vendor/x.md"));
133 let composed = compose(&GraphSet::new(vec![current]));
134
135 let dir = tempfile::TempDir::new().unwrap();
136 std::fs::write(
137 dir.path().join("drft.toml"),
138 "[rules]\nignore = [\"vendor/**\"]\n",
139 )
140 .unwrap();
141 let config = Config::load(dir.path()).unwrap();
142
143 let findings = run(&composed, Some(&lock), &config);
144 assert!(
146 !findings
147 .iter()
148 .any(|f| f.name == "stale-node" && f.subject == "vendor/x.md"),
149 "vendor stale-node should be suppressed, got: {findings:?}"
150 );
151 assert!(
154 findings
155 .iter()
156 .any(|f| f.name == "stale-edge" && f.subject == "yours.md"),
157 "your transitive stale-edge should remain, got: {findings:?}"
158 );
159 }
160
161 #[test]
162 fn ignore_glob_drops_subject() {
163 let dir = tempfile::TempDir::new().unwrap();
164 std::fs::write(
165 dir.path().join("drft.toml"),
166 "[rules.unresolved-edge]\nignore = [\"index.md\"]\n",
167 )
168 .unwrap();
169 let config = Config::load(dir.path()).unwrap();
170 let findings = run(&composed_unresolved(), None, &config);
171 assert!(!findings.iter().any(|f| f.name == "unresolved-edge"));
172 }
173}