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, locked_node.node_type);
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::Directory && !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::Directory)
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, node_type: NodeType) -> Option<String> {
163 if node_type == NodeType::Directory {
164 let child_dir = root.join(relative_path);
165 let config_path = child_dir.join("drft.toml");
166 let content = std::fs::read(&config_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 is_graph: false,
210 metadata: HashMap::new(),
211 });
212 graph.add_node(Node {
213 path: "setup.md".into(),
214 node_type: NodeType::File,
215 hash: Some(setup_hash),
216 graph: None,
217 is_graph: false,
218 metadata: HashMap::new(),
219 });
220 graph.add_edge(Edge {
221 source: "index.md".into(),
222 target: "setup.md".into(),
223 link: None,
224 parser: "markdown".into(),
225 });
226
227 let lockfile = Lockfile::from_graph(&graph);
228 write_lockfile(dir.path(), &lockfile).unwrap();
229 dir
230 }
231
232 #[test]
233 fn no_changes_when_unchanged() {
234 let dir = setup_locked_dir();
235 let graph = Graph::new();
236 let config = Config::defaults();
237 let ctx = make_ctx(&graph, dir.path(), &config);
238 let result = ChangePropagation.run(&ctx);
239 assert!(result.has_lockfile);
240 assert!(result.directly_changed.is_empty());
241 assert!(result.transitively_stale.is_empty());
242 }
243
244 #[test]
245 fn detects_direct_and_transitive() {
246 let dir = setup_locked_dir();
247 fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
248
249 let config = Config::defaults();
250 let graph = crate::graph::build_graph(dir.path(), &config).unwrap();
251 let ctx = make_ctx(&graph, dir.path(), &config);
252 let result = ChangePropagation.run(&ctx);
253 assert_eq!(result.directly_changed.len(), 1);
254 assert_eq!(result.directly_changed[0].node, "setup.md");
255 assert_eq!(result.transitively_stale.len(), 1);
256 assert_eq!(result.transitively_stale[0].node, "index.md");
257 assert_eq!(result.transitively_stale[0].via, "setup.md");
258 }
259
260 #[test]
261 fn no_lockfile_returns_empty() {
262 let dir = TempDir::new().unwrap();
263 let graph = Graph::new();
264 let config = Config::defaults();
265 let ctx = make_ctx(&graph, dir.path(), &config);
266 let result = ChangePropagation.run(&ctx);
267 assert!(!result.has_lockfile);
268 assert!(result.directly_changed.is_empty());
269 }
270
271 #[test]
272 fn detects_deleted_file() {
273 let dir = setup_locked_dir();
274 fs::remove_file(dir.path().join("setup.md")).unwrap();
275
276 let graph = Graph::new();
277 let config = Config::defaults();
278 let ctx = make_ctx(&graph, dir.path(), &config);
279 let result = ChangePropagation.run(&ctx);
280 assert_eq!(result.directly_changed.len(), 1);
281 assert_eq!(result.directly_changed[0].reason, "file deleted");
282 }
283}