pub mod fingerprint;
pub mod store;
use crate::commands::commit::CommitPlan;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use fingerprint::{compute_fingerprints, sha256_hex};
use store::{CacheEntry, cache_dir, list_entries, read_entry, write_entry};
pub enum CacheLookup {
ExactHit(CommitPlan),
PatchHit {
plan: CommitPlan,
dirty_commits: Vec<usize>,
changed_files: Vec<String>,
unplaced_files: Vec<String>,
delta_summary: String,
},
Miss,
}
pub struct CacheManager {
repo_root: PathBuf,
dir: PathBuf,
fingerprints: BTreeMap<String, String>,
state_key: String,
}
impl CacheManager {
pub fn new(
repo_root: &Path,
staged_only: bool,
user_message: Option<&str>,
backend: &str,
model: &str,
) -> Option<Self> {
let dir = cache_dir(repo_root)?;
let fingerprints = compute_fingerprints(repo_root, staged_only);
let state_key = compute_state_key(&fingerprints, staged_only, user_message, backend, model);
Some(Self {
repo_root: repo_root.to_path_buf(),
dir,
fingerprints,
state_key,
})
}
pub fn lookup(&self) -> CacheLookup {
let exact_path = store::entry_path(&self.dir, &self.state_key);
if let Ok(entry) = read_entry(&exact_path) {
return CacheLookup::ExactHit(entry.plan);
}
let entries = match list_entries(&self.dir) {
Ok(e) => e,
Err(_) => return CacheLookup::Miss,
};
if entries.is_empty() {
return CacheLookup::Miss;
}
let candidate = &entries[0];
let delta = compute_delta(&candidate.fingerprints, &self.fingerprints);
let total = self.fingerprints.len().max(candidate.fingerprints.len());
let change_count = delta.changed.len() + delta.added.len() + delta.removed.len();
if total == 0 || change_count * 2 > total {
return CacheLookup::Miss;
}
let affected_files: std::collections::BTreeSet<&str> = delta
.changed
.iter()
.chain(delta.removed.iter())
.map(|s| s.as_str())
.collect();
let mut dirty_commits = Vec::new();
for (i, commit) in candidate.plan.commits.iter().enumerate() {
if commit
.files
.iter()
.any(|f| affected_files.contains(f.as_str()))
{
dirty_commits.push(i);
}
}
let plan_files: std::collections::BTreeSet<&str> = candidate
.plan
.commits
.iter()
.flat_map(|c| c.files.iter().map(|f| f.as_str()))
.collect();
let unplaced_files: Vec<String> = delta
.added
.iter()
.filter(|f| !plan_files.contains(f.as_str()))
.cloned()
.collect();
let mut plan = candidate.plan.clone();
if !delta.removed.is_empty() {
let removed_set: std::collections::BTreeSet<&str> =
delta.removed.iter().map(|s| s.as_str()).collect();
for commit in &mut plan.commits {
commit.files.retain(|f| !removed_set.contains(f.as_str()));
}
plan.commits.retain(|c| !c.files.is_empty());
}
let summary = format_delta_summary(&delta);
CacheLookup::PatchHit {
plan,
dirty_commits,
changed_files: delta.changed.clone(),
unplaced_files,
delta_summary: summary,
}
}
pub fn store(&self, plan: &CommitPlan, backend: &str, model: &str) {
let entry = CacheEntry {
state_key: self.state_key.clone(),
fingerprints: self.fingerprints.clone(),
plan: plan.clone(),
created_at: store::now_secs(),
backend: backend.to_string(),
model: model.to_string(),
};
if let Err(e) = write_entry(&self.dir, &entry) {
eprintln!("Warning: failed to write cache: {e}");
}
}
pub fn clear(&self) -> anyhow::Result<usize> {
store::clear(&self.dir)
}
#[allow(dead_code)]
pub fn dir(&self) -> &Path {
&self.dir
}
#[allow(dead_code)]
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
}
fn compute_state_key(
fingerprints: &BTreeMap<String, String>,
staged_only: bool,
user_message: Option<&str>,
backend: &str,
model: &str,
) -> String {
let mut data = String::new();
for (file, hash) in fingerprints {
data.push_str(file);
data.push(':');
data.push_str(hash);
data.push('\n');
}
data.push_str(&format!("staged:{staged_only}\n"));
if let Some(msg) = user_message {
data.push_str(&format!("message:{msg}\n"));
}
data.push_str(&format!("backend:{backend}\n"));
data.push_str(&format!("model:{model}\n"));
sha256_hex(data.as_bytes())
}
struct FileDelta {
unchanged: Vec<String>,
changed: Vec<String>,
added: Vec<String>,
removed: Vec<String>,
}
fn compute_delta(old: &BTreeMap<String, String>, new: &BTreeMap<String, String>) -> FileDelta {
let mut unchanged = Vec::new();
let mut changed = Vec::new();
let mut added = Vec::new();
let mut removed = Vec::new();
for (file, new_hash) in new {
match old.get(file) {
Some(old_hash) if old_hash == new_hash => unchanged.push(file.clone()),
Some(_) => changed.push(file.clone()),
None => added.push(file.clone()),
}
}
for file in old.keys() {
if !new.contains_key(file) {
removed.push(file.clone());
}
}
FileDelta {
unchanged,
changed,
added,
removed,
}
}
fn format_delta_summary(delta: &FileDelta) -> String {
let mut parts = Vec::new();
if !delta.unchanged.is_empty() {
parts.push(format!(
"Unchanged files (keep previous groupings): {}",
delta.unchanged.join(", ")
));
}
if !delta.changed.is_empty() {
parts.push(format!(
"Modified files (re-analyze): {}",
delta.changed.join(", ")
));
}
if !delta.added.is_empty() {
parts.push(format!("New files: {}", delta.added.join(", ")));
}
if !delta.removed.is_empty() {
parts.push(format!(
"Removed files (drop from plan): {}",
delta.removed.join(", ")
));
}
parts.join("\n")
}