1use crate::diagnostic::Diagnostic;
2use crate::rules::{Rule, RuleContext};
3
4pub struct StaleRule;
5
6impl Rule for StaleRule {
7 fn name(&self) -> &str {
8 "stale"
9 }
10
11 fn evaluate(&self, ctx: &RuleContext) -> Vec<Diagnostic> {
12 let result = &ctx.graph.change_propagation;
13
14 if !result.has_lockfile {
15 return vec![];
16 }
17
18 let mut diagnostics = Vec::new();
19
20 for change in &result.directly_changed {
21 diagnostics.push(Diagnostic {
22 rule: "stale".into(),
23 message: "content changed".into(),
24 node: Some(change.node.clone()),
25 fix: Some(format!(
26 "{} has been modified since the last lock \u{2014} review its dependents, then run drft lock",
27 change.node
28 )),
29 ..Default::default()
30 });
31 }
32
33 for stale in &result.transitively_stale {
34 diagnostics.push(Diagnostic {
35 rule: "stale".into(),
36 message: "stale via".into(),
37 node: Some(stale.node.clone()),
38 via: Some(stale.via.clone()),
39 fix: Some(format!(
40 "{} has changed \u{2014} review {} to ensure it still accurately reflects {}, then run drft lock",
41 stale.via, stale.node, stale.via
42 )),
43 ..Default::default()
44 });
45 }
46
47 for change in &result.boundary_changes {
48 diagnostics.push(Diagnostic {
49 rule: "stale".into(),
50 message: "graph boundary changed".into(),
51 node: Some(change.node.clone()),
52 fix: Some(match change.reason.as_str() {
53 "child graph removed" => format!(
54 "{} no longer has a drft.lock \u{2014} run drft lock to update the parent lockfile",
55 change.node
56 ),
57 "new child graph" => format!(
58 "{} is a new child graph \u{2014} run drft lock to update the parent lockfile",
59 change.node
60 ),
61 _ => "run drft lock to update the lockfile".to_string(),
62 }),
63 ..Default::default()
64 });
65 }
66
67 diagnostics.sort_by(|a, b| a.node.cmp(&b.node));
68 diagnostics
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use crate::config::Config;
76 use crate::graph::{Edge, Graph, Node, NodeType, hash_bytes};
77 use crate::lockfile::{Lockfile, write_lockfile};
78 use crate::rules::RuleContext;
79 use std::collections::HashMap;
80 use std::fs;
81 use tempfile::TempDir;
82
83 fn setup_locked_dir() -> TempDir {
84 let dir = TempDir::new().unwrap();
85 fs::write(dir.path().join("index.md"), "[setup](setup.md)").unwrap();
86 fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
87
88 let mut graph = Graph::new();
89 let index_hash = hash_bytes(b"[setup](setup.md)");
90 let setup_hash = hash_bytes(b"# Setup");
91
92 graph.add_node(Node {
93 path: "index.md".into(),
94 node_type: NodeType::File,
95 hash: Some(index_hash),
96 graph: None,
97 is_graph: false,
98 metadata: HashMap::new(),
99 included: true,
100 });
101 graph.add_node(Node {
102 path: "setup.md".into(),
103 node_type: NodeType::File,
104 hash: Some(setup_hash),
105 graph: None,
106 is_graph: false,
107 metadata: HashMap::new(),
108 included: true,
109 });
110 graph.add_edge(Edge {
111 source: "index.md".into(),
112 target: "setup.md".into(),
113 link: None,
114 parser: "markdown".into(),
115 });
116
117 let lockfile = Lockfile::from_graph(&graph);
118 write_lockfile(dir.path(), &lockfile).unwrap();
119 dir
120 }
121
122 #[test]
123 fn no_staleness_when_unchanged() {
124 let dir = setup_locked_dir();
125 let config = Config::defaults();
126 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
127 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
128 let ctx = RuleContext {
129 graph: &enriched,
130 options: None,
131 };
132 let diagnostics = StaleRule.evaluate(&ctx);
133 assert!(diagnostics.is_empty());
134 }
135
136 #[test]
137 fn detects_direct_and_transitive_staleness() {
138 let dir = setup_locked_dir();
139 fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
140
141 let config = Config::defaults();
142 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
143 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
144 let ctx = RuleContext {
145 graph: &enriched,
146 options: None,
147 };
148 let diagnostics = StaleRule.evaluate(&ctx);
149 assert_eq!(diagnostics.len(), 2);
150
151 let direct = diagnostics
152 .iter()
153 .find(|d| d.message == "content changed")
154 .unwrap();
155 assert_eq!(direct.node.as_deref(), Some("setup.md"));
156 assert!(direct.via.is_none());
157
158 let transitive = diagnostics
159 .iter()
160 .find(|d| d.message == "stale via")
161 .unwrap();
162 assert_eq!(transitive.node.as_deref(), Some("index.md"));
163 assert_eq!(transitive.via.as_deref(), Some("setup.md"));
164 }
165
166 #[test]
167 fn skips_when_no_lockfile() {
168 let dir = TempDir::new().unwrap();
169 fs::write(dir.path().join("dummy.md"), "").unwrap();
170 let config = Config::defaults();
171 let enriched = crate::analyses::enrich(dir.path(), &config, None).unwrap();
172 let ctx = RuleContext {
173 graph: &enriched,
174 options: None,
175 };
176 let diagnostics = StaleRule.evaluate(&ctx);
177 assert!(diagnostics.is_empty());
178 }
179
180 #[test]
181 fn deleted_file_causes_staleness() {
182 let dir = setup_locked_dir();
183 fs::remove_file(dir.path().join("setup.md")).unwrap();
184
185 let config = Config::defaults();
186 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
187 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
188 let ctx = RuleContext {
189 graph: &enriched,
190 options: None,
191 };
192 let diagnostics = StaleRule.evaluate(&ctx);
193 assert!(!diagnostics.is_empty());
194
195 let direct = diagnostics
196 .iter()
197 .find(|d| d.message == "content changed")
198 .unwrap();
199 assert_eq!(direct.node.as_deref(), Some("setup.md"));
200 }
201}