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.exclude)
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, Graph, Node, NodeType};
181 use crate::lockfile::{Lockfile, write_lockfile};
182 use std::collections::HashMap;
183 use std::fs;
184 use tempfile::TempDir;
185
186 fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> AnalysisContext<'a> {
187 AnalysisContext {
188 graph,
189 root,
190 config,
191 lockfile: None,
192 }
193 }
194
195 fn setup_locked_dir() -> TempDir {
196 let dir = TempDir::new().unwrap();
197 fs::write(dir.path().join("index.md"), "[setup](setup.md)").unwrap();
198 fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
199
200 let mut graph = Graph::new();
201 let index_hash = hash_bytes(b"[setup](setup.md)");
202 let setup_hash = hash_bytes(b"# Setup");
203
204 graph.add_node(Node {
205 path: "index.md".into(),
206 node_type: NodeType::File,
207 hash: Some(index_hash),
208 graph: None,
209 metadata: HashMap::new(),
210 });
211 graph.add_node(Node {
212 path: "setup.md".into(),
213 node_type: NodeType::File,
214 hash: Some(setup_hash),
215 graph: None,
216 metadata: HashMap::new(),
217 });
218 graph.add_edge(Edge {
219 source: "index.md".into(),
220 target: "setup.md".into(),
221 link: None,
222 parser: "markdown".into(),
223 });
224
225 let lockfile = Lockfile::from_graph(&graph);
226 write_lockfile(dir.path(), &lockfile).unwrap();
227 dir
228 }
229
230 #[test]
231 fn no_changes_when_unchanged() {
232 let dir = setup_locked_dir();
233 let graph = Graph::new();
234 let config = Config::defaults();
235 let ctx = make_ctx(&graph, dir.path(), &config);
236 let result = ChangePropagation.run(&ctx);
237 assert!(result.has_lockfile);
238 assert!(result.directly_changed.is_empty());
239 assert!(result.transitively_stale.is_empty());
240 }
241
242 #[test]
243 fn detects_direct_and_transitive() {
244 let dir = setup_locked_dir();
245 fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
246
247 let config = Config::defaults();
248 let graph = crate::graph::build_graph(dir.path(), &config).unwrap();
249 let ctx = make_ctx(&graph, dir.path(), &config);
250 let result = ChangePropagation.run(&ctx);
251 assert_eq!(result.directly_changed.len(), 1);
252 assert_eq!(result.directly_changed[0].node, "setup.md");
253 assert_eq!(result.transitively_stale.len(), 1);
254 assert_eq!(result.transitively_stale[0].node, "index.md");
255 assert_eq!(result.transitively_stale[0].via, "setup.md");
256 }
257
258 #[test]
259 fn no_lockfile_returns_empty() {
260 let dir = TempDir::new().unwrap();
261 let graph = Graph::new();
262 let config = Config::defaults();
263 let ctx = make_ctx(&graph, dir.path(), &config);
264 let result = ChangePropagation.run(&ctx);
265 assert!(!result.has_lockfile);
266 assert!(result.directly_changed.is_empty());
267 }
268
269 #[test]
270 fn detects_deleted_file() {
271 let dir = setup_locked_dir();
272 fs::remove_file(dir.path().join("setup.md")).unwrap();
273
274 let graph = Graph::new();
275 let config = Config::defaults();
276 let ctx = make_ctx(&graph, dir.path(), &config);
277 let result = ChangePropagation.run(&ctx);
278 assert_eq!(result.directly_changed.len(), 1);
279 assert_eq!(result.directly_changed[0].reason, "file deleted");
280 }
281}