scryer-mcp 0.3.0

Model Context Protocol (MCP) server for Scryer code intelligence
//! Source lines for answers that quote code (`find_references` `text`).
//!
//! Each file is read once per call. A file whose content no longer matches the hash recorded
//! at indexing yields no text rather than a line that may belong to different code.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use scryer_engine::hasher::hash_bytes;

/// Longest quoted line, in characters.
const LINE_CHARS: usize = 160;

#[derive(Default)]
pub(crate) struct LineCache {
    files: HashMap<PathBuf, Option<Vec<String>>>,
    stale: usize,
}

impl LineCache {
    /// The trimmed 1-based `line` of `root`/`rel`, cut to [`LINE_CHARS`]. `None` when the file
    /// is missing, unreadable or changed since it was indexed (`expected_hash`).
    pub(crate) fn line(
        &mut self,
        root: &Path,
        rel: &str,
        expected_hash: &str,
        line: u32,
    ) -> Option<String> {
        let path = root.join(rel);
        if !self.files.contains_key(&path) {
            let lines = std::fs::read(&path).ok().and_then(|bytes| {
                if !expected_hash.is_empty() && hash_bytes(&bytes) != expected_hash {
                    self.stale += 1;
                    return None;
                }
                Some(
                    String::from_utf8_lossy(&bytes)
                        .lines()
                        .map(str::to_string)
                        .collect(),
                )
            });
            self.files.insert(path.clone(), lines);
        }
        let text = self.files[&path]
            .as_ref()?
            .get(line.checked_sub(1)? as usize)?;
        Some(cut(text.trim()))
    }

    /// Lines `start..=end` (1-based) of `root`/`rel` joined with newlines, each cut to
    /// [`LINE_CHARS`], at most `max_lines` of them. `None` under the same conditions as
    /// [`LineCache::line`]. The second value is how many lines the range holds in all.
    pub(crate) fn range(
        &mut self,
        root: &Path,
        rel: &str,
        expected_hash: &str,
        start: u32,
        end: u32,
        max_lines: usize,
    ) -> Option<(String, usize)> {
        // Loads (and hash-checks) the file; the first line of the range is a valid probe.
        self.line(root, rel, expected_hash, start.max(1))?;
        let lines = self.files[&root.join(rel)].as_ref()?;
        let from = start.saturating_sub(1) as usize;
        let to = (end as usize).min(lines.len());
        let all = lines.get(from..to)?;
        let text = all
            .iter()
            .take(max_lines)
            .map(|l| cut(l.trim_end()))
            .collect::<Vec<_>>()
            .join("\n");
        Some((text, all.len()))
    }

    /// Files skipped because they changed since indexing.
    pub(crate) fn stale_files(&self) -> usize {
        self.stale
    }
}

fn cut(text: &str) -> String {
    if text.chars().count() <= LINE_CHARS {
        return text.to_string();
    }
    let mut out: String = text.chars().take(LINE_CHARS - 1).collect();
    out.push('…');
    out
}

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

    #[test]
    fn quotes_the_trimmed_line_and_skips_changed_files() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("a.rs"), "fn a() {}\n    let x = 1;\n").unwrap();
        let hash = hash_bytes(&std::fs::read(dir.path().join("a.rs")).unwrap());
        let mut cache = LineCache::default();
        assert_eq!(
            cache.line(dir.path(), "a.rs", &hash, 2).as_deref(),
            Some("let x = 1;")
        );
        assert_eq!(cache.line(dir.path(), "a.rs", &hash, 9), None);
        assert_eq!(cache.line(dir.path(), "a.rs", &hash, 0), None);

        let mut stale = LineCache::default();
        assert_eq!(stale.line(dir.path(), "a.rs", "not-the-hash", 1), None);
        assert_eq!(stale.stale_files(), 1);
        assert_eq!(stale.line(dir.path(), "missing.rs", &hash, 1), None);
    }

    #[test]
    fn long_lines_are_cut_with_an_ellipsis() {
        let long = "x".repeat(400);
        let cut = cut(&long);
        assert_eq!(cut.chars().count(), LINE_CHARS);
        assert!(cut.ends_with('…'));
    }
}