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
11pub enum CacheLookup {
13 ExactHit(CommitPlan),
15 PatchHit {
20 plan: CommitPlan,
21 dirty_commits: Vec<usize>,
23 changed_files: Vec<String>,
25 unplaced_files: Vec<String>,
27 delta_summary: String,
29 },
30 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 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 pub fn lookup(&self) -> CacheLookup {
71 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 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 let candidate = &entries[0];
89 let delta = compute_delta(&candidate.fingerprints, &self.fingerprints);
90
91 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 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 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 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 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 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 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
189fn 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}