scryer-engine 0.3.0

Tree-sitter AST indexing and reference resolution engine for Scryer code intelligence
//! Is an indexed file still what is on disk? Answered with a stat first and a hash only when
//! the stat is new, so it is cheap enough to run on every tool call.
//!
//! The index stores each file's content hash but not its mtime, so the stat signature of a
//! file whose hash was just verified is remembered here, in memory. A later check that finds
//! the same size and mtime skips reading the file.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;

use crate::hasher::hash_bytes;

/// How many files one call refreshes at most. The rest are reported as not refreshed.
pub const MAX_REFRESH_FILES: usize = 64;

type Signature = (u64, Option<SystemTime>);

/// Stat signatures of files whose content hash matched the index when last checked.
#[derive(Default)]
pub struct FreshnessCache {
    verified: Mutex<HashMap<PathBuf, Signature>>,
}

/// What a freshness check found.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Freshness {
    /// The file matches the index.
    Fresh,
    /// The file differs from the index.
    Changed,
    /// The file no longer exists.
    Missing,
}

impl FreshnessCache {
    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, Signature>> {
        self.verified.lock().unwrap_or_else(|e| e.into_inner())
    }

    /// Compare the file at `abs` with `indexed_hash`: size and mtime first, a hash only when
    /// they are not the ones last verified. Returns the stat signature it saw with the
    /// verdict, so the caller can [`Self::record`] it once the file has been re-indexed.
    pub fn check(&self, abs: &Path, indexed_hash: &str) -> (Freshness, Option<Signature>) {
        let Ok(meta) = std::fs::metadata(abs) else {
            self.lock().remove(abs);
            return (Freshness::Missing, None);
        };
        let signature = (meta.len(), meta.modified().ok());
        if self.lock().get(abs) == Some(&signature) {
            return (Freshness::Fresh, Some(signature));
        }
        let Ok(bytes) = std::fs::read(abs) else {
            return (Freshness::Missing, None);
        };
        if hash_bytes(&bytes) == indexed_hash {
            self.lock().insert(abs.to_path_buf(), signature);
            (Freshness::Fresh, Some(signature))
        } else {
            self.lock().remove(abs);
            (Freshness::Changed, Some(signature))
        }
    }

    /// Remember that the file at `abs`, as it looked when `signature` was taken, is indexed.
    pub fn record(&self, abs: &Path, signature: Option<Signature>) {
        if let Some(signature) = signature {
            self.lock().insert(abs.to_path_buf(), signature);
        }
    }

    /// Forget a file, e.g. after it was removed from the index.
    pub fn forget(&self, abs: &Path) {
        self.lock().remove(abs);
    }
}

/// What [`crate::EngineService::refresh_files`] did.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RefreshReport {
    /// Files that had changed on disk and were re-indexed (or newly indexed).
    pub refreshed: Vec<String>,
    /// Files that no longer exist and were dropped from the index.
    pub removed: Vec<String>,
    /// Files not checked because the call asked for more than [`MAX_REFRESH_FILES`].
    pub skipped: Vec<String>,
}

impl RefreshReport {
    /// Whether the index changed.
    pub fn changed(&self) -> bool {
        !self.refreshed.is_empty() || !self.removed.is_empty()
    }

    /// How many files were refreshed or removed.
    pub fn count(&self) -> usize {
        self.refreshed.len() + self.removed.len()
    }
}

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

    #[test]
    fn a_changed_file_is_detected_and_a_verified_one_is_not_rehashed() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("a.rs");
        std::fs::write(&file, "fn a() {}").unwrap();
        let cache = FreshnessCache::default();
        let hash = hash_bytes(b"fn a() {}");

        assert_eq!(cache.check(&file, &hash).0, Freshness::Fresh);
        // The signature is remembered, so this one does not read the file.
        assert_eq!(cache.check(&file, &hash).0, Freshness::Fresh);

        std::fs::write(&file, "fn a() {}\nfn b() {}").unwrap();
        assert_eq!(cache.check(&file, &hash).0, Freshness::Changed);

        std::fs::remove_file(&file).unwrap();
        assert_eq!(cache.check(&file, &hash).0, Freshness::Missing);
    }
}