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 });
100 graph.add_node(Node {
101 path: "setup.md".into(),
102 node_type: NodeType::File,
103 hash: Some(setup_hash),
104 graph: None,
105 is_graph: false,
106 metadata: HashMap::new(),
107 });
108 graph.add_edge(Edge {
109 source: "index.md".into(),
110 target: "setup.md".into(),
111 link: None,
112 parser: "markdown".into(),
113 });
114
115 let lockfile = Lockfile::from_graph(&graph);
116 write_lockfile(dir.path(), &lockfile).unwrap();
117 dir
118 }
119
120 #[test]
121 fn no_staleness_when_unchanged() {
122 let dir = setup_locked_dir();
123 let config = Config::defaults();
124 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
125 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
126 let ctx = RuleContext {
127 graph: &enriched,
128 options: None,
129 };
130 let diagnostics = StaleRule.evaluate(&ctx);
131 assert!(diagnostics.is_empty());
132 }
133
134 #[test]
135 fn detects_direct_and_transitive_staleness() {
136 let dir = setup_locked_dir();
137 fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
138
139 let config = Config::defaults();
140 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
141 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
142 let ctx = RuleContext {
143 graph: &enriched,
144 options: None,
145 };
146 let diagnostics = StaleRule.evaluate(&ctx);
147 assert_eq!(diagnostics.len(), 2);
148
149 let direct = diagnostics
150 .iter()
151 .find(|d| d.message == "content changed")
152 .unwrap();
153 assert_eq!(direct.node.as_deref(), Some("setup.md"));
154 assert!(direct.via.is_none());
155
156 let transitive = diagnostics
157 .iter()
158 .find(|d| d.message == "stale via")
159 .unwrap();
160 assert_eq!(transitive.node.as_deref(), Some("index.md"));
161 assert_eq!(transitive.via.as_deref(), Some("setup.md"));
162 }
163
164 #[test]
165 fn skips_when_no_lockfile() {
166 let dir = TempDir::new().unwrap();
167 fs::write(dir.path().join("dummy.md"), "").unwrap();
168 let config = Config::defaults();
169 let enriched = crate::analyses::enrich(dir.path(), &config, None).unwrap();
170 let ctx = RuleContext {
171 graph: &enriched,
172 options: None,
173 };
174 let diagnostics = StaleRule.evaluate(&ctx);
175 assert!(diagnostics.is_empty());
176 }
177
178 #[test]
179 fn deleted_file_causes_staleness() {
180 let dir = setup_locked_dir();
181 fs::remove_file(dir.path().join("setup.md")).unwrap();
182
183 let config = Config::defaults();
184 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
185 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
186 let ctx = RuleContext {
187 graph: &enriched,
188 options: None,
189 };
190 let diagnostics = StaleRule.evaluate(&ctx);
191 assert!(!diagnostics.is_empty());
192
193 let direct = diagnostics
194 .iter()
195 .find(|d| d.message == "content changed")
196 .unwrap();
197 assert_eq!(direct.node.as_deref(), Some("setup.md"));
198 }
199}