collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// Tracks file content hashes for incremental re-parsing.
pub struct FileHashCache {
    cache: HashMap<PathBuf, CacheEntry>,
}

struct CacheEntry {
    hash: blake3::Hash,
    mtime: SystemTime,
}

// ── Serialization helpers ──────────────────────────────────────────────

/// Serializable representation of a single cache entry.
#[derive(serde::Serialize, serde::Deserialize)]
struct DiskEntry {
    /// BLAKE3 hash as hex string.
    hash_hex: String,
    /// Modification time as seconds since UNIX epoch.
    mtime_secs: u64,
}

impl FileHashCache {
    /// Serialize the entire cache to JSON bytes.
    pub fn to_json(&self) -> serde_json::Result<Vec<u8>> {
        let map: HashMap<String, DiskEntry> = self
            .cache
            .iter()
            .filter_map(|(path, entry)| {
                let secs = entry
                    .mtime
                    .duration_since(SystemTime::UNIX_EPOCH)
                    .ok()?
                    .as_secs();
                Some((
                    path.to_string_lossy().to_string(),
                    DiskEntry {
                        hash_hex: entry.hash.to_hex().to_string(),
                        mtime_secs: secs,
                    },
                ))
            })
            .collect();
        serde_json::to_vec(&map)
    }

    /// Restore from JSON bytes produced by `to_json`.
    pub fn from_json(data: &[u8]) -> Option<Self> {
        let map: HashMap<String, DiskEntry> = serde_json::from_slice(data).ok()?;
        let mut cache = HashMap::with_capacity(map.len());
        for (path_str, entry) in map {
            let hash = blake3::Hash::from_hex(&entry.hash_hex).ok()?;
            let mtime = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(entry.mtime_secs);
            cache.insert(PathBuf::from(path_str), CacheEntry { hash, mtime });
        }
        Some(Self { cache })
    }
}

#[derive(Debug, PartialEq)]
pub enum ChangeStatus {
    /// File hasn't changed since last check.
    Unchanged,
    /// File content has changed.
    Modified,
    /// File is new (not in cache).
    New,
    /// Error accessing file.
    Error,
}

impl Default for FileHashCache {
    fn default() -> Self {
        Self::new()
    }
}

impl FileHashCache {
    pub fn new() -> Self {
        Self {
            cache: HashMap::new(),
        }
    }

    /// Check if a file has changed. Uses mtime as a fast pre-check,
    /// falls back to BLAKE3 content hashing if mtime differs.
    pub fn check_file(&mut self, path: &Path) -> ChangeStatus {
        let metadata = match std::fs::metadata(path) {
            Ok(m) => m,
            Err(_) => return ChangeStatus::Error,
        };

        let mtime = match metadata.modified() {
            Ok(t) => t,
            Err(_) => return ChangeStatus::Error,
        };

        // Fast path: mtime unchanged → skip hashing
        if let Some(entry) = self.cache.get(path) {
            if entry.mtime == mtime {
                return ChangeStatus::Unchanged;
            }

            // mtime changed — need to hash
            let hash = match hash_file(path) {
                Some(h) => h,
                None => return ChangeStatus::Error,
            };

            if hash == entry.hash {
                // Content same despite mtime change — update mtime
                self.cache.get_mut(path).unwrap().mtime = mtime;
                return ChangeStatus::Unchanged;
            }

            // Content actually changed
            self.cache
                .insert(path.to_path_buf(), CacheEntry { hash, mtime });
            return ChangeStatus::Modified;
        }

        // New file
        let hash = match hash_file(path) {
            Some(h) => h,
            None => return ChangeStatus::Error,
        };

        self.cache
            .insert(path.to_path_buf(), CacheEntry { hash, mtime });
        ChangeStatus::New
    }

    /// Invalidate a file entry (force re-parse on next check).
    pub fn invalidate(&mut self, path: &Path) {
        self.cache.remove(path);
    }
}

fn hash_file(path: &Path) -> Option<blake3::Hash> {
    // Use streaming hasher to avoid loading entire file into memory.
    // For large files (>1 MB), this saves a full memcpy.
    use std::io::Read;
    let file = std::fs::File::open(path).ok()?;
    let mut hasher = blake3::Hasher::new();
    let mut reader = std::io::BufReader::new(file);
    let mut buf = [0u8; 8192];
    loop {
        let n = reader.read(&mut buf).ok()?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Some(hasher.finalize())
}