repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use serde_json::Value;
use std::cmp::Ordering;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::time::SystemTime;

pub(super) const MAX_VALID_ENTRIES: usize = 32;
const CACHE_GIT_PATH: &str = "repopilot/cache/mcp-scan";

static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

pub(super) fn load(path: &Path, key: &str) -> Option<String> {
    let cached = fs::read_to_string(cache_file(path, key)?).ok()?;
    valid_cached_scan(&cached).then_some(cached)
}

pub(super) fn store(path: &Path, key: &str, output: &str) {
    let Some(dir) = cache_dir(path) else {
        return;
    };
    if fs::create_dir_all(&dir).is_err() {
        return;
    }

    let file = dir.join(format!("{key}.json"));
    let tmp = unique_tmp_file(&dir, key);
    if fs::write(&tmp, output).is_err() {
        let _ = fs::remove_file(&tmp);
        return;
    }
    if fs::rename(&tmp, &file).is_ok() {
        prune(&dir);
    } else {
        let _ = fs::remove_file(&tmp);
    }
}

pub(super) fn cache_dir(path: &Path) -> Option<PathBuf> {
    let candidate = normalize_path(super::git::git_path(path, CACHE_GIT_PATH)?);
    let git_dir = canonical_metadata_dir(super::git::git_dir(path)?)?;
    let common_dir = canonical_metadata_dir(super::git::git_common_dir(path)?)?;

    (candidate.starts_with(&git_dir) || candidate.starts_with(&common_dir)).then_some(candidate)
}

fn cache_file(path: &Path, key: &str) -> Option<PathBuf> {
    Some(cache_dir(path)?.join(format!("{key}.json")))
}

fn unique_tmp_file(dir: &Path, key: &str) -> PathBuf {
    let suffix = TEMP_COUNTER.fetch_add(1, AtomicOrdering::Relaxed);
    dir.join(format!(
        "{}.{}.{}.json.tmp",
        key,
        std::process::id(),
        suffix
    ))
}

fn prune(dir: &Path) {
    let mut entries = valid_entries(dir);
    if entries.len() <= MAX_VALID_ENTRIES {
        return;
    }
    entries.sort_by(|left, right| match left.modified.cmp(&right.modified) {
        Ordering::Equal => left.path.cmp(&right.path),
        order => order,
    });

    let remove_count = entries.len().saturating_sub(MAX_VALID_ENTRIES);
    for entry in entries.into_iter().take(remove_count) {
        let _ = fs::remove_file(entry.path);
    }
}

fn valid_entries(dir: &Path) -> Vec<CacheEntry> {
    let Ok(entries) = fs::read_dir(dir) else {
        return Vec::new();
    };
    entries
        .filter_map(Result::ok)
        .filter_map(|entry| cache_entry(entry.path()))
        .collect()
}

fn cache_entry(path: PathBuf) -> Option<CacheEntry> {
    if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
        return None;
    }
    let text = fs::read_to_string(&path).ok()?;
    if !valid_cached_scan(&text) {
        return None;
    }
    let modified = fs::metadata(&path)
        .and_then(|metadata| metadata.modified())
        .unwrap_or(SystemTime::UNIX_EPOCH);
    Some(CacheEntry { path, modified })
}

struct CacheEntry {
    path: PathBuf,
    modified: SystemTime,
}

fn valid_cached_scan(text: &str) -> bool {
    let Ok(value) = serde_json::from_str::<Value>(text) else {
        return false;
    };
    value
        .get("schema_version")
        .and_then(Value::as_str)
        .is_some()
        && value
            .get("report")
            .and_then(|report| report.get("kind"))
            .and_then(Value::as_str)
            == Some("scan")
}

fn canonical_metadata_dir(path: PathBuf) -> Option<PathBuf> {
    fs::canonicalize(path).ok().map(normalize_path)
}

fn normalize_path(path: PathBuf) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
            Component::RootDir => normalized.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => {
                normalized.pop();
            }
            Component::Normal(part) => normalized.push(part),
        }
    }
    normalized
}