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