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, locked_node.node_type);
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.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 &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, 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}