Skip to main content

drft/analyses/
change_propagation.rs

1use super::{Analysis, AnalysisContext};
2use crate::graph::hash_bytes;
3use crate::lockfile::read_lockfile;
4use std::collections::{HashMap, HashSet, VecDeque};
5use std::path::Path;
6
7#[derive(Debug, Clone, serde::Serialize)]
8pub struct DirectChange {
9    pub node: String,
10    pub reason: String,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct TransitiveStale {
15    pub node: String,
16    pub via: String,
17}
18
19#[derive(Debug, Clone, serde::Serialize)]
20pub struct ChangePropagationResult {
21    pub has_lockfile: bool,
22    pub directly_changed: Vec<DirectChange>,
23    pub transitively_stale: Vec<TransitiveStale>,
24}
25
26pub struct ChangePropagation;
27
28impl Analysis for ChangePropagation {
29    type Output = ChangePropagationResult;
30
31    fn name(&self) -> &str {
32        "change-propagation"
33    }
34
35    fn run(&self, ctx: &AnalysisContext) -> ChangePropagationResult {
36        let graph = ctx.graph;
37        let root = ctx.root;
38        let lockfile = match read_lockfile(root) {
39            Ok(Some(lf)) => lf,
40            _ => {
41                return ChangePropagationResult {
42                    has_lockfile: false,
43                    directly_changed: Vec::new(),
44                    transitively_stale: Vec::new(),
45                };
46            }
47        };
48
49        // Direct changes: hash comparison
50        let mut directly_stale: HashSet<String> = HashSet::new();
51        let mut directly_changed = Vec::new();
52
53        for (path, locked_node) in &lockfile.nodes {
54            // Directory nodes are existence-only leaves with no hash.
55            if locked_node.hash.is_none() {
56                continue;
57            }
58            let current_hash = compute_current_hash(root, path);
59            match (&locked_node.hash, &current_hash) {
60                (Some(locked), Some(current)) if locked != current => {
61                    directly_stale.insert(path.clone());
62                    directly_changed.push(DirectChange {
63                        node: path.clone(),
64                        reason: "content changed".into(),
65                    });
66                }
67                (Some(_), None) => {
68                    directly_stale.insert(path.clone());
69                    directly_changed.push(DirectChange {
70                        node: path.clone(),
71                        reason: "file deleted".into(),
72                    });
73                }
74                _ => {}
75            }
76        }
77
78        // Transitive staleness: BFS over reverse dependency edges from current graph
79        let mut transitively_stale = Vec::new();
80
81        if !directly_stale.is_empty() {
82            let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
83            for edge in &graph.edges {
84                dependents
85                    .entry(edge.target.as_str())
86                    .or_default()
87                    .push(edge.source.as_str());
88            }
89
90            let mut stale_via: HashMap<String, String> = HashMap::new();
91            let mut queue: VecDeque<String> = directly_stale.iter().cloned().collect();
92
93            while let Some(stale_node) = queue.pop_front() {
94                if let Some(deps) = dependents.get(stale_node.as_str()) {
95                    for &dependent in deps {
96                        if !stale_via.contains_key(dependent) && !directly_stale.contains(dependent)
97                        {
98                            stale_via.insert(dependent.to_string(), stale_node.clone());
99                            queue.push_back(dependent.to_string());
100                        }
101                    }
102                }
103            }
104
105            let mut stale_pairs: Vec<_> = stale_via.into_iter().collect();
106            stale_pairs.sort_by(|a, b| a.0.cmp(&b.0));
107
108            transitively_stale = stale_pairs
109                .into_iter()
110                .map(|(node, via)| TransitiveStale { node, via })
111                .collect();
112        }
113
114        directly_changed.sort_by(|a, b| a.node.cmp(&b.node));
115
116        ChangePropagationResult {
117            has_lockfile: true,
118            directly_changed,
119            transitively_stale,
120        }
121    }
122}
123
124fn compute_current_hash(root: &Path, relative_path: &str) -> Option<String> {
125    let full_path = root.join(relative_path);
126    let content = std::fs::read(&full_path).ok()?;
127    Some(hash_bytes(&content))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::analyses::AnalysisContext;
134    use crate::config::Config;
135    use crate::graph::test_helpers::make_edge;
136    use crate::graph::{Graph, Node};
137    use crate::lockfile::{Lockfile, write_lockfile};
138    use std::collections::HashMap;
139    use std::fs;
140    use tempfile::TempDir;
141
142    fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> AnalysisContext<'a> {
143        AnalysisContext {
144            graph,
145            root,
146            config,
147            lockfile: None,
148        }
149    }
150
151    fn setup_locked_dir() -> TempDir {
152        let dir = TempDir::new().unwrap();
153        fs::write(dir.path().join("index.md"), "[setup](setup.md)").unwrap();
154        fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
155
156        let mut graph = Graph::new();
157        let index_hash = hash_bytes(b"[setup](setup.md)");
158        let setup_hash = hash_bytes(b"# Setup");
159
160        graph.add_node(Node {
161            path: "index.md".into(),
162            node_type: Some(crate::graph::NodeType::File),
163            included: true,
164            hash: Some(index_hash),
165            metadata: HashMap::new(),
166        });
167        graph.add_node(Node {
168            path: "setup.md".into(),
169            node_type: Some(crate::graph::NodeType::File),
170            included: true,
171            hash: Some(setup_hash),
172            metadata: HashMap::new(),
173        });
174        graph.add_edge(make_edge("index.md", "setup.md"));
175
176        let lockfile = Lockfile::from_graph(&graph);
177        write_lockfile(dir.path(), &lockfile).unwrap();
178        dir
179    }
180
181    #[test]
182    fn no_changes_when_unchanged() {
183        let dir = setup_locked_dir();
184        let graph = Graph::new();
185        let config = Config::defaults();
186        let ctx = make_ctx(&graph, dir.path(), &config);
187        let result = ChangePropagation.run(&ctx);
188        assert!(result.has_lockfile);
189        assert!(result.directly_changed.is_empty());
190        assert!(result.transitively_stale.is_empty());
191    }
192
193    #[test]
194    fn detects_direct_and_transitive() {
195        let dir = setup_locked_dir();
196        fs::write(dir.path().join("setup.md"), "# Setup (edited)").unwrap();
197
198        let config = Config::defaults();
199        let graph = crate::graph::build_graph(dir.path(), &config).unwrap();
200        let ctx = make_ctx(&graph, dir.path(), &config);
201        let result = ChangePropagation.run(&ctx);
202        assert_eq!(result.directly_changed.len(), 1);
203        assert_eq!(result.directly_changed[0].node, "setup.md");
204        assert_eq!(result.transitively_stale.len(), 1);
205        assert_eq!(result.transitively_stale[0].node, "index.md");
206        assert_eq!(result.transitively_stale[0].via, "setup.md");
207    }
208
209    #[test]
210    fn no_lockfile_returns_empty() {
211        let dir = TempDir::new().unwrap();
212        let graph = Graph::new();
213        let config = Config::defaults();
214        let ctx = make_ctx(&graph, dir.path(), &config);
215        let result = ChangePropagation.run(&ctx);
216        assert!(!result.has_lockfile);
217        assert!(result.directly_changed.is_empty());
218    }
219
220    #[test]
221    fn detects_deleted_file() {
222        let dir = setup_locked_dir();
223        fs::remove_file(dir.path().join("setup.md")).unwrap();
224
225        let graph = Graph::new();
226        let config = Config::defaults();
227        let ctx = make_ctx(&graph, dir.path(), &config);
228        let result = ChangePropagation.run(&ctx);
229        assert_eq!(result.directly_changed.len(), 1);
230        assert_eq!(result.directly_changed[0].reason, "file deleted");
231    }
232}