Skip to main content

drft/rules/
stale.rs

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}