Skip to main content

drft/rules/
stale.rs

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}