ctxpack 0.1.0

Configuration-driven source packing for LLM context, with a versioning protocol for incremental patch/replace
use anyhow::Result;
use std::path::{Path, PathBuf};

use super::state::IndexState;

pub struct SnapshotCache {
    pub cache_dir: PathBuf,
}

impl SnapshotCache {
    pub fn new(cache_dir: &Path) -> Self {
        SnapshotCache { cache_dir: cache_dir.to_path_buf() }
    }

    fn snapshot_path(&self, fid: u32, generation: u32) -> PathBuf {
        self.cache_dir
            .join("snapshots")
            .join(fid.to_string())
            .join(format!("gen{}.raw", generation))
    }

    pub fn store_snapshot(&self, fid: u32, generation: u32, raw_content: &str) -> Result<()> {
        let path = self.snapshot_path(fid, generation);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&path, raw_content)?;
        Ok(())
    }

    pub fn load_snapshot(&self, fid: u32, generation: u32) -> Result<Option<String>> {
        let path = self.snapshot_path(fid, generation);
        if !path.exists() {
            return Ok(None);
        }
        let content = std::fs::read_to_string(&path)?;
        Ok(Some(content))
    }

    pub fn cleanup(&self, fid: u32, retain_last_n: u32) -> Result<()> {
        let dir = self.cache_dir.join("snapshots").join(fid.to_string());
        if !dir.exists() {
            return Ok(());
        }

        let mut gens: Vec<u32> = Vec::new();
        for entry in std::fs::read_dir(&dir)? {
            let entry = entry?;
            let name = entry.file_name();
            let name = name.to_string_lossy();
            if let Some(rest) = name.strip_prefix("gen")
                && let Some(n_str) = rest.strip_suffix(".raw")
                && let Ok(n) = n_str.parse::<u32>()
            {
                gens.push(n);
            }
        }

        gens.sort();
        let to_delete = if gens.len() > retain_last_n as usize {
            &gens[..gens.len() - retain_last_n as usize]
        } else {
            &[]
        };

        for generation in to_delete {
            let path = self.snapshot_path(fid, *generation);
            if path.exists() {
                std::fs::remove_file(&path)?;
            }
        }

        Ok(())
    }

    pub fn cleanup_all(&self, index: &IndexState, retain_last_n: u32) -> Result<()> {
        for entry in index.files.values() {
            self.cleanup(entry.fid, retain_last_n)?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_store_load_round_trip() {
        let dir = TempDir::new().unwrap();
        let cache = SnapshotCache::new(dir.path());
        cache.store_snapshot(1, 0, "hello world").unwrap();
        let loaded = cache.load_snapshot(1, 0).unwrap();
        assert_eq!(loaded, Some("hello world".to_string()));
    }

    #[test]
    fn test_load_missing_returns_none() {
        let dir = TempDir::new().unwrap();
        let cache = SnapshotCache::new(dir.path());
        let loaded = cache.load_snapshot(99, 0).unwrap();
        assert_eq!(loaded, None);
    }

    #[test]
    fn test_cleanup_retains_last_n() {
        let dir = TempDir::new().unwrap();
        let cache = SnapshotCache::new(dir.path());
        for generation in 0..5 {
            cache.store_snapshot(1, generation, &format!("gen{}", generation)).unwrap();
        }
        cache.cleanup(1, 3).unwrap();
        assert_eq!(cache.load_snapshot(1, 0).unwrap(), None);
        assert_eq!(cache.load_snapshot(1, 1).unwrap(), None);
        assert!(cache.load_snapshot(1, 2).unwrap().is_some());
        assert!(cache.load_snapshot(1, 3).unwrap().is_some());
        assert!(cache.load_snapshot(1, 4).unwrap().is_some());
    }

    #[test]
    fn test_cleanup_all() {
        let dir = TempDir::new().unwrap();
        let cache = SnapshotCache::new(dir.path());
        for generation in 0..3 {
            cache.store_snapshot(1, generation, "content").unwrap();
        }
        let mut index = IndexState::default();
        index.allocate_fid("file.rs", "file.rs");
        cache.cleanup_all(&index, 1).unwrap();
        assert_eq!(cache.load_snapshot(1, 0).unwrap(), None);
        assert_eq!(cache.load_snapshot(1, 1).unwrap(), None);
        assert!(cache.load_snapshot(1, 2).unwrap().is_some());
    }
}