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// Wire envelope version.
44// ---------------------------------------------------------------------------
45
46/// Version of the daemon wire envelope ([`DaemonHelloResponse::envelope_version`],
47/// [`ShimRegisterAck::envelope_version`]).
48///
49/// Bumped when the [`ResponseEnvelope`] schema changes in an incompatible way.
50/// Kept at `1` per the Amendment-2 2026-04-09 freeze.
51///
52/// This constant lives in the leaf wire-type crate (`sqry-daemon-protocol`) so
53/// every consumer of the wire format — the daemon itself, the daemon client
54/// (`sqry-daemon-client`), and the shim-mode callers inside `sqry-lsp` /
55/// `sqry-mcp` — validates against exactly one source of truth. Clients MUST
56/// reject a response whose `envelope_version` differs from this constant
57/// rather than proceed on a mismatched wire format.
58pub const ENVELOPE_VERSION: u32 = 1;
59
60// ---------------------------------------------------------------------------
61// WorkspaceState — moved here from sqry-daemon/src/workspace/state.rs
62// ---------------------------------------------------------------------------
63
64/// Six-state workspace lifecycle per plan Task 6 Step 1 and Amendment 2 §G.5 /
65/// §G.7.
66///
67/// The `#[repr(u8)]` is load-bearing: `sqry-daemon`'s `LoadedWorkspace::state`
68/// is an `AtomicU8`, and the conversions [`Self::from_u8`] / [`Self::as_u8`]
69/// serialise the state machine without allocation. Values are deliberately
70/// contiguous from 0 so adding a variant stays backwards-compatible with
71/// persisted telemetry.
72///
73/// This type lives in the leaf wire-type crate so [`ResponseMeta`] can
74/// carry a canonical workspace_state string on every successful tool
75/// response without the leaf crate taking a dep on `sqry-daemon` itself.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[repr(u8)]
78pub enum WorkspaceState {
79 /// Workspace entry exists but no graph has been loaded yet.
80 Unloaded = 0,
81
82 /// Initial load is in progress — a single blocking read from disk or
83 /// a full rebuild with no prior snapshot.
84 Loading = 1,
85
86 /// Graph is loaded, idle, and ready to serve queries.
87 Loaded = 2,
88
89 /// A rebuild (incremental or full) is actively running on the
90 /// dispatcher's background task. Queries keep serving the prior
91 /// `ArcSwap<CodeGraph>` snapshot until `publish_and_retain` swaps
92 /// the new graph in.
93 Rebuilding = 3,
94
95 /// Workspace was LRU-evicted or explicitly unloaded. The entry is
96 /// REMOVED from the manager map — the next query must re-load via
97 /// `get_or_load`. This discriminant exists for the short window
98 /// between `execute_eviction` storing the state and
99 /// `workspaces.remove(key)` completing (both under
100 /// `workspaces.write()`); external observers routed through
101 /// `WorkspaceManager::classify_for_serve` see the map-missing arm
102 /// first and get `DaemonError::WorkspaceEvicted` regardless.
103 Evicted = 4,
104
105 /// The most recent rebuild failed. Queries are served from the last
106 /// good snapshot with `meta.stale = true`; if the
107 /// `stale_serve_max_age_hours` cap is exceeded, queries receive the
108 /// JSON-RPC `-32002 workspace_stale_expired` error instead.
109 Failed = 5,
110}
111
112impl WorkspaceState {
113 /// Round-trip the state to its discriminant.
114 #[must_use]
115 pub const fn as_u8(self) -> u8 {
116 self as u8
117 }
118
119 /// Parse a discriminant back to a state. Returns `None` on any value
120 /// outside the current enum range — callers should treat this as a
121 /// telemetry corruption rather than silently map to `Unloaded`.
122 #[must_use]
123 pub const fn from_u8(value: u8) -> Option<Self> {
124 match value {
125 0 => Some(Self::Unloaded),
126 1 => Some(Self::Loading),
127 2 => Some(Self::Loaded),
128 3 => Some(Self::Rebuilding),
129 4 => Some(Self::Evicted),
130 5 => Some(Self::Failed),
131 _ => None,
132 }
133 }
134
135 /// Canonical display string. Used by `daemon/status` output and
136 /// tracing spans.
137 #[must_use]
138 pub const fn as_str(self) -> &'static str {
139 match self {
140 Self::Unloaded => "unloaded",
141 Self::Loading => "loading",
142 Self::Loaded => "loaded",
143 Self::Rebuilding => "rebuilding",
144 Self::Evicted => "evicted",
145 Self::Failed => "failed",
146 }
147 }
148
149 /// Whether the workspace can still serve queries in this state.
150 ///
151 /// `true` for [`Self::Loaded`], [`Self::Rebuilding`] (old snapshot
152 /// still served), and [`Self::Failed`] (stale-serve subject to the
153 /// age cap). `false` for [`Self::Unloaded`], [`Self::Loading`],
154 /// and [`Self::Evicted`].
155 #[must_use]
156 pub const fn is_serving(self) -> bool {
157 matches!(self, Self::Loaded | Self::Rebuilding | Self::Failed)
158 }
159}
160
161impl std::fmt::Display for WorkspaceState {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 f.write_str(self.as_str())
164 }
165}
166
167// ---------------------------------------------------------------------------
168// Handshake types.
169// ---------------------------------------------------------------------------
170
171/// Pre-handshake header sent as the very first frame by a CLI client.
172/// The server responds with [`DaemonHelloResponse`] before the
173/// JSON-RPC request loop begins.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct DaemonHello {
177 /// Free-form client identifier (`env!("CARGO_PKG_VERSION")` plus
178 /// user-agent suffix). Informational only.
179 pub client_version: String,
180
181 /// Wire protocol version. Phase 8a accepts exactly `1`.
182 pub protocol_version: u32,
183}
184
185/// Server's reply to [`DaemonHello`]. If `compatible` is `false` the
186/// server closes the connection immediately after the frame is sent.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct DaemonHelloResponse {
190 pub compatible: bool,
191 pub daemon_version: String,
192 pub envelope_version: u32,
193}
194
195// ---------------------------------------------------------------------------
196// Shim handshake (Phase 8c wire types).
197// ---------------------------------------------------------------------------
198
199/// Which client protocol the shim will pump bytes for. Phase 8c surface.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum ShimProtocol {
203 Lsp,
204 Mcp,
205}
206
207/// Shim registration header sent as the first frame by a
208/// `sqry lsp --daemon` or `sqry mcp --daemon` process. The router in
209/// sqry-daemon shape-discriminates between [`DaemonHello`] and this
210/// type using `#[serde(deny_unknown_fields)]`.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct ShimRegister {
214 pub protocol: ShimProtocol,
215 pub pid: u32,
216}
217
218/// Server's reply to [`ShimRegister`]. If `accepted` is `false` the
219/// server closes the connection after sending the ack and the shim
220/// client surfaces `reason` to its parent process. When `accepted` is
221/// `true`, `reason` is omitted from the wire form (skip-if-none).
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct ShimRegisterAck {
225 pub accepted: bool,
226 pub daemon_version: String,
227 /// Rejection reason. Omitted from the wire when accepted=true.
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub reason: Option<String>,
230 pub envelope_version: u32,
231}
232
233// ---------------------------------------------------------------------------
234// ResponseEnvelope.
235// ---------------------------------------------------------------------------
236
237/// Uniform successful-response wrapper. Every successful method
238/// response is serialised as `ResponseEnvelope<T>` at the JSON-RPC
239/// `result` field — clients can rely on the [`ResponseMeta`] shape
240/// being present on every successful reply regardless of method.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ResponseEnvelope<T> {
243 pub result: T,
244 pub meta: ResponseMeta,
245}
246
247/// Metadata attached to every successful response. For Phase 8a
248/// management methods the staleness fields are always absent
249/// (`stale = false`, no last_good_at, no last_error,
250/// `workspace_state = None`). Phase 8b populates them from the
251/// server-side `ServeVerdict` for tool-method responses.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct ResponseMeta {
254 pub stale: bool,
255
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub last_good_at: Option<String>,
258
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub last_error: Option<String>,
261
262 /// Canonical workspace state string (serde form of
263 /// [`WorkspaceState`]). `None` for methods not tied to a workspace.
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub workspace_state: Option<WorkspaceState>,
266
267 pub daemon_version: String,
268}
269
270impl ResponseMeta {
271 /// Construct the [`ResponseMeta`] used by daemon management methods
272 /// (`daemon/status`, `daemon/unload`, `daemon/stop` — the ones not
273 /// bound to a specific workspace).
274 #[must_use]
275 pub fn management(daemon_version: &str) -> Self {
276 Self {
277 stale: false,
278 last_good_at: None,
279 last_error: None,
280 workspace_state: None,
281 daemon_version: daemon_version.to_owned(),
282 }
283 }
284
285 /// Construct the [`ResponseMeta`] for a successful `daemon/load`.
286 /// Phase 8b adds `fresh_from` / `stale_from` constructors for
287 /// MCP tool-method responses that route through `classify_for_serve`.
288 #[must_use]
289 pub fn loaded(daemon_version: &str) -> Self {
290 Self {
291 stale: false,
292 last_good_at: None,
293 last_error: None,
294 workspace_state: Some(WorkspaceState::Loaded),
295 daemon_version: daemon_version.to_owned(),
296 }
297 }
298
299 /// Construct [`ResponseMeta`] for a tool-method response served from a
300 /// Fresh workspace verdict (`WorkspaceState::Loaded` or `Rebuilding`).
301 ///
302 /// Phase 8b Task 7 — populated by the `tool_dispatch` helper when
303 /// the daemon's `WorkspaceManager::classify_for_serve` returns
304 /// `ServeVerdict::Fresh`. `stale` is `false` and both `last_good_at`
305 /// and `last_error` are absent from the wire form (they are skipped
306 /// by `serde(skip_serializing_if = "Option::is_none")`).
307 #[must_use]
308 pub fn fresh_from(state: WorkspaceState, daemon_version: &str) -> Self {
309 Self {
310 stale: false,
311 last_good_at: None,
312 last_error: None,
313 workspace_state: Some(state),
314 daemon_version: daemon_version.to_owned(),
315 }
316 }
317
318 /// Construct [`ResponseMeta`] for a tool-method response served from a
319 /// Stale verdict. `last_good_at` is rendered as RFC3339 UTC-Zulu via
320 /// `chrono::DateTime::<Utc>::from(SystemTime) -> to_rfc3339_opts(Secs, true)`.
321 ///
322 /// `workspace_state` is fixed at [`WorkspaceState::Failed`] because
323 /// `WorkspaceManager::classify_for_serve` only emits a Stale verdict
324 /// when the observed state is `Failed`. Keeping this constructor
325 /// intentionally rigid (no caller-supplied state) prevents the wire
326 /// form from claiming `stale = true` with a workspace_state the
327 /// classifier could never have produced.
328 #[must_use]
329 pub fn stale_from(
330 last_good_at: std::time::SystemTime,
331 last_error: Option<String>,
332 daemon_version: &str,
333 ) -> Self {
334 use chrono::{DateTime, SecondsFormat, Utc};
335 let rfc3339 =
336 DateTime::<Utc>::from(last_good_at).to_rfc3339_opts(SecondsFormat::Secs, true);
337 Self {
338 stale: true,
339 last_good_at: Some(rfc3339),
340 last_error,
341 workspace_state: Some(WorkspaceState::Failed),
342 daemon_version: daemon_version.to_owned(),
343 }
344 }
345}
346
347// ---------------------------------------------------------------------------
348// daemon/load result wire type.
349// ---------------------------------------------------------------------------
350
351/// `daemon/load` success result payload.
352///
353/// Serialised under the `result` field of [`ResponseEnvelope`]. Living
354/// in the leaf protocol crate lets both the daemon (writer) and
355/// [`sqry-daemon-client`][] (reader) share a single typed definition —
356/// clients can `serde_json::from_value::<ResponseEnvelope<LoadResult>>`
357/// and get compile-time schema checking instead of stringly-typed
358/// `serde_json::Value::get` lookups.
359///
360/// [`sqry-daemon-client`]: ../../sqry-daemon-client/index.html
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[serde(deny_unknown_fields)]
363pub struct LoadResult {
364 /// The canonicalised workspace root path that the daemon loaded.
365 pub root: std::path::PathBuf,
366
367 /// Resident graph memory footprint for the loaded workspace, in
368 /// bytes. Matches `LoadedWorkspace::heap_bytes()` at the moment of
369 /// the response.
370 pub current_bytes: u64,
371
372 /// The canonical workspace lifecycle state after the load
373 /// completes. Always [`WorkspaceState::Loaded`] on the successful
374 /// `daemon/load` path — the field is typed so clients do not have
375 /// to re-parse the string.
376 pub state: WorkspaceState,
377}
378
379/// `daemon/rebuild` success result payload.
380///
381/// Serialised under the `result` field of [`ResponseEnvelope`]. Reports
382/// post-rebuild graph statistics and the wall-clock duration of the rebuild.
383#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
384pub struct RebuildResult {
385 /// The canonicalised workspace root path that was rebuilt.
386 pub root: std::path::PathBuf,
387 /// Wall-clock time the rebuild took, in milliseconds.
388 pub duration_ms: u64,
389 /// Node count of the freshly published graph.
390 pub nodes: u64,
391 /// Edge count of the freshly published graph.
392 pub edges: u64,
393 /// Number of source files indexed in the freshly published graph.
394 pub files_indexed: u64,
395 /// `true` when the rebuild was a full (non-incremental) rebuild.
396 pub was_full: bool,
397}
398
399/// `daemon/cancel_rebuild` success result payload.
400///
401/// Serialised under the `result` field of [`ResponseEnvelope`].
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
403pub struct CancelRebuildResult {
404 /// The canonicalised workspace root path whose rebuild was signalled for
405 /// cancellation.
406 pub root: std::path::PathBuf,
407 /// `true` when a rebuild was actually in flight at the moment the
408 /// cancellation signal was dispatched.
409 pub cancelled: bool,
410}
411
412// ---------------------------------------------------------------------------
413// JSON-RPC 2.0 envelope types.
414// ---------------------------------------------------------------------------
415
416/// JSON-RPC `"2.0"` version tag. Manual serde impls enforce exact
417/// string match on the wire so malformed requests never leak into the
418/// method dispatcher.
419#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
420pub struct JsonRpcVersion;
421
422impl Serialize for JsonRpcVersion {
423 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
424 s.serialize_str("2.0")
425 }
426}
427
428impl<'de> Deserialize<'de> for JsonRpcVersion {
429 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
430 struct Vis(PhantomData<JsonRpcVersion>);
431 impl<'de> de::Visitor<'de> for Vis {
432 type Value = JsonRpcVersion;
433 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434 f.write_str("the string \"2.0\"")
435 }
436 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
437 if v == "2.0" {
438 Ok(JsonRpcVersion)
439 } else {
440 Err(E::invalid_value(de::Unexpected::Str(v), &"\"2.0\""))
441 }
442 }
443 }
444 d.deserialize_str(Vis(PhantomData))
445 }
446}
447
448/// JSON-RPC id: `null`, integer (signed or unsigned), or string.
449/// `I64` covers `i64::MIN..=i64::MAX`; `U64` covers
450/// `i64::MAX + 1..=u64::MAX`. Serde's untagged deserialize tries
451/// variants in order so `0..=i64::MAX` lands in `I64` and
452/// `i64::MAX + 1..=u64::MAX` in `U64`.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
454#[serde(untagged)]
455pub enum JsonRpcId {
456 /// Signed integer id.
457 I64(i64),
458 /// Unsigned integer id above `i64::MAX`.
459 U64(u64),
460 /// String id.
461 Str(String),
462}
463
464/// JSON-RPC 2.0 request.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct JsonRpcRequest {
467 pub jsonrpc: JsonRpcVersion,
468
469 /// `None` ≙ notification (no response expected).
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub id: Option<JsonRpcId>,
472
473 pub method: String,
474
475 #[serde(default)]
476 pub params: serde_json::Value,
477}
478
479/// JSON-RPC 2.0 response. `id` is [`Option<JsonRpcId>`] with **no**
480/// `skip_serializing_if` — the `None` case serialises as JSON `null`,
481/// which is exactly what the spec demands for parse-error and
482/// invalid-request responses.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct JsonRpcResponse {
485 pub jsonrpc: JsonRpcVersion,
486
487 /// `null` on the wire when the server could not determine the
488 /// originating request id (parse error, invalid request shape,
489 /// batch element with un-parseable id).
490 pub id: Option<JsonRpcId>,
491
492 #[serde(flatten)]
493 pub payload: JsonRpcPayload,
494}
495
496/// Tagged success-or-error payload. Serde `untagged` so the wire form
497/// is `{... "result": ...}` or `{... "error": ...}`, never both.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(untagged)]
500pub enum JsonRpcPayload {
501 Success { result: serde_json::Value },
502 Error { error: JsonRpcError },
503}
504
505/// JSON-RPC 2.0 error payload.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct JsonRpcError {
508 pub code: i32,
509 pub message: String,
510 #[serde(skip_serializing_if = "Option::is_none")]
511 pub data: Option<serde_json::Value>,
512}
513
514impl JsonRpcResponse {
515 /// Construct a successful response.
516 #[must_use]
517 pub fn success(id: Option<JsonRpcId>, result: serde_json::Value) -> Self {
518 Self {
519 jsonrpc: JsonRpcVersion,
520 id,
521 payload: JsonRpcPayload::Success { result },
522 }
523 }
524
525 /// Construct an error response.
526 #[must_use]
527 pub fn error(
528 id: Option<JsonRpcId>,
529 code: i32,
530 message: impl Into<String>,
531 data: Option<serde_json::Value>,
532 ) -> Self {
533 Self {
534 jsonrpc: JsonRpcVersion,
535 id,
536 payload: JsonRpcPayload::Error {
537 error: JsonRpcError {
538 code,
539 message: message.into(),
540 data,
541 },
542 },
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[test]
552 fn jsonrpc_version_roundtrip() {
553 let wire = serde_json::to_string(&JsonRpcVersion).unwrap();
554 assert_eq!(wire, r#""2.0""#);
555 let back: JsonRpcVersion = serde_json::from_str(&wire).unwrap();
556 assert_eq!(back, JsonRpcVersion);
557 }
558
559 #[test]
560 fn jsonrpc_version_rejects_wrong_string() {
561 let err = serde_json::from_str::<JsonRpcVersion>(r#""1.0""#)
562 .expect_err("must reject non-\"2.0\"");
563 assert!(err.to_string().contains("\"2.0\""));
564 }
565
566 #[test]
567 fn jsonrpc_id_untagged_roundtrip() {
568 let cases: &[(&str, JsonRpcId)] = &[
569 ("0", JsonRpcId::I64(0)),
570 ("-7", JsonRpcId::I64(-7)),
571 (&i64::MAX.to_string(), JsonRpcId::I64(i64::MAX)),
572 ("\"abc\"", JsonRpcId::Str("abc".into())),
573 ];
574 for (wire, expected) in cases {
575 let parsed: JsonRpcId = serde_json::from_str(wire).expect(wire);
576 assert_eq!(&parsed, expected, "round-trip failed for {wire}");
577 }
578 // i64::MAX + 1 routes to U64.
579 let u: JsonRpcId = serde_json::from_str("9223372036854775808").unwrap();
580 assert_eq!(u, JsonRpcId::U64(9_223_372_036_854_775_808));
581 }
582
583 #[test]
584 fn response_id_none_serializes_as_json_null() {
585 let resp = JsonRpcResponse::error(None, -32700, "Parse error", None);
586 let wire = serde_json::to_string(&resp).unwrap();
587 assert!(
588 wire.contains(r#""id":null"#),
589 "expected id:null in wire form, got: {wire}"
590 );
591 }
592
593 #[test]
594 fn response_id_some_serializes_as_value() {
595 let resp = JsonRpcResponse::success(Some(JsonRpcId::I64(7)), serde_json::json!({}));
596 let wire = serde_json::to_string(&resp).unwrap();
597 assert!(wire.contains(r#""id":7"#));
598 }
599
600 #[test]
601 fn response_meta_management_has_none_workspace_state() {
602 let meta = ResponseMeta::management("8.0.6");
603 let wire = serde_json::to_string(&meta).unwrap();
604 assert!(!wire.contains("workspace_state"), "wire: {wire}");
605 assert!(wire.contains(r#""stale":false"#));
606 assert!(wire.contains(r#""daemon_version":"8.0.6""#));
607 }
608
609 #[test]
610 fn response_meta_loaded_has_loaded_workspace_state() {
611 let meta = ResponseMeta::loaded("8.0.6");
612 let wire = serde_json::to_string(&meta).unwrap();
613 assert!(
614 wire.contains(r#""workspace_state":"Loaded""#),
615 "wire: {wire}"
616 );
617 }
618
619 #[test]
620 fn response_meta_fresh_from_emits_state() {
621 let meta = ResponseMeta::fresh_from(WorkspaceState::Loaded, "8.0.6");
622 let wire = serde_json::to_string(&meta).unwrap();
623 assert!(
624 wire.contains(r#""workspace_state":"Loaded""#),
625 "wire: {wire}"
626 );
627 assert!(wire.contains(r#""stale":false"#), "wire: {wire}");
628 // `last_good_at` / `last_error` are omitted for a Fresh verdict.
629 assert!(!wire.contains("last_good_at"), "wire: {wire}");
630 assert!(!wire.contains("last_error"), "wire: {wire}");
631
632 // Rebuilding is also a valid Fresh variant per `classify_for_serve`.
633 let meta_rebuild = ResponseMeta::fresh_from(WorkspaceState::Rebuilding, "8.0.6");
634 let wire_rebuild = serde_json::to_string(&meta_rebuild).unwrap();
635 assert!(
636 wire_rebuild.contains(r#""workspace_state":"Rebuilding""#),
637 "wire: {wire_rebuild}"
638 );
639 }
640
641 #[test]
642 fn response_meta_stale_from_rfc3339_and_workspace_state() {
643 let anchor =
644 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_760_000_000);
645 let meta = ResponseMeta::stale_from(anchor, Some("boom".to_owned()), "8.0.6");
646 let wire = serde_json::to_string(&meta).unwrap();
647 assert!(wire.contains(r#""stale":true"#), "wire: {wire}");
648 assert!(
649 wire.contains(r#""workspace_state":"Failed""#),
650 "wire: {wire}"
651 );
652 assert!(wire.contains(r#""last_error":"boom""#), "wire: {wire}");
653 // RFC3339 UTC-Zulu — the rendered timestamp must terminate with `Z"`.
654 let last_good_marker = r#""last_good_at":""#;
655 let start = wire
656 .find(last_good_marker)
657 .unwrap_or_else(|| panic!("missing last_good_at in wire: {wire}"))
658 + last_good_marker.len();
659 let rest = &wire[start..];
660 let end = rest
661 .find('"')
662 .expect("last_good_at must be a closed string");
663 let rfc = &rest[..end];
664 assert!(rfc.ends_with('Z'), "expected UTC-Zulu, got: {rfc}");
665 assert!(
666 rfc.contains('T'),
667 "RFC3339 must carry a 'T' separator: {rfc}"
668 );
669 }
670
671 // ------------------------------------------------------------------
672 // ShimRegisterAck tests (Phase 8c U1 new surface).
673 // ------------------------------------------------------------------
674
675 #[test]
676 fn shim_register_ack_accepted_omits_reason_on_wire() {
677 let ack = ShimRegisterAck {
678 accepted: true,
679 daemon_version: "8.0.6".to_owned(),
680 reason: None,
681 envelope_version: 1,
682 };
683 let wire = serde_json::to_string(&ack).unwrap();
684 assert!(!wire.contains("reason"), "wire: {wire}");
685 assert!(wire.contains(r#""accepted":true"#), "wire: {wire}");
686 assert!(wire.contains(r#""daemon_version":"8.0.6""#), "wire: {wire}");
687 assert!(wire.contains(r#""envelope_version":1"#), "wire: {wire}");
688 }
689
690 #[test]
691 fn shim_register_ack_rejected_includes_reason() {
692 let ack = ShimRegisterAck {
693 accepted: false,
694 daemon_version: "8.0.6".to_owned(),
695 reason: Some("cap".to_owned()),
696 envelope_version: 1,
697 };
698 let wire = serde_json::to_string(&ack).unwrap();
699 assert!(wire.contains(r#""reason":"cap""#), "wire: {wire}");
700 assert!(wire.contains(r#""accepted":false"#), "wire: {wire}");
701 }
702
703 // ------------------------------------------------------------------
704 // deny_unknown_fields verification (iter-1 M1 fix).
705 // ------------------------------------------------------------------
706
707 #[test]
708 fn daemon_hello_rejects_unknown_fields() {
709 let wire = r#"{"client_version":"x","protocol_version":1,"extra":true}"#;
710 let err = serde_json::from_str::<DaemonHello>(wire)
711 .expect_err("DaemonHello must reject unknown fields");
712 // serde's `deny_unknown_fields` error message contains
713 // "unknown field" — enough to assert without pinning exact phrasing.
714 let msg = err.to_string();
715 assert!(
716 msg.contains("unknown field"),
717 "expected 'unknown field' in error, got: {msg}"
718 );
719 }
720
721 #[test]
722 fn shim_register_rejects_unknown_fields() {
723 let wire = r#"{"protocol":"lsp","pid":1,"extra":true}"#;
724 let err = serde_json::from_str::<ShimRegister>(wire)
725 .expect_err("ShimRegister must reject unknown fields");
726 let msg = err.to_string();
727 assert!(
728 msg.contains("unknown field"),
729 "expected 'unknown field' in error, got: {msg}"
730 );
731 }
732}