Skip to main content

sr_ai/cache/
mod.rs

1pub mod fingerprint;
2pub mod store;
3
4use crate::commands::commit::CommitPlan;
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8use fingerprint::{compute_fingerprints, sha256_hex};
9use store::{CacheEntry, cache_dir, list_entries, read_entry, write_entry};
10
11/// Result of a cache lookup.
12pub enum CacheLookup {
13    /// Exact fingerprint match — use cached plan directly.
14    ExactHit(CommitPlan),
15    /// Some files changed — patched plan with affected commits marked.
16    /// When `unplaced_files` is empty, the plan can be executed directly
17    /// (no AI call needed). When non-empty, AI is called with a targeted
18    /// prompt to place only the new files.
19    PatchHit {
20        plan: CommitPlan,
21        /// Indices of commits that contain changed/removed files.
22        dirty_commits: Vec<usize>,
23        /// Files that changed content (exist in a commit but hash differs).
24        changed_files: Vec<String>,
25        /// New files not belonging to any cached commit (need AI placement).
26        unplaced_files: Vec<String>,
27        /// Human-readable delta summary for AI prompt.
28        delta_summary: String,
29    },
30    /// Too much changed or no cache — full AI run.
31    Miss,
32}
33
34pub struct CacheManager {
35    repo_root: PathBuf,
36    dir: PathBuf,
37    fingerprints: BTreeMap<String, String>,
38    state_key: String,
39}
40
41impl CacheManager {
42    /// Build a new CacheManager, computing fingerprints and state key.
43    /// Returns None if cache dir can't be resolved (graceful degradation).
44    pub fn new(
45        repo_root: &Path,
46        staged_only: bool,
47        user_message: Option<&str>,
48        backend: &str,
49        model: &str,
50    ) -> Option<Self> {
51        let dir = cache_dir(repo_root)?;
52        let fingerprints = compute_fingerprints(repo_root, staged_only);
53
54        let state_key = compute_state_key(&fingerprints, staged_only, user_message, backend, model);
55
56        Some(Self {
57            repo_root: repo_root.to_path_buf(),
58            dir,
59            fingerprints,
60            state_key,
61        })
62    }
63
64    /// Look up the cache. Returns ExactHit, PatchHit, or Miss.
65    ///
66    /// **ExactHit**: all file fingerprints match — use plan as-is.
67    /// **PatchHit**: some files changed — identifies dirty commits and unplaced files.
68    ///   When `unplaced_files` is empty the plan can execute without an AI call.
69    /// **Miss**: too much changed (>50%) or no cache.
70    pub fn lookup(&self) -> CacheLookup {
71        // Tier 1: exact match
72        let exact_path = store::entry_path(&self.dir, &self.state_key);
73        if let Ok(entry) = read_entry(&exact_path) {
74            return CacheLookup::ExactHit(entry.plan);
75        }
76
77        // Tier 2: find best candidate for DAG-aware patching
78        let entries = match list_entries(&self.dir) {
79            Ok(e) => e,
80            Err(_) => return CacheLookup::Miss,
81        };
82
83        if entries.is_empty() {
84            return CacheLookup::Miss;
85        }
86
87        // Pick the most recent entry as the patch candidate
88        let candidate = &entries[0];
89        let delta = compute_delta(&candidate.fingerprints, &self.fingerprints);
90
91        // Bail if >50% changed — not worth patching
92        let total = self.fingerprints.len().max(candidate.fingerprints.len());
93        let change_count = delta.changed.len() + delta.added.len() + delta.removed.len();
94        if total == 0 || change_count * 2 > total {
95            return CacheLookup::Miss;
96        }
97
98        // Map changed/removed files to commits in the cached plan (dirty commits).
99        let affected_files: std::collections::BTreeSet<&str> = delta
100            .changed
101            .iter()
102            .chain(delta.removed.iter())
103            .map(|s| s.as_str())
104            .collect();
105
106        let mut dirty_commits = Vec::new();
107        for (i, commit) in candidate.plan.commits.iter().enumerate() {
108            if commit
109                .files
110                .iter()
111                .any(|f| affected_files.contains(f.as_str()))
112            {
113                dirty_commits.push(i);
114            }
115        }
116
117        // Files in `added` that don't belong to any commit are "unplaced".
118        let plan_files: std::collections::BTreeSet<&str> = candidate
119            .plan
120            .commits
121            .iter()
122            .flat_map(|c| c.files.iter().map(|f| f.as_str()))
123            .collect();
124
125        let unplaced_files: Vec<String> = delta
126            .added
127            .iter()
128            .filter(|f| !plan_files.contains(f.as_str()))
129            .cloned()
130            .collect();
131
132        // Build a patched plan: remove files from commits that were deleted,
133        // keep the rest as-is. The dirty_commits list tells the caller which
134        // commits need re-validation.
135        let mut plan = candidate.plan.clone();
136        if !delta.removed.is_empty() {
137            let removed_set: std::collections::BTreeSet<&str> =
138                delta.removed.iter().map(|s| s.as_str()).collect();
139            for commit in &mut plan.commits {
140                commit.files.retain(|f| !removed_set.contains(f.as_str()));
141            }
142            // Drop commits that became empty after removal.
143            plan.commits.retain(|c| !c.files.is_empty());
144        }
145
146        let summary = format_delta_summary(&delta);
147
148        CacheLookup::PatchHit {
149            plan,
150            dirty_commits,
151            changed_files: delta.changed.clone(),
152            unplaced_files,
153            delta_summary: summary,
154        }
155    }
156
157    /// Store a plan in the cache.
158    pub fn store(&self, plan: &CommitPlan, backend: &str, model: &str) {
159        let entry = CacheEntry {
160            state_key: self.state_key.clone(),
161            fingerprints: self.fingerprints.clone(),
162            plan: plan.clone(),
163            created_at: store::now_secs(),
164            backend: backend.to_string(),
165            model: model.to_string(),
166        };
167
168        if let Err(e) = write_entry(&self.dir, &entry) {
169            eprintln!("Warning: failed to write cache: {e}");
170        }
171    }
172
173    /// Clear cache for this repo.
174    pub fn clear(&self) -> anyhow::Result<usize> {
175        store::clear(&self.dir)
176    }
177
178    #[allow(dead_code)]
179    pub fn dir(&self) -> &Path {
180        &self.dir
181    }
182
183    #[allow(dead_code)]
184    pub fn repo_root(&self) -> &Path {
185        &self.repo_root
186    }
187}
188
189/// Compute the full state key from fingerprints + parameters.
190fn compute_state_key(
191    fingerprints: &BTreeMap<String, String>,
192    staged_only: bool,
193    user_message: Option<&str>,
194    backend: &str,
195    model: &str,
196) -> String {
197    let mut data = String::new();
198    for (file, hash) in fingerprints {
199        data.push_str(file);
200        data.push(':');
201        data.push_str(hash);
202        data.push('\n');
203    }
204    data.push_str(&format!("staged:{staged_only}\n"));
205    if let Some(msg) = user_message {
206        data.push_str(&format!("message:{msg}\n"));
207    }
208    data.push_str(&format!("backend:{backend}\n"));
209    data.push_str(&format!("model:{model}\n"));
210
211    sha256_hex(data.as_bytes())
212}
213
214struct FileDelta {
215    unchanged: Vec<String>,
216    changed: Vec<String>,
217    added: Vec<String>,
218    removed: Vec<String>,
219}
220
221fn compute_delta(old: &BTreeMap<String, String>, new: &BTreeMap<String, String>) -> FileDelta {
222    let mut unchanged = Vec::new();
223    let mut changed = Vec::new();
224    let mut added = Vec::new();
225    let mut removed = Vec::new();
226
227    for (file, new_hash) in new {
228        match old.get(file) {
229            Some(old_hash) if old_hash == new_hash => unchanged.push(file.clone()),
230            Some(_) => changed.push(file.clone()),
231            None => added.push(file.clone()),
232        }
233    }
234
235    for file in old.keys() {
236        if !new.contains_key(file) {
237            removed.push(file.clone());
238        }
239    }
240
241    FileDelta {
242        unchanged,
243        changed,
244        added,
245        removed,
246    }
247}
248
249fn format_delta_summary(delta: &FileDelta) -> String {
250    let mut parts = Vec::new();
251
252    if !delta.unchanged.is_empty() {
253        parts.push(format!(
254            "Unchanged files (keep previous groupings): {}",
255            delta.unchanged.join(", ")
256        ));
257    }
258    if !delta.changed.is_empty() {
259        parts.push(format!(
260            "Modified files (re-analyze): {}",
261            delta.changed.join(", ")
262        ));
263    }
264    if !delta.added.is_empty() {
265        parts.push(format!("New files: {}", delta.added.join(", ")));
266    }
267    if !delta.removed.is_empty() {
268        parts.push(format!(
269            "Removed files (drop from plan): {}",
270            delta.removed.join(", ")
271        ));
272    }
273
274    parts.join("\n")
275}