Skip to main content

greplm_core/
proto.rs

1//! Wire protocol shared by the daemon and its clients.
2//!
3//! Messages are newline-delimited JSON over a Unix domain socket. Each request
4//! is one JSON object on a line; each response is one JSON object on a line.
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use crate::search::{SearchQuery, SymbolQuery};
11
12/// Default socket path relative to a project's `.greplm` directory.
13pub const SOCKET_NAME: &str = "greplmd.sock";
14
15/// Machine-wide (per-user) socket for the global multi-root daemon. One daemon
16/// serves every project the user touches, lazily loading and evicting them, so
17/// running many agents across many repos costs a single background process.
18///
19/// Placed in the per-user runtime/temp dir so it's private to the user and
20/// cleared across reboots. Falls back through `XDG_RUNTIME_DIR` (Linux),
21/// `TMPDIR` (macOS), `~/.cache`, then the system temp dir.
22pub fn global_socket_path() -> PathBuf {
23    let base = std::env::var_os("XDG_RUNTIME_DIR")
24        .map(PathBuf::from)
25        .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
26        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
27        .unwrap_or_else(std::env::temp_dir);
28    base.join("greplm").join(SOCKET_NAME)
29}
30
31/// How fresh the daemon's answer must be relative to on-disk edits.
32///
33/// The daemon serves from a warm in-memory index kept current by a background
34/// filesystem watcher (debounced). That is ideal for a human editor but leaves
35/// a brief window in which an agent that *edits a file and immediately queries
36/// it* can read a line number from the pre-edit index. `Freshness` lets a
37/// caller trade latency for read-after-write guarantees on that boundary.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Freshness {
41    /// Serve straight from the warm index and rely on the watcher to catch up.
42    /// Lowest latency; the default for human-facing and latency-sensitive calls.
43    #[default]
44    Lazy,
45    /// Serve from the warm index but stat the tree first; if any indexed file
46    /// differs on disk, return the answer with [`Response::stale`] set so the
47    /// caller knows it may not reflect the most recent edits. Adds a stat-only
48    /// tree walk (no hashing, no reindex), so it stays fast.
49    Flag,
50    /// Bring the index up to date with on-disk edits before answering, so the
51    /// result reflects the current working tree (read-after-write). Costs a
52    /// tree walk plus a reindex of whatever changed.
53    Strict,
54}
55
56/// A request addressed to a specific project root, used by the global daemon
57/// (which serves many roots over one socket). The client resolves `root` to the
58/// project's `.greplm` ancestor before sending.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct RoutedRequest {
61    pub root: PathBuf,
62    /// Freshness mode for this query. Optional on the wire so an older client
63    /// (which never sends it) deserializes as [`Freshness::Lazy`].
64    #[serde(default)]
65    pub freshness: Freshness,
66    pub req: Request,
67}
68
69/// A request to a per-project daemon, carrying the freshness mode alongside the
70/// request. The request is flattened so a bare pre-freshness `Request` (an older
71/// client) still deserializes here with `freshness` defaulting to
72/// [`Freshness::Lazy`].
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct LocalRequest {
75    #[serde(default)]
76    pub freshness: Freshness,
77    #[serde(flatten)]
78    pub req: Request,
79}
80
81/// A request from a client to the daemon.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(tag = "op", rename_all = "snake_case")]
84pub enum Request {
85    Ping,
86    Status,
87    Summary,
88    Reindex {
89        force: bool,
90    },
91    Search(SearchQuery),
92    Symbols(SymbolQuery),
93    Refs {
94        name: String,
95        limit: usize,
96        offset: usize,
97    },
98    /// Resolved references (definitions + call sites + imports) from the
99    /// structural reference index.
100    RefsResolved {
101        name: String,
102        limit: usize,
103        offset: usize,
104    },
105    /// Call sites that target a symbol (who calls it).
106    Callers {
107        name: String,
108        limit: usize,
109        offset: usize,
110    },
111    /// Call sites inside a symbol's body (what it calls).
112    Callees {
113        name: String,
114        limit: usize,
115        offset: usize,
116    },
117    /// Symbols transitively affected by changing a symbol (reverse call graph).
118    BlastRadius {
119        name: String,
120        depth: u32,
121        limit: usize,
122    },
123    /// Typed go-to-definition for the identifier at a source position.
124    Definition {
125        file: String,
126        line: u32,
127        col: u32,
128    },
129    /// Resolved references for the identifier at a source position.
130    ReferencesAt {
131        file: String,
132        line: u32,
133        col: u32,
134    },
135    /// Structural (AST) search by tree-sitter query or meta-variable pattern.
136    Structural {
137        pattern: String,
138        lang: String,
139        limit: usize,
140        offset: usize,
141    },
142    /// Build a token-budgeted context pack for a task.
143    ContextPack {
144        task: String,
145        budget: u64,
146    },
147    /// Git blame for a single line.
148    Blame {
149        file: String,
150        line: u32,
151    },
152    /// Commit history of a symbol's definition.
153    History {
154        name: String,
155        limit: usize,
156    },
157    /// Files (with symbols) changed since a revision.
158    ChangedSince {
159        rev: String,
160    },
161    Outline {
162        file: String,
163    },
164    Snippet {
165        file: String,
166        start: u32,
167        end: u32,
168        context: u32,
169    },
170}
171
172/// A response from the daemon to a client.
173///
174/// `result` is a pre-serialized JSON fragment ([`serde_json::value::RawValue`])
175/// rather than a `serde_json::Value` tree: the daemon serializes each result
176/// exactly once (typed struct -> JSON text) and the framing serializer embeds
177/// it verbatim, instead of building and then re-walking an intermediate
178/// `Value` tree for every response. Clients that want typed access parse the
179/// fragment directly into their target type; clients that just forward the
180/// payload (the MCP server) pass the text through untouched.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct Response {
183    pub ok: bool,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub result: Option<Box<serde_json::value::RawValue>>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub error: Option<String>,
188    /// Set when a [`Freshness::Flag`] query found the index behind the working
189    /// tree: the result is still returned but may not reflect recent edits.
190    /// Omitted from the wire when false so existing responses are unchanged.
191    #[serde(default, skip_serializing_if = "is_false")]
192    pub stale: bool,
193}
194
195/// Skip-serializing predicate for the `stale` flag (a `false` is the common
196/// case and stays off the wire).
197fn is_false(b: &bool) -> bool {
198    !*b
199}
200
201impl Response {
202    /// Build a success response by serializing `value` once.
203    pub fn json<T: serde::Serialize>(value: &T) -> Self {
204        match serde_json::value::to_raw_value(value) {
205            Ok(raw) => Response {
206                ok: true,
207                result: Some(raw),
208                error: None,
209                stale: false,
210            },
211            Err(e) => Response::err(e.to_string()),
212        }
213    }
214
215    pub fn err(message: impl Into<String>) -> Self {
216        Response {
217            ok: false,
218            result: None,
219            error: Some(message.into()),
220            stale: false,
221        }
222    }
223
224    /// Mark this response stale (or not) and return it, for the [`Freshness::Flag`]
225    /// path to annotate an otherwise-normal answer.
226    pub fn with_stale(mut self, stale: bool) -> Self {
227        self.stale = stale;
228        self
229    }
230
231    /// The raw JSON text of the result (`"null"` when absent).
232    pub fn result_text(&self) -> &str {
233        self.result.as_deref().map(|r| r.get()).unwrap_or("null")
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::search::SearchQuery;
241
242    /// The flattened `LocalRequest` envelope must round-trip every request and,
243    /// crucially, still accept a bare pre-freshness `Request` (older client) as
244    /// `Lazy`. This guards the `#[serde(flatten)]` over the internally-tagged
245    /// `Request` enum, which is the subtle part of the wire format.
246    #[test]
247    fn local_request_roundtrips_and_accepts_bare_request() {
248        let env = LocalRequest {
249            freshness: Freshness::Strict,
250            req: Request::Search(SearchQuery {
251                pattern: "needle".into(),
252                ..Default::default()
253            }),
254        };
255        let json = serde_json::to_string(&env).unwrap();
256        // Flattened: the op tag sits next to `freshness`, not nested under `req`.
257        assert!(json.contains("\"op\":\"search\""), "flattened op: {json}");
258        assert!(
259            json.contains("\"freshness\":\"strict\""),
260            "freshness: {json}"
261        );
262        let back: LocalRequest = serde_json::from_str(&json).unwrap();
263        assert_eq!(back.freshness, Freshness::Strict);
264        assert!(matches!(back.req, Request::Search(_)));
265
266        // A bare request from an older client (no `freshness` key) defaults Lazy.
267        let bare = serde_json::to_string(&Request::Status).unwrap();
268        let env: LocalRequest = serde_json::from_str(&bare).unwrap();
269        assert_eq!(env.freshness, Freshness::Lazy);
270        assert!(matches!(env.req, Request::Status));
271    }
272
273    /// `stale` stays off the wire when false (responses are byte-for-byte as
274    /// before) and appears when set.
275    #[test]
276    fn response_stale_is_omitted_when_false() {
277        let clean = serde_json::to_string(&Response::json(&42)).unwrap();
278        assert!(!clean.contains("stale"), "clean response: {clean}");
279        let flagged = serde_json::to_string(&Response::json(&42).with_stale(true)).unwrap();
280        assert!(flagged.contains("\"stale\":true"), "flagged: {flagged}");
281    }
282}