Skip to main content

drft/analyses/
change_propagation.rs

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        // Direct changes: hash comparison
59        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, &current_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        // Boundary changes
84        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 &current_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        // Transitive staleness: BFS over reverse dependency edges from current graph
115        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}