drft/analyses/
change_propagation.rs1use super::{Analysis, AnalysisContext};
2use crate::graph::hash_bytes;
3use crate::lockfile::read_lockfile;
4use std::collections::{HashMap, HashSet, VecDeque};
5use std::path::Path;
6
7#[derive(Debug, Clone, serde::Serialize)]
8pub struct DirectChange {
9 pub node: String,
10 pub reason: String,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct TransitiveStale {
15 pub node: String,
16 pub via: String,
17}
18
19#[derive(Debug, Clone, serde::Serialize)]
20pub struct ChangePropagationResult {
21 pub has_lockfile: bool,
22 pub directly_changed: Vec<DirectChange>,
23 pub transitively_stale: Vec<TransitiveStale>,
24}
25
26pub struct ChangePropagation;
27
28impl Analysis for ChangePropagation {
29 type Output = ChangePropagationResult;
30
31 fn name(&self) -> &str {
32 "change-propagation"
33 }
34
35 fn run(&self, ctx: &AnalysisContext) -> ChangePropagationResult {
36 let graph = ctx.graph;
37 let root = ctx.root;
38 let lockfile = match read_lockfile(root) {
39 Ok(Some(lf)) => lf,
40 _ => {
41 return ChangePropagationResult {
42 has_lockfile: false,
43 directly_changed: Vec::new(),
44 transitively_stale: Vec::new(),
45 };
46 }
47 };
48
49 let mut directly_stale: HashSet<String> = HashSet::new();
51 let mut directly_changed = Vec::new();
52
53 for (path, locked_node) in &lockfile.nodes {
54 if locked_node.hash.is_none() {
56 continue;
57 }
58 let current_hash = compute_current_hash(root, path);
59 match (&locked_node.hash, ¤t_hash) {
60 (Some(locked), Some(current)) if locked != current => {
61 directly_stale.insert(path.clone());
62 directly_changed.push(DirectChange {
63 node: path.clone(),
64 reason: "content changed".into(),
65 });
66 }
67 (Some(_), None) => {
68 directly_stale.insert(path.clone());
69 directly_changed.push(DirectChange {
70 node: path.clone(),
71 reason: "file deleted".into(),
72 });
73 }
74 _ => {}
75 }
76 }
77
78 let mut transitively_stale = Vec::new();
80
81 if !directly_stale.is_empty() {
82 let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
83 for edge in &graph.edges {
84 dependents
85 .entry(edge.target.as_str())
86 .or_default()
87 .push(edge.source.as_str());
88 }
89
90 let mut stale_via: HashMap<String, String> = HashMap::new();
91 let mut queue: VecDeque<String> = directly_stale.iter().cloned().collect();
92
93 while let Some(stale_node) = queue.pop_front() {
94 if let Some(deps) = dependents.get(stale_node.as_str()) {
95 for &dependent in deps {
96 if !stale_via.contains_key(dependent) && !directly_stale.contains(dependent)
97 {
98 stale_via.insert(dependent.to_string(), stale_node.clone());
99 queue.push_back(dependent.to_string());
100 }
101 }
102 }
103 }
104
105 let mut stale_pairs: Vec<_> = stale_via.into_iter().collect();
106 stale_pairs.sort_by(|a, b| a.0.cmp(&b.0));
107
108 transitively_stale = stale_pairs
109 .into_iter()
110 .map(|(node, via)| TransitiveStale { node, via })
111 .collect();
112 }
113
114 directly_changed.sort_by(|a, b| a.node.cmp(&b.node));
115
116 ChangePropagationResult {
117 has_lockfile: true,
118 directly_changed,
119 transitively_stale,
120 }
121 }
122}
123
124fn compute_current_hash(root: &Path, relative_path: &str) -> Option<String> {
125 let full_path = root.join(relative_path);
126 let content = std::fs::read(&full_path).ok()?;
127 Some(hash_bytes(&content))
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::analyses::AnalysisContext;
134 use crate::config::Config;
135 use crate::graph::test_helpers::make_edge;
136 use crate::graph::{Graph, Node};
137 use crate::lockfile::{Lockfile, write_lockfile};
138 use std::collections::HashMap;
139 use std::fs;
140 use tempfile::TempDir;
141
142 fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> AnalysisContext<'a> {
143 AnalysisContext {
144 graph,
145 root,
146 config,
147 lockfile: None,
148 }
149 }
150
151 fn setup_locked_dir() -> TempDir {
152 let dir = TempDir::new().unwrap();
153 fs::write(dir.path().join("index.md"), "[setup](setup.md)").unwrap();
154 fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
155
156 let mut graph = Graph::new();
157 let index_hash = hash_bytes(b"[setup](setup.md)");
158 let setup_hash = hash_bytes(b"# Setup");
159
160 graph.add_node(Node {
161 path: "index.md".into(),
162 node_type: Some(crate::graph::NodeType::File),
163 included: true,
164 hash: Some(index_hash),
165 metadata: HashMap::new(),
166 });
167 graph.add_node(Node {
168 path: "setup.md".into(),
169 node_type: Some(crate::graph::NodeType::File),
170 included: true,
171 hash: Some(setup_hash),
172 metadata: HashMap::new(),
173 });
174 graph.add_edge(make_edge("index.md", "setup.md"));
175
176 let lockfile = Lockfile::from_graph(&graph);
177 write_lockfile(dir.path(), &lockfile).unwrap();
178 dir
179 }
180
181 #[test]
182 fn no_changes_when_unchanged() {
183 let dir = setup_locked_dir();
184 let graph = Graph::new();
185 let config = Config::defaults();
186 let ctx = make_ctx(&graph, dir.path(), &config);
187 let result = ChangePropagation.run(&ctx);
188 assert!(result.has_lockfile);
189 assert!(result.directly_changed.is_empty());
190 assert!(result.transitively_stale.is_empty());
191 }
192
193 #[test]
194 fn detects_direct_and_transitive() {
195 let dir = setup_locked_dir();
196 fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
197
198 let config = Config::defaults();
199 let graph = crate::graph::build_graph(dir.path(), &config).unwrap();
200 let ctx = make_ctx(&graph, dir.path(), &config);
201 let result = ChangePropagation.run(&ctx);
202 assert_eq!(result.directly_changed.len(), 1);
203 assert_eq!(result.directly_changed[0].node, "setup.md");
204 assert_eq!(result.transitively_stale.len(), 1);
205 assert_eq!(result.transitively_stale[0].node, "index.md");
206 assert_eq!(result.transitively_stale[0].via, "setup.md");
207 }
208
209 #[test]
210 fn no_lockfile_returns_empty() {
211 let dir = TempDir::new().unwrap();
212 let graph = Graph::new();
213 let config = Config::defaults();
214 let ctx = make_ctx(&graph, dir.path(), &config);
215 let result = ChangePropagation.run(&ctx);
216 assert!(!result.has_lockfile);
217 assert!(result.directly_changed.is_empty());
218 }
219
220 #[test]
221 fn detects_deleted_file() {
222 let dir = setup_locked_dir();
223 fs::remove_file(dir.path().join("setup.md")).unwrap();
224
225 let graph = Graph::new();
226 let config = Config::defaults();
227 let ctx = make_ctx(&graph, dir.path(), &config);
228 let result = ChangePropagation.run(&ctx);
229 assert_eq!(result.directly_changed.len(), 1);
230 assert_eq!(result.directly_changed[0].reason, "file deleted");
231 }
232}