Skip to main content

agentic_codebase/engine/
incremental.rs

1//! Incremental recompilation support.
2//!
3//! Uses content hashing to detect which files have changed since the last
4//! compilation, then re-parses only those files and surgically updates
5//! the graph.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::Instant;
10
11use crate::graph::CodeGraph;
12use crate::parse::parser::ParseOptions;
13use crate::parse::parser::Parser;
14use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
15use crate::types::AcbResult;
16
17/// Set of files that have changed since the last compilation.
18#[derive(Debug, Clone)]
19pub struct ChangeSet {
20    /// Files that are new (not in the previous graph).
21    pub added: Vec<PathBuf>,
22    /// Files whose content has changed.
23    pub modified: Vec<PathBuf>,
24    /// Files that no longer exist on disk.
25    pub removed: Vec<PathBuf>,
26}
27
28impl ChangeSet {
29    /// True if nothing changed.
30    pub fn is_empty(&self) -> bool {
31        self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
32    }
33
34    /// Total number of changed files.
35    pub fn total(&self) -> usize {
36        self.added.len() + self.modified.len() + self.removed.len()
37    }
38}
39
40/// Result of an incremental recompilation.
41#[derive(Debug, Clone)]
42pub struct IncrementalResult {
43    /// Number of code units added.
44    pub units_added: usize,
45    /// Number of code units removed.
46    pub units_removed: usize,
47    /// Number of code units modified (removed + re-added).
48    pub units_modified: usize,
49    /// Total duration of the incremental recompilation.
50    pub duration: std::time::Duration,
51}
52
53/// Incremental compiler that tracks file content hashes to detect changes.
54pub struct IncrementalCompiler {
55    /// File path -> blake3 hash of contents.
56    hashes: HashMap<PathBuf, String>,
57}
58
59impl IncrementalCompiler {
60    /// Create a new incremental compiler with no previous state.
61    pub fn new() -> Self {
62        Self {
63            hashes: HashMap::new(),
64        }
65    }
66
67    /// Build an incremental compiler from an existing graph.
68    ///
69    /// Extracts file paths from the graph's code units and computes
70    /// current content hashes for each file.
71    pub fn from_graph(graph: &CodeGraph) -> Self {
72        let mut hashes = HashMap::new();
73
74        for unit in graph.units() {
75            let path = &unit.file_path;
76            if hashes.contains_key(path) {
77                continue;
78            }
79            if let Ok(content) = std::fs::read(path) {
80                let hash = blake3::hash(&content).to_hex().to_string();
81                hashes.insert(path.clone(), hash);
82            }
83        }
84
85        Self { hashes }
86    }
87
88    /// Detect which files have changed relative to the stored hashes.
89    ///
90    /// Walks the directory for supported source files and compares
91    /// content hashes against the stored state.
92    pub fn detect_changes(&self, dir: &Path) -> AcbResult<ChangeSet> {
93        let current_files = collect_source_files(dir)?;
94
95        let mut added = Vec::new();
96        let mut modified = Vec::new();
97
98        for path in &current_files {
99            let current_hash = match std::fs::read(path) {
100                Ok(content) => blake3::hash(&content).to_hex().to_string(),
101                Err(_) => continue,
102            };
103
104            match self.hashes.get(path) {
105                Some(stored_hash) => {
106                    if *stored_hash != current_hash {
107                        modified.push(path.clone());
108                    }
109                }
110                None => {
111                    added.push(path.clone());
112                }
113            }
114        }
115
116        // Files in stored hashes but not on disk
117        let current_set: std::collections::HashSet<&PathBuf> = current_files.iter().collect();
118        let removed: Vec<PathBuf> = self
119            .hashes
120            .keys()
121            .filter(|p| !current_set.contains(p))
122            .cloned()
123            .collect();
124
125        Ok(ChangeSet {
126            added,
127            modified,
128            removed,
129        })
130    }
131
132    /// Perform incremental recompilation.
133    ///
134    /// Re-parses changed files and rebuilds the graph. For simplicity,
135    /// this performs a full rebuild when changes are detected — true
136    /// surgical graph patching is a future optimisation.
137    pub fn recompile(
138        &mut self,
139        dir: &Path,
140        changes: &ChangeSet,
141    ) -> AcbResult<(CodeGraph, IncrementalResult)> {
142        let start = Instant::now();
143
144        // Count units from changed files for reporting
145        let changed_file_count = changes.total();
146
147        tracing::info!(
148            "Incremental: {} added, {} modified, {} removed",
149            changes.added.len(),
150            changes.modified.len(),
151            changes.removed.len()
152        );
153
154        // For now, do a full recompile but only report the delta.
155        // True incremental graph patching is a future optimisation.
156        let parser = Parser::new();
157        let parse_result = parser.parse_directory(dir, &ParseOptions::default())?;
158
159        let analyzer = SemanticAnalyzer::new();
160        let graph = analyzer.analyze(parse_result.units, &AnalyzeOptions::default())?;
161
162        // Update stored hashes
163        self.hashes.clear();
164        for unit in graph.units() {
165            let path = &unit.file_path;
166            if self.hashes.contains_key(path) {
167                continue;
168            }
169            if let Ok(content) = std::fs::read(path) {
170                let hash = blake3::hash(&content).to_hex().to_string();
171                self.hashes.insert(path.clone(), hash);
172            }
173        }
174
175        let duration = start.elapsed();
176
177        let result = IncrementalResult {
178            units_added: changes.added.len(),
179            units_removed: changes.removed.len(),
180            units_modified: changes.modified.len(),
181            duration,
182        };
183
184        tracing::info!(
185            "Incremental recompile: {} changed files in {:.2?}",
186            changed_file_count,
187            duration
188        );
189
190        Ok((graph, result))
191    }
192}
193
194impl Default for IncrementalCompiler {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200/// Collect supported source files from a directory.
201fn collect_source_files(dir: &Path) -> AcbResult<Vec<PathBuf>> {
202    use ignore::WalkBuilder;
203
204    let extensions = ["rs", "py", "ts", "tsx", "js", "jsx", "go"];
205    let mut files = Vec::new();
206
207    let walker = WalkBuilder::new(dir).hidden(true).git_ignore(true).build();
208
209    for entry in walker {
210        let entry = entry
211            .map_err(|e| crate::AcbError::Io(std::io::Error::other(format!("Walk error: {e}"))))?;
212        let path = entry.path();
213        if path.is_file() {
214            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
215                if extensions.contains(&ext) {
216                    files.push(path.to_path_buf());
217                }
218            }
219        }
220    }
221
222    Ok(files)
223}