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