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            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}