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