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