greplm-core 0.6.0

Core indexing and search engine for greplm: a trigram code index for LLM agents.
Documentation
//! Wire protocol shared by the daemon and its clients.
//!
//! Messages are newline-delimited JSON over a Unix domain socket. Each request
//! is one JSON object on a line; each response is one JSON object on a line.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::search::{SearchQuery, SymbolQuery};

/// Default socket path relative to a project's `.greplm` directory.
pub const SOCKET_NAME: &str = "greplmd.sock";

/// Machine-wide (per-user) socket for the global multi-root daemon. One daemon
/// serves every project the user touches, lazily loading and evicting them, so
/// running many agents across many repos costs a single background process.
///
/// Placed in the per-user runtime/temp dir so it's private to the user and
/// cleared across reboots. Falls back through `XDG_RUNTIME_DIR` (Linux),
/// `TMPDIR` (macOS), `~/.cache`, then the system temp dir.
pub fn global_socket_path() -> PathBuf {
    let base = std::env::var_os("XDG_RUNTIME_DIR")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
        .unwrap_or_else(std::env::temp_dir);
    base.join("greplm").join(SOCKET_NAME)
}

/// How fresh the daemon's answer must be relative to on-disk edits.
///
/// The daemon serves from a warm in-memory index kept current by a background
/// filesystem watcher (debounced). That is ideal for a human editor but leaves
/// a brief window in which an agent that *edits a file and immediately queries
/// it* can read a line number from the pre-edit index. `Freshness` lets a
/// caller trade latency for read-after-write guarantees on that boundary.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Freshness {
    /// Serve straight from the warm index and rely on the watcher to catch up.
    /// Lowest latency; the default for human-facing and latency-sensitive calls.
    #[default]
    Lazy,
    /// Serve from the warm index but stat the tree first; if any indexed file
    /// differs on disk, return the answer with [`Response::stale`] set so the
    /// caller knows it may not reflect the most recent edits. Adds a stat-only
    /// tree walk (no hashing, no reindex), so it stays fast.
    Flag,
    /// Bring the index up to date with on-disk edits before answering, so the
    /// result reflects the current working tree (read-after-write). Costs a
    /// tree walk plus a reindex of whatever changed.
    Strict,
}

/// A request addressed to a specific project root, used by the global daemon
/// (which serves many roots over one socket). The client resolves `root` to the
/// project's `.greplm` ancestor before sending.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutedRequest {
    pub root: PathBuf,
    /// Freshness mode for this query. Optional on the wire so an older client
    /// (which never sends it) deserializes as [`Freshness::Lazy`].
    #[serde(default)]
    pub freshness: Freshness,
    pub req: Request,
}

/// A request to a per-project daemon, carrying the freshness mode alongside the
/// request. The request is flattened so a bare pre-freshness `Request` (an older
/// client) still deserializes here with `freshness` defaulting to
/// [`Freshness::Lazy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalRequest {
    #[serde(default)]
    pub freshness: Freshness,
    #[serde(flatten)]
    pub req: Request,
}

/// A request from a client to the daemon.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
    Ping,
    Status,
    Summary,
    Reindex {
        force: bool,
    },
    Search(SearchQuery),
    Symbols(SymbolQuery),
    Refs {
        name: String,
        limit: usize,
        offset: usize,
    },
    /// Resolved references (definitions + call sites + imports) from the
    /// structural reference index.
    RefsResolved {
        name: String,
        limit: usize,
        offset: usize,
    },
    /// Call sites that target a symbol (who calls it).
    Callers {
        name: String,
        limit: usize,
        offset: usize,
    },
    /// Call sites inside a symbol's body (what it calls).
    Callees {
        name: String,
        limit: usize,
        offset: usize,
    },
    /// Symbols transitively affected by changing a symbol (reverse call graph).
    BlastRadius {
        name: String,
        depth: u32,
        limit: usize,
    },
    /// Typed go-to-definition for the identifier at a source position.
    Definition {
        file: String,
        line: u32,
        col: u32,
    },
    /// Resolved references for the identifier at a source position.
    ReferencesAt {
        file: String,
        line: u32,
        col: u32,
    },
    /// Structural (AST) search by tree-sitter query or meta-variable pattern.
    Structural {
        pattern: String,
        lang: String,
        limit: usize,
        offset: usize,
    },
    /// Build a token-budgeted context pack for a task.
    ContextPack {
        task: String,
        budget: u64,
    },
    /// Git blame for a single line.
    Blame {
        file: String,
        line: u32,
    },
    /// Commit history of a symbol's definition.
    History {
        name: String,
        limit: usize,
    },
    /// Files (with symbols) changed since a revision.
    ChangedSince {
        rev: String,
    },
    Outline {
        file: String,
    },
    Snippet {
        file: String,
        start: u32,
        end: u32,
        context: u32,
    },
}

/// A response from the daemon to a client.
///
/// `result` is a pre-serialized JSON fragment ([`serde_json::value::RawValue`])
/// rather than a `serde_json::Value` tree: the daemon serializes each result
/// exactly once (typed struct -> JSON text) and the framing serializer embeds
/// it verbatim, instead of building and then re-walking an intermediate
/// `Value` tree for every response. Clients that want typed access parse the
/// fragment directly into their target type; clients that just forward the
/// payload (the MCP server) pass the text through untouched.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Box<serde_json::value::RawValue>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Set when a [`Freshness::Flag`] query found the index behind the working
    /// tree: the result is still returned but may not reflect recent edits.
    /// Omitted from the wire when false so existing responses are unchanged.
    #[serde(default, skip_serializing_if = "is_false")]
    pub stale: bool,
}

/// Skip-serializing predicate for the `stale` flag (a `false` is the common
/// case and stays off the wire).
fn is_false(b: &bool) -> bool {
    !*b
}

impl Response {
    /// Build a success response by serializing `value` once.
    pub fn json<T: serde::Serialize>(value: &T) -> Self {
        match serde_json::value::to_raw_value(value) {
            Ok(raw) => Response {
                ok: true,
                result: Some(raw),
                error: None,
                stale: false,
            },
            Err(e) => Response::err(e.to_string()),
        }
    }

    pub fn err(message: impl Into<String>) -> Self {
        Response {
            ok: false,
            result: None,
            error: Some(message.into()),
            stale: false,
        }
    }

    /// Mark this response stale (or not) and return it, for the [`Freshness::Flag`]
    /// path to annotate an otherwise-normal answer.
    pub fn with_stale(mut self, stale: bool) -> Self {
        self.stale = stale;
        self
    }

    /// The raw JSON text of the result (`"null"` when absent).
    pub fn result_text(&self) -> &str {
        self.result.as_deref().map(|r| r.get()).unwrap_or("null")
    }
}

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

    /// The flattened `LocalRequest` envelope must round-trip every request and,
    /// crucially, still accept a bare pre-freshness `Request` (older client) as
    /// `Lazy`. This guards the `#[serde(flatten)]` over the internally-tagged
    /// `Request` enum, which is the subtle part of the wire format.
    #[test]
    fn local_request_roundtrips_and_accepts_bare_request() {
        let env = LocalRequest {
            freshness: Freshness::Strict,
            req: Request::Search(SearchQuery {
                pattern: "needle".into(),
                ..Default::default()
            }),
        };
        let json = serde_json::to_string(&env).unwrap();
        // Flattened: the op tag sits next to `freshness`, not nested under `req`.
        assert!(json.contains("\"op\":\"search\""), "flattened op: {json}");
        assert!(
            json.contains("\"freshness\":\"strict\""),
            "freshness: {json}"
        );
        let back: LocalRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(back.freshness, Freshness::Strict);
        assert!(matches!(back.req, Request::Search(_)));

        // A bare request from an older client (no `freshness` key) defaults Lazy.
        let bare = serde_json::to_string(&Request::Status).unwrap();
        let env: LocalRequest = serde_json::from_str(&bare).unwrap();
        assert_eq!(env.freshness, Freshness::Lazy);
        assert!(matches!(env.req, Request::Status));
    }

    /// `stale` stays off the wire when false (responses are byte-for-byte as
    /// before) and appears when set.
    #[test]
    fn response_stale_is_omitted_when_false() {
        let clean = serde_json::to_string(&Response::json(&42)).unwrap();
        assert!(!clean.contains("stale"), "clean response: {clean}");
        let flagged = serde_json::to_string(&Response::json(&42).with_stale(true)).unwrap();
        assert!(flagged.contains("\"stale\":true"), "flagged: {flagged}");
    }
}