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 diagnostics.sort_by(|a, b| a.node.cmp(&b.node));
48 diagnostics
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use crate::config::Config;
56 use crate::graph::test_helpers::make_edge;
57 use crate::graph::{Graph, Node, hash_bytes};
58 use crate::lockfile::{Lockfile, write_lockfile};
59 use crate::rules::RuleContext;
60 use std::collections::HashMap;
61 use std::fs;
62 use tempfile::TempDir;
63
64 fn setup_locked_dir() -> TempDir {
65 let dir = TempDir::new().unwrap();
66 fs::write(dir.path().join("index.md"), "[setup](setup.md)").unwrap();
67 fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
68
69 let mut graph = Graph::new();
70 let index_hash = hash_bytes(b"[setup](setup.md)");
71 let setup_hash = hash_bytes(b"# Setup");
72
73 graph.add_node(Node {
74 path: "index.md".into(),
75 node_type: Some(crate::graph::NodeType::File),
76 included: true,
77 hash: Some(index_hash),
78 metadata: HashMap::new(),
79 });
80 graph.add_node(Node {
81 path: "setup.md".into(),
82 node_type: Some(crate::graph::NodeType::File),
83 included: true,
84 hash: Some(setup_hash),
85 metadata: HashMap::new(),
86 });
87 graph.add_edge(make_edge("index.md", "setup.md"));
88
89 let lockfile = Lockfile::from_graph(&graph);
90 write_lockfile(dir.path(), &lockfile).unwrap();
91 dir
92 }
93
94 #[test]
95 fn no_staleness_when_unchanged() {
96 let dir = setup_locked_dir();
97 let config = Config::defaults();
98 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
99 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
100 let ctx = RuleContext {
101 graph: &enriched,
102 options: None,
103 };
104 let diagnostics = StaleRule.evaluate(&ctx);
105 assert!(diagnostics.is_empty());
106 }
107
108 #[test]
109 fn detects_direct_and_transitive_staleness() {
110 let dir = setup_locked_dir();
111 fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
112
113 let config = Config::defaults();
114 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
115 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
116 let ctx = RuleContext {
117 graph: &enriched,
118 options: None,
119 };
120 let diagnostics = StaleRule.evaluate(&ctx);
121 assert_eq!(diagnostics.len(), 2);
122
123 let direct = diagnostics
124 .iter()
125 .find(|d| d.message == "content changed")
126 .unwrap();
127 assert_eq!(direct.node.as_deref(), Some("setup.md"));
128 assert!(direct.via.is_none());
129
130 let transitive = diagnostics
131 .iter()
132 .find(|d| d.message == "stale via")
133 .unwrap();
134 assert_eq!(transitive.node.as_deref(), Some("index.md"));
135 assert_eq!(transitive.via.as_deref(), Some("setup.md"));
136 }
137
138 #[test]
139 fn skips_when_no_lockfile() {
140 let dir = TempDir::new().unwrap();
141 fs::write(dir.path().join("dummy.md"), "").unwrap();
142 let config = Config::defaults();
143 let enriched = crate::analyses::enrich(dir.path(), &config, None).unwrap();
144 let ctx = RuleContext {
145 graph: &enriched,
146 options: None,
147 };
148 let diagnostics = StaleRule.evaluate(&ctx);
149 assert!(diagnostics.is_empty());
150 }
151
152 #[test]
153 fn deleted_file_causes_staleness() {
154 let dir = setup_locked_dir();
155 fs::remove_file(dir.path().join("setup.md")).unwrap();
156
157 let config = Config::defaults();
158 let lockfile = crate::lockfile::read_lockfile(dir.path()).ok().flatten();
159 let enriched = crate::analyses::enrich(dir.path(), &config, lockfile.as_ref()).unwrap();
160 let ctx = RuleContext {
161 graph: &enriched,
162 options: None,
163 };
164 let diagnostics = StaleRule.evaluate(&ctx);
165 assert!(!diagnostics.is_empty());
166
167 let direct = diagnostics
168 .iter()
169 .find(|d| d.message == "content changed")
170 .unwrap();
171 assert_eq!(direct.node.as_deref(), Some("setup.md"));
172 }
173}