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