scryer-mcp 0.2.1

Model Context Protocol (MCP) server for Scryer code intelligence
use std::path::{Path, PathBuf};

/// Longest socket path we bind. `sun_path` is 108 bytes on Linux and 104 on macOS.
const MAX_SOCKET_PATH: usize = 100;

/// Runtime files for the daemon that owns one database.
///
/// Every file name is keyed by a stable hash of the canonical database path, so
/// clients and daemons from different Scryer versions agree on where to meet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DaemonPaths {
    /// Canonical database path the daemon serves.
    pub db_path: PathBuf,
    /// Unix socket clients connect to. Usually in the run directory, but moved to a shorter
    /// per-user location when that path would exceed the platform's socket path limit.
    pub socket: PathBuf,
    /// Lock file that serializes daemon startup across clients.
    pub spawn_lock: PathBuf,
    /// Lock file held by the running daemon for its lifetime; contains its PID.
    pub pid_file: PathBuf,
    /// Daemon log (the spawner redirects the daemon's stderr here).
    pub log_file: PathBuf,
}

impl DaemonPaths {
    /// Build runtime paths for `db_path` inside `run_dir`.
    pub fn new(run_dir: &Path, db_path: &Path) -> Self {
        let db_path = canonical_db_path(db_path);
        let key = format!("{:016x}", fnv1a64(db_path.to_string_lossy().as_bytes()));
        Self {
            socket: socket_path(run_dir, &key),
            spawn_lock: run_dir.join(format!("{key}.lock")),
            pid_file: run_dir.join(format!("{key}.pid")),
            log_file: run_dir.join(format!("{key}.log")),
            db_path,
        }
    }

    /// Build runtime paths in the default run directory (`~/.scryer/run`).
    pub fn for_db(db_path: &Path) -> Self {
        Self::new(&default_run_dir(), db_path)
    }

    /// Directory holding the runtime files.
    pub fn run_dir(&self) -> &Path {
        self.pid_file.parent().unwrap_or(Path::new("."))
    }

    /// Whether the socket lives outside the private run directory (see [`DaemonPaths::socket`]).
    pub fn socket_is_external(&self) -> bool {
        self.socket.parent() != Some(self.run_dir())
    }
}

/// Default runtime directory: `$HOME/.scryer/run`, or `./.scryer/run` without a home.
pub fn default_run_dir() -> PathBuf {
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".scryer")
        .join("run")
}

/// Whether a database URL refers to a local file that a daemon can own.
///
/// In-memory and remote (Turso Cloud / libSQL / HTTP) databases have no file lock to share.
pub fn is_local_db(db_url: &str) -> bool {
    let lower = db_url.to_ascii_lowercase();
    !(db_url == ":memory:"
        || lower.starts_with("turso:")
        || lower.starts_with("libsql:")
        || lower.starts_with("http:")
        || lower.starts_with("https:"))
}

/// Socket path in `run_dir`, or a short fallback when that path is too long to bind.
fn socket_path(run_dir: &Path, key: &str) -> PathBuf {
    let preferred = run_dir.join(format!("{key}.sock"));
    if preferred.as_os_str().len() <= MAX_SOCKET_PATH {
        return preferred;
    }
    let short_dir = std::env::var_os("XDG_RUNTIME_DIR")
        .map(PathBuf::from)
        .filter(|dir| dir.is_absolute() && dir.as_os_str().len() < 64)
        .unwrap_or_else(|| PathBuf::from("/tmp"));
    short_dir.join(format!("scryer-{key}.sock"))
}

/// Canonicalize a database path even when the file does not exist yet.
fn canonical_db_path(db_path: &Path) -> PathBuf {
    if let Ok(path) = dunce::canonicalize(db_path) {
        return path;
    }
    let absolute = std::path::absolute(db_path).unwrap_or_else(|_| db_path.to_path_buf());
    match (absolute.parent(), absolute.file_name()) {
        (Some(parent), Some(name)) => dunce::canonicalize(parent)
            .map(|p| p.join(name))
            .unwrap_or(absolute),
        _ => absolute,
    }
}

/// 64-bit FNV-1a: tiny and stable across Rust releases, unlike `DefaultHasher`.
fn fnv1a64(bytes: &[u8]) -> u64 {
    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
        (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
    })
}

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

    #[test]
    fn same_db_maps_to_same_socket() {
        let dir = tempfile::tempdir().unwrap();
        let db = dir.path().join("scryer.db");
        let a = DaemonPaths::new(dir.path(), &db);
        let b = DaemonPaths::new(dir.path(), &dir.path().join(".").join("scryer.db"));
        assert_eq!(a.socket, b.socket);
        assert_ne!(
            a.socket,
            DaemonPaths::new(dir.path(), &dir.path().join("other.db")).socket
        );
    }

    #[test]
    fn long_run_dirs_fall_back_to_short_socket_paths() {
        let long_dir = PathBuf::from("/").join("d".repeat(120));
        let paths = DaemonPaths::new(&long_dir, &long_dir.join("scryer.db"));
        assert!(paths.socket.as_os_str().len() <= MAX_SOCKET_PATH);
        assert!(paths.socket_is_external());
        assert_eq!(paths.run_dir(), long_dir.as_path());

        let short = DaemonPaths::new(Path::new("/home/me/.scryer/run"), Path::new("/x.db"));
        assert!(!short.socket_is_external());
    }

    #[test]
    fn remote_and_memory_urls_are_not_local() {
        assert!(is_local_db("/home/me/.scryer/scryer.db"));
        assert!(!is_local_db(":memory:"));
        assert!(!is_local_db("libsql://db.turso.io"));
        assert!(!is_local_db("https://db.turso.io"));
    }
}