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}