sqry_daemon_protocol/protocol.rs
1//! Wire types for the sqryd daemon IPC.
2//!
3//! Every type in this module serialises as UTF-8 JSON through serde.
4//! The wire format is versioned via the `envelope_version` field on
5//! [`DaemonHelloResponse`] / [`ShimRegisterAck`]; clients negotiate
6//! compatibility during the handshake before issuing any JSON-RPC
7//! request or entering the shim byte-pump.
8//!
9//! # JSON-RPC 2.0 conformance
10//!
11//! - Requests and responses carry the mandatory `"jsonrpc": "2.0"` tag
12//! enforced by [`JsonRpcVersion`]'s manual serde impls.
13//! - Response ids follow the spec exactly: a response to a request
14//! with a missing/invalid id MUST carry `id: null`; `Option<JsonRpcId>`
15//! on [`JsonRpcResponse::id`] is NOT marked `skip_serializing_if`, so
16//! `None` serialises as JSON `null` instead of being omitted.
17//! - Batches are implemented in the sqry-daemon router; this module
18//! only provides the single-request envelope types.
19//!
20//! # `shim/register`
21//!
22//! [`ShimRegister`] / [`ShimProtocol`] / [`ShimRegisterAck`] are the
23//! Phase 8c shim handshake wire types. The router in sqry-daemon
24//! discriminates on the very first frame:
25//!
26//! - If the frame object has both `protocol` + `pid` keys (shim-shaped),
27//! the router enters the shim path and deserialises as [`ShimRegister`]
28//! with `deny_unknown_fields`. On deserialisation failure (e.g. extra
29//! keys from the hello shape, or an unknown `protocol` variant) the
30//! server writes [`ShimRegisterAck`]`{ accepted: false, reason: Some(..) }`
31//! and closes. **Not** a JSON-RPC `-32600` — the shim client expects a
32//! [`ShimRegisterAck`] as the first response, so the wire-form stays
33//! coherent.
34//! - Otherwise the router falls through to the [`DaemonHello`] path
35//! (JSON-RPC). A frame with neither shape is rejected with
36//! `-32600 Invalid Request` and `id: null`.
37
38use std::marker::PhantomData;
39
40use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
41
42// ---------------------------------------------------------------------------
43// WorkspaceId — protocol-side wire wrapper for sqry-core's WorkspaceId.
44// ---------------------------------------------------------------------------
45
46/// 32-byte stable identity for a logical workspace, byte-identical to
47/// `sqry_core::workspace::WorkspaceId`.
48///
49/// Defined here in the leaf protocol crate so the daemon wire types
50/// (`DaemonHello.logical_workspace`, `daemon/load.logical_workspace`,
51/// `daemon/workspaceStatus.workspace_id`) can carry the identity without
52/// the protocol crate taking a `sqry-core` dependency. The `sqry-daemon`
53/// binary owns the `From`/`Into` bridge against the canonical
54/// `sqry_core::workspace::WorkspaceId` type — both use the same 32-byte
55/// representation, so the bridge is a zero-cost newtype unwrap.
56///
57/// `STEP_6` (workspace-aware-cross-repo DAG) introduced this type. Older
58/// daemon clients that send `DaemonHello` without `logical_workspace`
59/// continue to work because the field is `#[serde(default)]` — they
60/// reproduce today's per-source-root semantics, with `workspace_id =
61/// None` on the matching [`crate::WorkspaceState`] entries.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct WorkspaceId([u8; 32]);
64
65impl WorkspaceId {
66 /// Construct from raw 32 bytes. Callers in `sqry-daemon` use this
67 /// to bridge from `sqry_core::workspace::WorkspaceId::as_bytes()`.
68 #[must_use]
69 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
70 Self(bytes)
71 }
72
73 /// Borrow the 32-byte digest. Callers cross the bridge by feeding
74 /// these bytes back into `sqry_core::workspace::WorkspaceId`.
75 #[must_use]
76 pub const fn as_bytes(&self) -> &[u8; 32] {
77 &self.0
78 }
79
80 /// First 16 hex characters. Suitable for log lines / short
81 /// identifiers; **not** sufficient for cross-process identity.
82 #[must_use]
83 pub fn as_short_hex(&self) -> String {
84 let full = self.as_full_hex();
85 full[..16].to_string()
86 }
87
88 /// Full 64-character hex digest. Use this for any identity
89 /// comparison.
90 #[must_use]
91 pub fn as_full_hex(&self) -> String {
92 use std::fmt::Write as _;
93 let mut s = String::with_capacity(64);
94 for byte in &self.0 {
95 // `write!` to a `String` is infallible.
96 let _ = write!(s, "{byte:02x}");
97 }
98 s
99 }
100}
101
102impl std::fmt::Display for WorkspaceId {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(&self.as_short_hex())
105 }
106}
107
108// ---------------------------------------------------------------------------
109// LogicalWorkspaceWire — daemon-IPC wire form of sqry-core's LogicalWorkspace.
110// ---------------------------------------------------------------------------
111
112/// Wire-form summary of a `LogicalWorkspace`, attached to
113/// [`DaemonHello`] / `daemon/load` payloads. Carries the workspace
114/// identity plus the canonical source-root paths the client wants the
115/// daemon to bind under a single grouping `workspace_id`.
116///
117/// `member_folders` and `exclusions` are explicitly **not** carried on
118/// this wire shape — they are MCP / redaction-side concerns (Step 7 of
119/// the workspace-aware-cross-repo plan), not daemon admission concerns.
120/// The daemon only needs `workspace_id` + the source-root list to build
121/// one [`crate::WorkspaceState`]-keyed entry per source root.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(deny_unknown_fields)]
124pub struct LogicalWorkspaceWire {
125 /// 32-byte BLAKE3-256 identity of the logical workspace.
126 pub workspace_id: WorkspaceId,
127 /// Canonical absolute source-root paths. The daemon constructs one
128 /// `WorkspaceKey { workspace_id: Some(this id), source_root: <p>, .. }`
129 /// per entry, all sharing the same `workspace_id` for grouping.
130 pub source_roots: Vec<std::path::PathBuf>,
131 /// `STEP_11_4` — per-source-root bindings. Each entry's `path` MUST
132 /// appear in [`Self::source_roots`]; the binding's
133 /// `config_fingerprint` overrides the workspace-level default for
134 /// that root only. Empty in the common case so the wire stays
135 /// pre-STEP_11_4-compatible.
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub source_root_bindings: Vec<SourceRootBinding>,
138 /// `STEP_11_4` — workspace-level config fingerprint applied to any
139 /// source root that does not carry its own
140 /// [`SourceRootBinding::config_fingerprint`] override. `0` is the
141 /// "fingerprint not set" sentinel.
142 #[serde(default, skip_serializing_if = "is_zero_u64")]
143 pub workspace_config_fingerprint: u64,
144}
145
146#[allow(
147 clippy::trivially_copy_pass_by_ref,
148 reason = "serde skip_serializing_if callbacks are invoked with a reference to the field"
149)]
150fn is_zero_u64(value: &u64) -> bool {
151 *value == 0
152}
153
154/// `STEP_11_4` — per-source-root binding inside a [`LogicalWorkspaceWire`].
155///
156/// `path` MUST appear in the parent [`LogicalWorkspaceWire::source_roots`]
157/// vector; the daemon matches bindings to source roots by canonical path
158/// equality. A binding whose `path` is not in `source_roots` is silently
159/// ignored.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(deny_unknown_fields)]
162pub struct SourceRootBinding {
163 /// Canonical absolute path of the source root this binding applies to.
164 pub path: std::path::PathBuf,
165 /// Per-source-root override of the config fingerprint. `0` means
166 /// "use the workspace-level fingerprint"; non-zero overrides for
167 /// this source root only.
168 #[serde(default, skip_serializing_if = "is_zero_u64")]
169 pub config_fingerprint: u64,
170 /// Optional pre-resolved classpath directory for this source root.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub classpath_dir: Option<std::path::PathBuf>,
173}
174
175// ---------------------------------------------------------------------------
176// WorkspaceIndexStatus — daemon/workspaceStatus result payload.
177// ---------------------------------------------------------------------------
178
179/// Aggregate status of a single source root inside a logical workspace.
180/// Mirrors the per-source-root subset of `WorkspaceStatus` so cross-repo
181/// MCP / LSP queries can render a per-source-root state without paying
182/// the cost of the full `daemon/status` snapshot.
183///
184/// `STEP_11_4` (workspace-aware-cross-repo, 2026-04-26) — adds the
185/// `classpath_present` flag so consumers of `daemon/workspaceStatus`
186/// know which source roots have JVM classpath analysis available
187/// (`<source_root>/.sqry/classpath/` exists) without having to make a
188/// separate filesystem probe. The flag is per-source-root, never
189/// aggregated, so a workspace mixing JVM and non-JVM source roots
190/// reports accurate per-root granularity.
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct WorkspaceSourceRootStatus {
193 /// Canonical absolute path to the source root.
194 pub source_root: std::path::PathBuf,
195 /// Per-source-root lifecycle state. `Evicted` is a valid (and
196 /// useful — partial eviction is observable here) value for a
197 /// source root that has been LRU'd out while sibling source roots
198 /// remain `Loaded`.
199 pub state: WorkspaceState,
200 /// Live graph size for this source root, in bytes.
201 pub current_bytes: u64,
202 /// `STEP_11_4` — `true` when the daemon observed
203 /// `<source_root>/.sqry/classpath/` as a directory at status time.
204 /// `false` when the directory is absent or the probe failed (the
205 /// daemon never blocks status on a classpath probe; failures
206 /// surface through the LSP-side `WorkspaceIndexStatus.warnings`
207 /// channel instead).
208 ///
209 /// `#[serde(default)]` so v1 IPC payloads (which never carried the
210 /// flag) round-trip into `false`. `skip_serializing_if = ...` is
211 /// deliberately NOT applied — the flag must be serialised even
212 /// when `false` so consumers can distinguish "JVM-aware daemon
213 /// reporting no classpath" from "older daemon that does not yet
214 /// surface the flag".
215 #[serde(default)]
216 pub classpath_present: bool,
217}
218
219/// Aggregate status of a logical workspace, returned by
220/// `daemon/workspaceStatus { workspace_id }`.
221///
222/// The daemon walks every `WorkspaceKey` whose `workspace_id` matches
223/// the request and aggregates them into this view. A workspace is
224/// "partially evicted" when at least one source root reports
225/// [`WorkspaceState::Evicted`] but at least one other reports any
226/// non-Evicted state — see [`Self::partially_evicted`].
227///
228/// `STEP_12` (workspace-aware-cross-repo, 2026-04-26) introduced the
229/// hex-string telemetry fields `workspace_id_short` (16 hex chars,
230/// display) and `workspace_id_full` (64 hex chars, machine identity).
231/// Scripts consuming this payload should key on `workspace_id_full` —
232/// the 32-byte `workspace_id` is the canonical bytewise identity but
233/// the hex string is what humans / shell tooling read. The two hex
234/// fields are derived from `workspace_id`; they are NOT independent
235/// inputs — they exist purely for ergonomic JSON consumption.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct WorkspaceIndexStatus {
238 /// Identity the request matched against.
239 pub workspace_id: WorkspaceId,
240 /// `STEP_12` — short (16 hex) form of `workspace_id`, suitable for
241 /// CLI columns and human-scale log lines. Display only.
242 pub workspace_id_short: String,
243 /// `STEP_12` — full (64 hex) form of `workspace_id`. Machine
244 /// identity. Cross-process script consumers MUST key on this
245 /// rather than the short form to avoid the (remote, non-zero)
246 /// possibility of short-hex collisions across hundreds of
247 /// thousands of distinct workspaces.
248 pub workspace_id_full: String,
249 /// Per-source-root status rows, sorted by `source_root` for
250 /// deterministic CLI / test output.
251 pub source_roots: Vec<WorkspaceSourceRootStatus>,
252}
253
254impl WorkspaceIndexStatus {
255 /// Whether at least one source root is in [`WorkspaceState::Evicted`]
256 /// while at least one other is not. `false` for fully-loaded or
257 /// fully-evicted aggregates.
258 #[must_use]
259 pub fn partially_evicted(&self) -> bool {
260 let any_evicted = self
261 .source_roots
262 .iter()
263 .any(|r| matches!(r.state, WorkspaceState::Evicted));
264 let any_alive = self
265 .source_roots
266 .iter()
267 .any(|r| !matches!(r.state, WorkspaceState::Evicted));
268 any_evicted && any_alive
269 }
270}
271
272// ---------------------------------------------------------------------------
273// Wire envelope version.
274// ---------------------------------------------------------------------------
275
276/// Version of the daemon wire envelope ([`DaemonHelloResponse::envelope_version`],
277/// [`ShimRegisterAck::envelope_version`]).
278///
279/// Bumped when the [`ResponseEnvelope`] schema changes in an incompatible way.
280/// Kept at `1` per the Amendment-2 2026-04-09 freeze.
281///
282/// This constant lives in the leaf wire-type crate (`sqry-daemon-protocol`) so
283/// every consumer of the wire format — the daemon itself, the daemon client
284/// (`sqry-daemon-client`), and the shim-mode callers inside `sqry-lsp` /
285/// `sqry-mcp` — validates against exactly one source of truth. Clients MUST
286/// reject a response whose `envelope_version` differs from this constant
287/// rather than proceed on a mismatched wire format.
288pub const ENVELOPE_VERSION: u32 = 1;
289
290// ---------------------------------------------------------------------------
291// WorkspaceState — moved here from sqry-daemon/src/workspace/state.rs
292// ---------------------------------------------------------------------------
293
294/// Six-state workspace lifecycle per plan Task 6 Step 1 and Amendment 2 §G.5 /
295/// §G.7.
296///
297/// The `#[repr(u8)]` is load-bearing: `sqry-daemon`'s `LoadedWorkspace::state`
298/// is an `AtomicU8`, and the conversions [`Self::from_u8`] / [`Self::as_u8`]
299/// serialise the state machine without allocation. Values are deliberately
300/// contiguous from 0 so adding a variant stays backwards-compatible with
301/// persisted telemetry.
302///
303/// This type lives in the leaf wire-type crate so [`ResponseMeta`] can
304/// carry a canonical `workspace_state` string on every successful tool
305/// response without the leaf crate taking a dep on `sqry-daemon` itself.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
307#[repr(u8)]
308pub enum WorkspaceState {
309 /// Workspace entry exists but no graph has been loaded yet.
310 Unloaded = 0,
311
312 /// Initial load is in progress — a single blocking read from disk or
313 /// a full rebuild with no prior snapshot.
314 Loading = 1,
315
316 /// Graph is loaded, idle, and ready to serve queries.
317 Loaded = 2,
318
319 /// A rebuild (incremental or full) is actively running on the
320 /// dispatcher's background task. Queries keep serving the prior
321 /// `ArcSwap<CodeGraph>` snapshot until `publish_and_retain` swaps
322 /// the new graph in.
323 Rebuilding = 3,
324
325 /// Workspace was LRU-evicted or explicitly unloaded. The entry is
326 /// REMOVED from the manager map — the next query must re-load via
327 /// `get_or_load`. This discriminant exists for the short window
328 /// between `execute_eviction` storing the state and
329 /// `workspaces.remove(key)` completing (both under
330 /// `workspaces.write()`); external observers routed through
331 /// `WorkspaceManager::classify_for_serve` see the map-missing arm
332 /// first and get `DaemonError::WorkspaceEvicted` regardless.
333 Evicted = 4,
334
335 /// The most recent rebuild failed. Queries are served from the last
336 /// good snapshot with `meta.stale = true`; if the
337 /// `stale_serve_max_age_hours` cap is exceeded, queries receive the
338 /// JSON-RPC `-32002 workspace_stale_expired` error instead.
339 Failed = 5,
340}
341
342impl WorkspaceState {
343 /// Round-trip the state to its discriminant.
344 #[must_use]
345 pub const fn as_u8(self) -> u8 {
346 self as u8
347 }
348
349 /// Parse a discriminant back to a state. Returns `None` on any value
350 /// outside the current enum range — callers should treat this as a
351 /// telemetry corruption rather than silently map to `Unloaded`.
352 #[must_use]
353 pub const fn from_u8(value: u8) -> Option<Self> {
354 match value {
355 0 => Some(Self::Unloaded),
356 1 => Some(Self::Loading),
357 2 => Some(Self::Loaded),
358 3 => Some(Self::Rebuilding),
359 4 => Some(Self::Evicted),
360 5 => Some(Self::Failed),
361 _ => None,
362 }
363 }
364
365 /// Canonical display string. Used by `daemon/status` output and
366 /// tracing spans.
367 #[must_use]
368 pub const fn as_str(self) -> &'static str {
369 match self {
370 Self::Unloaded => "unloaded",
371 Self::Loading => "loading",
372 Self::Loaded => "loaded",
373 Self::Rebuilding => "rebuilding",
374 Self::Evicted => "evicted",
375 Self::Failed => "failed",
376 }
377 }
378
379 /// Whether the workspace can still serve queries in this state.
380 ///
381 /// `true` for [`Self::Loaded`], [`Self::Rebuilding`] (old snapshot
382 /// still served), and [`Self::Failed`] (stale-serve subject to the
383 /// age cap). `false` for [`Self::Unloaded`], [`Self::Loading`],
384 /// and [`Self::Evicted`].
385 #[must_use]
386 pub const fn is_serving(self) -> bool {
387 matches!(self, Self::Loaded | Self::Rebuilding | Self::Failed)
388 }
389}
390
391impl std::fmt::Display for WorkspaceState {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 f.write_str(self.as_str())
394 }
395}
396
397// ---------------------------------------------------------------------------
398// Handshake types.
399// ---------------------------------------------------------------------------
400
401/// Pre-handshake header sent as the very first frame by a CLI client.
402/// The server responds with [`DaemonHelloResponse`] before the
403/// JSON-RPC request loop begins.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct DaemonHello {
407 /// Free-form client identifier (`env!("CARGO_PKG_VERSION")` plus
408 /// user-agent suffix). Informational only.
409 pub client_version: String,
410
411 /// Wire protocol version. Phase 8a accepts exactly `1`.
412 pub protocol_version: u32,
413
414 /// Optional logical-workspace binding hint (`STEP_6` of the
415 /// workspace-aware-cross-repo plan). When present, every
416 /// subsequent `daemon/load` on this connection that does not
417 /// itself supply `logical_workspace` inherits this binding —
418 /// keeping today's anonymous behaviour for clients that do not
419 /// set the hint.
420 ///
421 /// `#[serde(default)]` so older clients (and the standalone
422 /// `sqry-mcp` / `sqry-lsp` shims that have not yet learned about
423 /// logical workspaces) keep working with `None`. The daemon
424 /// router synthesises one `WorkspaceKey` per source root with
425 /// `workspace_id = Some(this id)`.
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub logical_workspace: Option<LogicalWorkspaceWire>,
428}
429
430/// Server's reply to [`DaemonHello`]. If `compatible` is `false` the
431/// server closes the connection immediately after the frame is sent.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct DaemonHelloResponse {
435 pub compatible: bool,
436 pub daemon_version: String,
437 pub envelope_version: u32,
438}
439
440// ---------------------------------------------------------------------------
441// Shim handshake (Phase 8c wire types).
442// ---------------------------------------------------------------------------
443
444/// Which client protocol the shim will pump bytes for. Phase 8c surface.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "lowercase")]
447pub enum ShimProtocol {
448 Lsp,
449 Mcp,
450}
451
452/// Shim registration header sent as the first frame by a
453/// `sqry lsp --daemon` or `sqry mcp --daemon` process. The router in
454/// sqry-daemon shape-discriminates between [`DaemonHello`] and this
455/// type using `#[serde(deny_unknown_fields)]`.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct ShimRegister {
459 pub protocol: ShimProtocol,
460 pub pid: u32,
461}
462
463/// Server's reply to [`ShimRegister`]. If `accepted` is `false` the
464/// server closes the connection after sending the ack and the shim
465/// client surfaces `reason` to its parent process. When `accepted` is
466/// `true`, `reason` is omitted from the wire form (skip-if-none).
467#[derive(Debug, Clone, Serialize, Deserialize)]
468#[serde(deny_unknown_fields)]
469pub struct ShimRegisterAck {
470 pub accepted: bool,
471 pub daemon_version: String,
472 /// Rejection reason. Omitted from the wire when accepted=true.
473 #[serde(skip_serializing_if = "Option::is_none")]
474 pub reason: Option<String>,
475 pub envelope_version: u32,
476}
477
478// ---------------------------------------------------------------------------
479// ResponseEnvelope.
480// ---------------------------------------------------------------------------
481
482/// Uniform successful-response wrapper. Every successful method
483/// response is serialised as `ResponseEnvelope<T>` at the JSON-RPC
484/// `result` field — clients can rely on the [`ResponseMeta`] shape
485/// being present on every successful reply regardless of method.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct ResponseEnvelope<T> {
488 pub result: T,
489 pub meta: ResponseMeta,
490}
491
492/// Metadata attached to every successful response. For Phase 8a
493/// management methods the staleness fields are always absent
494/// (`stale = false`, no `last_good_at`, no `last_error`,
495/// `workspace_state = None`). Phase 8b populates them from the
496/// server-side `ServeVerdict` for tool-method responses.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub struct ResponseMeta {
499 pub stale: bool,
500
501 #[serde(skip_serializing_if = "Option::is_none")]
502 pub last_good_at: Option<String>,
503
504 #[serde(skip_serializing_if = "Option::is_none")]
505 pub last_error: Option<String>,
506
507 /// Canonical workspace state string (serde form of
508 /// [`WorkspaceState`]). `None` for methods not tied to a workspace.
509 #[serde(skip_serializing_if = "Option::is_none")]
510 pub workspace_state: Option<WorkspaceState>,
511
512 pub daemon_version: String,
513}
514
515impl ResponseMeta {
516 /// Construct the [`ResponseMeta`] used by daemon management methods
517 /// (`daemon/status`, `daemon/unload`, `daemon/stop` — the ones not
518 /// bound to a specific workspace).
519 #[must_use]
520 pub fn management(daemon_version: &str) -> Self {
521 Self {
522 stale: false,
523 last_good_at: None,
524 last_error: None,
525 workspace_state: None,
526 daemon_version: daemon_version.to_owned(),
527 }
528 }
529
530 /// Construct the [`ResponseMeta`] for a successful `daemon/load`.
531 /// Phase 8b adds `fresh_from` / `stale_from` constructors for
532 /// MCP tool-method responses that route through `classify_for_serve`.
533 #[must_use]
534 pub fn loaded(daemon_version: &str) -> Self {
535 Self {
536 stale: false,
537 last_good_at: None,
538 last_error: None,
539 workspace_state: Some(WorkspaceState::Loaded),
540 daemon_version: daemon_version.to_owned(),
541 }
542 }
543
544 /// Construct [`ResponseMeta`] for a tool-method response served from a
545 /// Fresh workspace verdict (`WorkspaceState::Loaded` or `Rebuilding`).
546 ///
547 /// Phase 8b Task 7 — populated by the `tool_dispatch` helper when
548 /// the daemon's `WorkspaceManager::classify_for_serve` returns
549 /// `ServeVerdict::Fresh`. `stale` is `false` and both `last_good_at`
550 /// and `last_error` are absent from the wire form (they are skipped
551 /// by `serde(skip_serializing_if = "Option::is_none")`).
552 #[must_use]
553 pub fn fresh_from(state: WorkspaceState, daemon_version: &str) -> Self {
554 Self {
555 stale: false,
556 last_good_at: None,
557 last_error: None,
558 workspace_state: Some(state),
559 daemon_version: daemon_version.to_owned(),
560 }
561 }
562
563 /// Construct [`ResponseMeta`] for a tool-method response served from a
564 /// Stale verdict. `last_good_at` is rendered as RFC3339 UTC-Zulu via
565 /// `chrono::DateTime::<Utc>::from(SystemTime) -> to_rfc3339_opts(Secs, true)`.
566 ///
567 /// `workspace_state` is fixed at [`WorkspaceState::Failed`] because
568 /// `WorkspaceManager::classify_for_serve` only emits a Stale verdict
569 /// when the observed state is `Failed`. Keeping this constructor
570 /// intentionally rigid (no caller-supplied state) prevents the wire
571 /// form from claiming `stale = true` with a `workspace_state` the
572 /// classifier could never have produced.
573 #[must_use]
574 pub fn stale_from(
575 last_good_at: std::time::SystemTime,
576 last_error: Option<String>,
577 daemon_version: &str,
578 ) -> Self {
579 use chrono::{DateTime, SecondsFormat, Utc};
580 let rfc3339 =
581 DateTime::<Utc>::from(last_good_at).to_rfc3339_opts(SecondsFormat::Secs, true);
582 Self {
583 stale: true,
584 last_good_at: Some(rfc3339),
585 last_error,
586 workspace_state: Some(WorkspaceState::Failed),
587 daemon_version: daemon_version.to_owned(),
588 }
589 }
590}
591
592// ---------------------------------------------------------------------------
593// daemon/load result wire type.
594// ---------------------------------------------------------------------------
595
596/// `daemon/load` success result payload.
597///
598/// Serialised under the `result` field of [`ResponseEnvelope`]. Living
599/// in the leaf protocol crate lets both the daemon (writer) and
600/// [`sqry-daemon-client`][] (reader) share a single typed definition —
601/// clients can `serde_json::from_value::<ResponseEnvelope<LoadResult>>`
602/// and get compile-time schema checking instead of stringly-typed
603/// `serde_json::Value::get` lookups.
604///
605/// [`sqry-daemon-client`]: ../../sqry-daemon-client/index.html
606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
607#[serde(deny_unknown_fields)]
608pub struct LoadResult {
609 /// The canonicalised workspace root path that the daemon loaded.
610 pub root: std::path::PathBuf,
611
612 /// Resident graph memory footprint for the loaded workspace, in
613 /// bytes. Matches `LoadedWorkspace::heap_bytes()` at the moment of
614 /// the response.
615 pub current_bytes: u64,
616
617 /// The canonical workspace lifecycle state after the load
618 /// completes. Always [`WorkspaceState::Loaded`] on the successful
619 /// `daemon/load` path — the field is typed so clients do not have
620 /// to re-parse the string.
621 pub state: WorkspaceState,
622}
623
624/// Status of a `daemon/rebuild` invocation (cluster-G §2.4).
625///
626/// Distinguishes the four outcomes the dispatcher can produce so a
627/// `--timeout 0` (fire-and-forget) caller can distinguish "started in
628/// the background" from "actually completed in this call". Pre-§2.4
629/// callers received only the `Completed` shape and a missing field
630/// here is interpreted as `Completed` for backward compatibility.
631#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
632#[serde(rename_all = "snake_case")]
633pub enum RebuildStatus {
634 /// The rebuild ran to completion in this call. `duration_ms`,
635 /// `nodes`, `edges`, `files_indexed`, and `was_full` are all
636 /// populated.
637 #[default]
638 Completed,
639 /// `--timeout 0` (fire-and-forget): the runner-role was acquired
640 /// and the rebuild is running in the background. The stat fields
641 /// are absent. The caller should poll `daemon/status` to observe
642 /// completion.
643 Started,
644 /// Another runner is active; this request was coalesced into the
645 /// pending lane. The stat fields reflect the runner's *previous*
646 /// publish if known, or are absent.
647 Coalesced,
648 /// Reservation failed before the pipeline started (e.g.
649 /// `MemoryBudgetExceeded`, `WorkspaceOversize`). The stat fields
650 /// are absent.
651 Rejected,
652}
653
654/// `daemon/rebuild` success result payload (`schema_version` 2 — see
655/// cluster-G §2.4).
656///
657/// Serialised under the `result` field of [`ResponseEnvelope`]. The
658/// stat fields are `Option`-typed because `--timeout 0` callers
659/// receive them populated only when `status == Completed`.
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
661pub struct RebuildResult {
662 /// The canonicalised workspace root path that was rebuilt.
663 pub root: std::path::PathBuf,
664 /// Outcome of the dispatch. New in cluster-G §2.4. Older clients
665 /// that pre-date the schema bump are tolerated by the
666 /// `#[serde(default)]` here — they read the field as `Completed`
667 /// and continue to work because pre-§2.4 daemons only ever
668 /// produced the completed shape.
669 #[serde(default)]
670 pub status: RebuildStatus,
671 /// Wall-clock time the rebuild took, in milliseconds. Populated
672 /// only when `status == Completed`.
673 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub duration_ms: Option<u64>,
675 /// Node count of the freshly published graph. Populated only
676 /// when `status == Completed`.
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub nodes: Option<u64>,
679 /// Edge count of the freshly published graph. Populated only
680 /// when `status == Completed`.
681 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub edges: Option<u64>,
683 /// Number of source files indexed in the freshly published
684 /// graph. Populated only when `status == Completed`.
685 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub files_indexed: Option<u64>,
687 /// `true` when the rebuild was a full (non-incremental) rebuild.
688 /// Populated only when `status == Completed`.
689 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub was_full: Option<bool>,
691}
692
693/// `daemon/cancel_rebuild` success result payload.
694///
695/// Serialised under the `result` field of [`ResponseEnvelope`].
696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
697pub struct CancelRebuildResult {
698 /// The canonicalised workspace root path whose rebuild was signalled for
699 /// cancellation.
700 pub root: std::path::PathBuf,
701 /// `true` when a rebuild was actually in flight at the moment the
702 /// cancellation signal was dispatched.
703 pub cancelled: bool,
704}
705
706// ---------------------------------------------------------------------------
707// `daemon/search` wire types (verivus-oss/sqry#238 Tier 2).
708//
709// Mirror the relevant arguments of `commands::search::run_search` so the
710// CLI shim at `sqry-cli/src/commands/search.rs` can attempt the daemon
711// path before falling through to in-process load. Field shape follows the
712// established protocol-crate convention: leaf-only (no upstream sqry deps,
713// no tower-lsp types), flat fields rather than nested LSP `Location`
714// wrappers — the CLI converts to `DisplaySymbol` for output formatting,
715// keeping parity at the formatter layer rather than the wire layer.
716// ---------------------------------------------------------------------------
717
718/// `daemon/search` request payload.
719///
720/// Submitted as the `params` field of a [`JsonRpcRequest`] with
721/// `method == "daemon/search"`. Wire-compatible with `--exact`, regex, and
722/// fuzzy search modes; the daemon handler dispatches to the same
723/// `find_by_exact_name` / regex / fuzzy logic the in-process CLI uses
724/// (verifiable by the `DAEMON_SEARCH_TESTS` parity assertions).
725///
726/// Errors: a request whose `search_path` does not resolve to a
727/// daemon-loaded workspace returns `WorkspaceEvicted` (-32004) or
728/// `WorkspaceNotLoaded`; an incompatible plugin selection returns
729/// `WorkspaceIncompatibleGraph` (-32005). The CLI shim treats either as
730/// a soft failure and falls through to the in-process path.
731#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
732#[serde(deny_unknown_fields)]
733pub struct SearchRequest {
734 /// Wire envelope version. Defaults to [`ENVELOPE_VERSION`] for
735 /// forward-compatible serde when the field is absent.
736 #[serde(default = "default_envelope_version")]
737 pub envelope_version: u32,
738 /// Pattern to search for. Interpreted per `mode`.
739 pub pattern: String,
740 /// Workspace root the search should resolve against. The daemon
741 /// normalises and looks this up via `WorkspaceManager`.
742 pub search_path: String,
743 /// Search interpretation mode. Maps to the CLI's `--exact` / `--fuzzy`
744 /// flags; everything else falls into [`SearchMode::Regex`].
745 pub mode: SearchMode,
746 /// Optional kind filter (e.g. `"function"`, `"class"`). Matches
747 /// the in-process `Cli::kind` semantics.
748 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub kind: Option<String>,
750 /// Optional language filter (e.g. `"rust"`, `"python"`).
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub lang: Option<String>,
753 /// Maximum result count. `None` lets the daemon apply its default
754 /// (mirrors `Cli::limit`).
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub limit: Option<u32>,
757 /// Mirror of the CLI's `--include-generated` flag (default in-process:
758 /// `false` — macro-generated symbols are dropped). When `false` the
759 /// daemon applies the same `filter_nodes_by_macro_boundary` contract
760 /// as `sqry-cli/src/commands/search.rs::run_regular_search` so the
761 /// daemon route does not surface macro-generated symbols the
762 /// in-process path would have dropped.
763 ///
764 /// Serde default is `true` for backward compatibility — a request
765 /// body that omits the field gets the same "no filter" behaviour
766 /// the tier-2 daemon handler shipped with originally (the
767 /// `DAEMON_SEARCH_HANDLER` unit's approved tests rely on that
768 /// default and continue to pass without modification).
769 #[serde(default = "default_include_generated")]
770 pub include_generated: bool,
771}
772
773fn default_envelope_version() -> u32 {
774 ENVELOPE_VERSION
775}
776
777fn default_include_generated() -> bool {
778 // True keeps the pre-`include_generated` wire-shape behaviour: the
779 // daemon returns every candidate, including macro-generated ones.
780 // Callers that need parity with the CLI's default exact search must
781 // set this to `false` so the daemon drops `macro_generated == Some(true)`
782 // nodes before serialising the result set.
783 true
784}
785
786/// Search interpretation mode for [`SearchRequest::mode`].
787#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
788#[serde(rename_all = "lowercase")]
789pub enum SearchMode {
790 /// Regex pattern over interned symbol names. Default behaviour for
791 /// `sqry search <pat>` and `sqry <pat>`.
792 Regex,
793 /// Literal byte-exact symbol-name match (`--exact` / planner
794 /// `name:<literal>` contract).
795 Exact,
796 /// Trigram fuzzy match (`--fuzzy`).
797 Fuzzy,
798}
799
800/// One search hit. Flat shape (no LSP `Location` wrapper) so the protocol
801/// crate stays leaf-only; the CLI shim converts to `DisplaySymbol` for
802/// output formatting.
803///
804/// Line/column semantics match `DisplaySymbol`:
805/// - `start_line` / `end_line` are 1-based
806/// - `start_column` / `end_column` are 0-based byte offsets
807#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
808#[serde(deny_unknown_fields)]
809pub struct SearchItem {
810 pub name: String,
811 pub qualified_name: String,
812 pub kind: String,
813 pub language: String,
814 pub file_path: String,
815 pub start_line: u32,
816 pub start_column: u32,
817 pub end_line: u32,
818 pub end_column: u32,
819 /// Fuzzy match score. Absent for regex / exact hits.
820 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub score: Option<f32>,
822}
823
824/// `daemon/search` success result payload.
825///
826/// Serialised under the `result` field of [`ResponseEnvelope`]. The CLI
827/// shim reads `total` + `truncated` separately so it can emit the same
828/// "Showing N of M matches" banner the in-process path does.
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
830#[serde(deny_unknown_fields)]
831pub struct SearchResult {
832 /// The hits returned for this query, post-limit.
833 pub items: Vec<SearchItem>,
834 /// Pre-truncation match count. When `truncated == false` this equals
835 /// `items.len()`; when `truncated == true` it is at least `limit + 1`
836 /// (lower-bound sentinel, matching the LSP `list_unused_symbols` /
837 /// `list_circular_dependencies` convention).
838 pub total: u64,
839 /// True when the result was capped by `SearchRequest::limit`.
840 pub truncated: bool,
841 /// Reserved for future cursor-based pagination. Tier-2 always returns
842 /// `None`; clients should not assume any specific format.
843 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub cursor: Option<String>,
845}
846
847// ---------------------------------------------------------------------------
848// JSON-RPC 2.0 envelope types.
849// ---------------------------------------------------------------------------
850
851/// JSON-RPC `"2.0"` version tag. Manual serde impls enforce exact
852/// string match on the wire so malformed requests never leak into the
853/// method dispatcher.
854#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
855pub struct JsonRpcVersion;
856
857impl Serialize for JsonRpcVersion {
858 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
859 s.serialize_str("2.0")
860 }
861}
862
863impl<'de> Deserialize<'de> for JsonRpcVersion {
864 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
865 struct Vis(PhantomData<JsonRpcVersion>);
866 impl de::Visitor<'_> for Vis {
867 type Value = JsonRpcVersion;
868 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869 f.write_str("the string \"2.0\"")
870 }
871 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
872 if v == "2.0" {
873 Ok(JsonRpcVersion)
874 } else {
875 Err(E::invalid_value(de::Unexpected::Str(v), &"\"2.0\""))
876 }
877 }
878 }
879 d.deserialize_str(Vis(PhantomData))
880 }
881}
882
883/// JSON-RPC id: `null`, integer (signed or unsigned), or string.
884/// `I64` covers `i64::MIN..=i64::MAX`; `U64` covers
885/// `i64::MAX + 1..=u64::MAX`. Serde's untagged deserialize tries
886/// variants in order so `0..=i64::MAX` lands in `I64` and
887/// `i64::MAX + 1..=u64::MAX` in `U64`.
888#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
889#[serde(untagged)]
890pub enum JsonRpcId {
891 /// Signed integer id.
892 I64(i64),
893 /// Unsigned integer id above `i64::MAX`.
894 U64(u64),
895 /// String id.
896 Str(String),
897}
898
899/// JSON-RPC 2.0 request.
900#[derive(Debug, Clone, Serialize, Deserialize)]
901pub struct JsonRpcRequest {
902 pub jsonrpc: JsonRpcVersion,
903
904 /// `None` ≙ notification (no response expected).
905 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub id: Option<JsonRpcId>,
907
908 pub method: String,
909
910 #[serde(default)]
911 pub params: serde_json::Value,
912}
913
914/// JSON-RPC 2.0 response. `id` is [`Option<JsonRpcId>`] with **no**
915/// `skip_serializing_if` — the `None` case serialises as JSON `null`,
916/// which is exactly what the spec demands for parse-error and
917/// invalid-request responses.
918#[derive(Debug, Clone, Serialize, Deserialize)]
919pub struct JsonRpcResponse {
920 pub jsonrpc: JsonRpcVersion,
921
922 /// `null` on the wire when the server could not determine the
923 /// originating request id (parse error, invalid request shape,
924 /// batch element with un-parseable id).
925 pub id: Option<JsonRpcId>,
926
927 #[serde(flatten)]
928 pub payload: JsonRpcPayload,
929}
930
931/// Tagged success-or-error payload. Serde `untagged` so the wire form
932/// is `{... "result": ...}` or `{... "error": ...}`, never both.
933#[derive(Debug, Clone, Serialize, Deserialize)]
934#[serde(untagged)]
935pub enum JsonRpcPayload {
936 Success { result: serde_json::Value },
937 Error { error: JsonRpcError },
938}
939
940/// JSON-RPC 2.0 error payload.
941#[derive(Debug, Clone, Serialize, Deserialize)]
942pub struct JsonRpcError {
943 pub code: i32,
944 pub message: String,
945 #[serde(skip_serializing_if = "Option::is_none")]
946 pub data: Option<serde_json::Value>,
947}
948
949impl JsonRpcResponse {
950 /// Construct a successful response.
951 #[must_use]
952 pub fn success(id: Option<JsonRpcId>, result: serde_json::Value) -> Self {
953 Self {
954 jsonrpc: JsonRpcVersion,
955 id,
956 payload: JsonRpcPayload::Success { result },
957 }
958 }
959
960 /// Construct an error response.
961 #[must_use]
962 pub fn error(
963 id: Option<JsonRpcId>,
964 code: i32,
965 message: impl Into<String>,
966 data: Option<serde_json::Value>,
967 ) -> Self {
968 Self {
969 jsonrpc: JsonRpcVersion,
970 id,
971 payload: JsonRpcPayload::Error {
972 error: JsonRpcError {
973 code,
974 message: message.into(),
975 data,
976 },
977 },
978 }
979 }
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985
986 #[test]
987 fn jsonrpc_version_roundtrip() {
988 let wire = serde_json::to_string(&JsonRpcVersion).unwrap();
989 assert_eq!(wire, r#""2.0""#);
990 let back: JsonRpcVersion = serde_json::from_str(&wire).unwrap();
991 assert_eq!(back, JsonRpcVersion);
992 }
993
994 #[test]
995 fn jsonrpc_version_rejects_wrong_string() {
996 let err = serde_json::from_str::<JsonRpcVersion>(r#""1.0""#)
997 .expect_err("must reject non-\"2.0\"");
998 assert!(err.to_string().contains("\"2.0\""));
999 }
1000
1001 #[test]
1002 fn jsonrpc_id_untagged_roundtrip() {
1003 let cases: &[(&str, JsonRpcId)] = &[
1004 ("0", JsonRpcId::I64(0)),
1005 ("-7", JsonRpcId::I64(-7)),
1006 (&i64::MAX.to_string(), JsonRpcId::I64(i64::MAX)),
1007 ("\"abc\"", JsonRpcId::Str("abc".into())),
1008 ];
1009 for (wire, expected) in cases {
1010 let parsed: JsonRpcId = serde_json::from_str(wire).expect(wire);
1011 assert_eq!(&parsed, expected, "round-trip failed for {wire}");
1012 }
1013 // i64::MAX + 1 routes to U64.
1014 let u: JsonRpcId = serde_json::from_str("9223372036854775808").unwrap();
1015 assert_eq!(u, JsonRpcId::U64(9_223_372_036_854_775_808));
1016 }
1017
1018 #[test]
1019 fn response_id_none_serializes_as_json_null() {
1020 let resp = JsonRpcResponse::error(None, -32700, "Parse error", None);
1021 let wire = serde_json::to_string(&resp).unwrap();
1022 assert!(
1023 wire.contains(r#""id":null"#),
1024 "expected id:null in wire form, got: {wire}"
1025 );
1026 }
1027
1028 #[test]
1029 fn response_id_some_serializes_as_value() {
1030 let resp = JsonRpcResponse::success(Some(JsonRpcId::I64(7)), serde_json::json!({}));
1031 let wire = serde_json::to_string(&resp).unwrap();
1032 assert!(wire.contains(r#""id":7"#));
1033 }
1034
1035 #[test]
1036 fn response_meta_management_has_none_workspace_state() {
1037 let meta = ResponseMeta::management("8.0.6");
1038 let wire = serde_json::to_string(&meta).unwrap();
1039 assert!(!wire.contains("workspace_state"), "wire: {wire}");
1040 assert!(wire.contains(r#""stale":false"#));
1041 assert!(wire.contains(r#""daemon_version":"8.0.6""#));
1042 }
1043
1044 #[test]
1045 fn response_meta_loaded_has_loaded_workspace_state() {
1046 let meta = ResponseMeta::loaded("8.0.6");
1047 let wire = serde_json::to_string(&meta).unwrap();
1048 assert!(
1049 wire.contains(r#""workspace_state":"Loaded""#),
1050 "wire: {wire}"
1051 );
1052 }
1053
1054 #[test]
1055 fn response_meta_fresh_from_emits_state() {
1056 let meta = ResponseMeta::fresh_from(WorkspaceState::Loaded, "8.0.6");
1057 let wire = serde_json::to_string(&meta).unwrap();
1058 assert!(
1059 wire.contains(r#""workspace_state":"Loaded""#),
1060 "wire: {wire}"
1061 );
1062 assert!(wire.contains(r#""stale":false"#), "wire: {wire}");
1063 // `last_good_at` / `last_error` are omitted for a Fresh verdict.
1064 assert!(!wire.contains("last_good_at"), "wire: {wire}");
1065 assert!(!wire.contains("last_error"), "wire: {wire}");
1066
1067 // Rebuilding is also a valid Fresh variant per `classify_for_serve`.
1068 let meta_rebuild = ResponseMeta::fresh_from(WorkspaceState::Rebuilding, "8.0.6");
1069 let wire_rebuild = serde_json::to_string(&meta_rebuild).unwrap();
1070 assert!(
1071 wire_rebuild.contains(r#""workspace_state":"Rebuilding""#),
1072 "wire: {wire_rebuild}"
1073 );
1074 }
1075
1076 #[test]
1077 fn response_meta_stale_from_rfc3339_and_workspace_state() {
1078 let anchor =
1079 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_760_000_000);
1080 let meta = ResponseMeta::stale_from(anchor, Some("boom".to_owned()), "8.0.6");
1081 let wire = serde_json::to_string(&meta).unwrap();
1082 assert!(wire.contains(r#""stale":true"#), "wire: {wire}");
1083 assert!(
1084 wire.contains(r#""workspace_state":"Failed""#),
1085 "wire: {wire}"
1086 );
1087 assert!(wire.contains(r#""last_error":"boom""#), "wire: {wire}");
1088 // RFC3339 UTC-Zulu — the rendered timestamp must terminate with `Z"`.
1089 let last_good_marker = r#""last_good_at":""#;
1090 let start = wire
1091 .find(last_good_marker)
1092 .unwrap_or_else(|| panic!("missing last_good_at in wire: {wire}"))
1093 + last_good_marker.len();
1094 let rest = &wire[start..];
1095 let end = rest
1096 .find('"')
1097 .expect("last_good_at must be a closed string");
1098 let rfc = &rest[..end];
1099 assert!(rfc.ends_with('Z'), "expected UTC-Zulu, got: {rfc}");
1100 assert!(
1101 rfc.contains('T'),
1102 "RFC3339 must carry a 'T' separator: {rfc}"
1103 );
1104 }
1105
1106 // ------------------------------------------------------------------
1107 // ShimRegisterAck tests (Phase 8c U1 new surface).
1108 // ------------------------------------------------------------------
1109
1110 #[test]
1111 fn shim_register_ack_accepted_omits_reason_on_wire() {
1112 let ack = ShimRegisterAck {
1113 accepted: true,
1114 daemon_version: "8.0.6".to_owned(),
1115 reason: None,
1116 envelope_version: 1,
1117 };
1118 let wire = serde_json::to_string(&ack).unwrap();
1119 assert!(!wire.contains("reason"), "wire: {wire}");
1120 assert!(wire.contains(r#""accepted":true"#), "wire: {wire}");
1121 assert!(wire.contains(r#""daemon_version":"8.0.6""#), "wire: {wire}");
1122 assert!(wire.contains(r#""envelope_version":1"#), "wire: {wire}");
1123 }
1124
1125 #[test]
1126 fn shim_register_ack_rejected_includes_reason() {
1127 let ack = ShimRegisterAck {
1128 accepted: false,
1129 daemon_version: "8.0.6".to_owned(),
1130 reason: Some("cap".to_owned()),
1131 envelope_version: 1,
1132 };
1133 let wire = serde_json::to_string(&ack).unwrap();
1134 assert!(wire.contains(r#""reason":"cap""#), "wire: {wire}");
1135 assert!(wire.contains(r#""accepted":false"#), "wire: {wire}");
1136 }
1137
1138 // ------------------------------------------------------------------
1139 // deny_unknown_fields verification (iter-1 M1 fix).
1140 // ------------------------------------------------------------------
1141
1142 #[test]
1143 fn daemon_hello_rejects_unknown_fields() {
1144 let wire = r#"{"client_version":"x","protocol_version":1,"extra":true}"#;
1145 let err = serde_json::from_str::<DaemonHello>(wire)
1146 .expect_err("DaemonHello must reject unknown fields");
1147 // serde's `deny_unknown_fields` error message contains
1148 // "unknown field" — enough to assert without pinning exact phrasing.
1149 let msg = err.to_string();
1150 assert!(
1151 msg.contains("unknown field"),
1152 "expected 'unknown field' in error, got: {msg}"
1153 );
1154 }
1155
1156 #[test]
1157 fn shim_register_rejects_unknown_fields() {
1158 let wire = r#"{"protocol":"lsp","pid":1,"extra":true}"#;
1159 let err = serde_json::from_str::<ShimRegister>(wire)
1160 .expect_err("ShimRegister must reject unknown fields");
1161 let msg = err.to_string();
1162 assert!(
1163 msg.contains("unknown field"),
1164 "expected 'unknown field' in error, got: {msg}"
1165 );
1166 }
1167
1168 // ── daemon/search (verivus-oss/sqry#238 Tier 2) ─────────────────
1169
1170 #[test]
1171 fn search_request_roundtrip_minimal() {
1172 let req = SearchRequest {
1173 envelope_version: ENVELOPE_VERSION,
1174 pattern: "foo".into(),
1175 search_path: "/tmp/ws".into(),
1176 mode: SearchMode::Exact,
1177 kind: None,
1178 lang: None,
1179 limit: None,
1180 include_generated: true,
1181 };
1182 let wire = serde_json::to_string(&req).expect("serialize");
1183 let back: SearchRequest = serde_json::from_str(&wire).expect("deserialize");
1184 assert_eq!(back, req);
1185 }
1186
1187 #[test]
1188 fn search_request_roundtrip_with_all_filters() {
1189 let req = SearchRequest {
1190 envelope_version: ENVELOPE_VERSION,
1191 pattern: "test_.*".into(),
1192 search_path: "/srv/repo".into(),
1193 mode: SearchMode::Regex,
1194 kind: Some("function".into()),
1195 lang: Some("rust".into()),
1196 limit: Some(50),
1197 include_generated: false,
1198 };
1199 let wire = serde_json::to_string(&req).expect("serialize");
1200 let back: SearchRequest = serde_json::from_str(&wire).expect("deserialize");
1201 assert_eq!(back, req);
1202 }
1203
1204 #[test]
1205 fn search_request_mode_lowercase_on_wire() {
1206 let req = SearchRequest {
1207 envelope_version: ENVELOPE_VERSION,
1208 pattern: "f".into(),
1209 search_path: ".".into(),
1210 mode: SearchMode::Fuzzy,
1211 kind: None,
1212 lang: None,
1213 limit: None,
1214 include_generated: true,
1215 };
1216 let wire = serde_json::to_string(&req).expect("serialize");
1217 // The `#[serde(rename_all = "lowercase")]` attribute on SearchMode
1218 // must surface the variant as `"fuzzy"`, not `"Fuzzy"`.
1219 assert!(
1220 wire.contains(r#""mode":"fuzzy""#),
1221 "expected mode=fuzzy lowercase; got: {wire}"
1222 );
1223 }
1224
1225 #[test]
1226 fn search_request_rejects_unknown_field() {
1227 let wire =
1228 r#"{"envelope_version":1,"pattern":"f","search_path":"/","mode":"exact","bogus":true}"#;
1229 let err = serde_json::from_str::<SearchRequest>(wire)
1230 .expect_err("SearchRequest must reject unknown fields");
1231 assert!(
1232 err.to_string().contains("unknown field"),
1233 "expected unknown-field error, got: {err}"
1234 );
1235 }
1236
1237 #[test]
1238 fn search_request_include_generated_defaults_when_absent() {
1239 // Forward-compat: a request body that predates the
1240 // `include_generated` wire field deserialises to the legacy
1241 // "no filter" behaviour (`true`), preserving the
1242 // `DAEMON_SEARCH_HANDLER` unit's approved tests after the
1243 // Codex round-1 CLI shim review fix.
1244 let wire = r#"{"pattern":"f","search_path":"/","mode":"exact"}"#;
1245 let req: SearchRequest =
1246 serde_json::from_str(wire).expect("deserialize w/o include_generated");
1247 assert!(req.include_generated);
1248 }
1249
1250 #[test]
1251 fn search_request_include_generated_round_trips_when_false() {
1252 let wire = r#"{"pattern":"f","search_path":"/","mode":"exact","include_generated":false}"#;
1253 let req: SearchRequest =
1254 serde_json::from_str(wire).expect("deserialize include_generated=false");
1255 assert!(!req.include_generated);
1256 // Re-serialise and confirm the field is preserved.
1257 let back = serde_json::to_string(&req).expect("serialize");
1258 assert!(
1259 back.contains(r#""include_generated":false"#),
1260 "include_generated=false must survive a round-trip: {back}"
1261 );
1262 }
1263
1264 #[test]
1265 fn search_request_envelope_version_defaults_when_absent() {
1266 // Forward-compat: older clients can omit `envelope_version` and
1267 // the daemon defaults it to ENVELOPE_VERSION.
1268 let wire = r#"{"pattern":"f","search_path":"/","mode":"exact"}"#;
1269 let req: SearchRequest =
1270 serde_json::from_str(wire).expect("deserialize w/o envelope_version");
1271 assert_eq!(req.envelope_version, ENVELOPE_VERSION);
1272 }
1273
1274 #[test]
1275 fn search_result_roundtrip_empty() {
1276 let result = SearchResult {
1277 items: vec![],
1278 total: 0,
1279 truncated: false,
1280 cursor: None,
1281 };
1282 let wire = serde_json::to_string(&result).expect("serialize");
1283 let back: SearchResult = serde_json::from_str(&wire).expect("deserialize");
1284 assert_eq!(back, result);
1285 }
1286
1287 #[test]
1288 fn search_result_roundtrip_single_hit() {
1289 let result = SearchResult {
1290 items: vec![SearchItem {
1291 name: "start_kernel".into(),
1292 qualified_name: "kernel::start_kernel".into(),
1293 kind: "function".into(),
1294 language: "c".into(),
1295 file_path: "/linux/init/main.c".into(),
1296 start_line: 985,
1297 start_column: 0,
1298 end_line: 1100,
1299 end_column: 1,
1300 score: None,
1301 }],
1302 total: 1,
1303 truncated: false,
1304 cursor: None,
1305 };
1306 let wire = serde_json::to_string(&result).expect("serialize");
1307 let back: SearchResult = serde_json::from_str(&wire).expect("deserialize");
1308 assert_eq!(back, result);
1309 }
1310
1311 #[test]
1312 fn search_result_roundtrip_truncated_with_score() {
1313 let result = SearchResult {
1314 items: (0..3)
1315 .map(|i| SearchItem {
1316 name: format!("hit_{i}"),
1317 qualified_name: format!("crate::hit_{i}"),
1318 kind: "function".into(),
1319 language: "rust".into(),
1320 file_path: "/repo/src/lib.rs".into(),
1321 start_line: 10 + i,
1322 start_column: 0,
1323 end_line: 12 + i,
1324 end_column: 1,
1325 score: Some(0.5_f32 + (i as f32) * 0.1),
1326 })
1327 .collect(),
1328 total: 101,
1329 truncated: true,
1330 cursor: None,
1331 };
1332 let wire = serde_json::to_string(&result).expect("serialize");
1333 let back: SearchResult = serde_json::from_str(&wire).expect("deserialize");
1334 assert_eq!(back, result);
1335 }
1336
1337 #[test]
1338 fn search_item_rejects_unknown_field() {
1339 let wire = r#"{"name":"f","qualified_name":"f","kind":"function","language":"rust","file_path":"/","start_line":1,"start_column":0,"end_line":1,"end_column":0,"bogus":1}"#;
1340 let err = serde_json::from_str::<SearchItem>(wire)
1341 .expect_err("SearchItem must reject unknown fields");
1342 assert!(err.to_string().contains("unknown field"));
1343 }
1344
1345 #[test]
1346 fn search_result_rejects_unknown_field() {
1347 let wire = r#"{"items":[],"total":0,"truncated":false,"bogus":1}"#;
1348 let err = serde_json::from_str::<SearchResult>(wire)
1349 .expect_err("SearchResult must reject unknown fields");
1350 assert!(err.to_string().contains("unknown field"));
1351 }
1352
1353 #[test]
1354 fn search_result_score_omitted_when_none() {
1355 // `skip_serializing_if = "Option::is_none"` on `score` keeps the
1356 // wire compact for regex/exact hits where no score is meaningful.
1357 let item = SearchItem {
1358 name: "f".into(),
1359 qualified_name: "f".into(),
1360 kind: "function".into(),
1361 language: "rust".into(),
1362 file_path: "/".into(),
1363 start_line: 1,
1364 start_column: 0,
1365 end_line: 1,
1366 end_column: 0,
1367 score: None,
1368 };
1369 let wire = serde_json::to_string(&item).expect("serialize");
1370 assert!(
1371 !wire.contains("\"score\""),
1372 "score must be omitted when None; got: {wire}"
1373 );
1374 }
1375
1376 #[test]
1377 fn search_result_envelope_wraps_payload() {
1378 // SearchResult is intended to ride inside ResponseEnvelope<T>, just
1379 // like LoadResult / RebuildResult. Verify the wrap.
1380 let envelope = ResponseEnvelope {
1381 result: SearchResult {
1382 items: vec![],
1383 total: 0,
1384 truncated: false,
1385 cursor: None,
1386 },
1387 meta: ResponseMeta::management("test"),
1388 };
1389 let wire = serde_json::to_string(&envelope).expect("serialize");
1390 let back: ResponseEnvelope<SearchResult> =
1391 serde_json::from_str(&wire).expect("deserialize");
1392 assert_eq!(back.result, envelope.result);
1393 }
1394}