Skip to main content

lean_rs_worker_protocol/
protocol.rs

1//! Length-delimited frame codec and message payload types for the
2//! parent ↔ child worker process boundary.
3//!
4//! ## Additive evolution
5//!
6//! Every public enum here is `#[non_exhaustive]` so the wire format can gain
7//! a new request, response, or message kind without forcing a semver-major
8//! bump on consumers. Most structs are also `#[non_exhaustive]` and expose
9//! `pub fn new(...)` constructors so the shapes can grow fields without
10//! breaking external builders. The exception is [`DataRow`], which is built
11//! so frequently with struct-literal syntax (tests, harnesses, fakes) that
12//! the ergonomic cost of `#[non_exhaustive]` outweighs the additive-evolution
13//! benefit; the wire schema for a data row is also already fixed by the
14//! stream contract.
15
16use std::collections::BTreeMap;
17use std::fmt;
18use std::io::{self, Read, Write};
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use serde_json::value::RawValue;
24
25use crate::types::{
26    LeanWorkerCapabilityMetadata, LeanWorkerDeclarationFilter, LeanWorkerDeclarationInspectionRequest,
27    LeanWorkerDeclarationInspectionResult, LeanWorkerDeclarationRow, LeanWorkerDeclarationSearch,
28    LeanWorkerDeclarationSearchResult, LeanWorkerDeclarationType, LeanWorkerDeclarationVerificationRequest,
29    LeanWorkerDeclarationVerificationResult, LeanWorkerDoctorReport, LeanWorkerElabOptions, LeanWorkerElabResult,
30    LeanWorkerImportStats, LeanWorkerKernelResult, LeanWorkerMetaResult, LeanWorkerMetaTransparency,
31    LeanWorkerModuleQuery, LeanWorkerModuleQueryBatchOutcome, LeanWorkerModuleQueryOutcome,
32    LeanWorkerModuleQuerySelector, LeanWorkerOutputBudgets, LeanWorkerProofAttemptRequest,
33    LeanWorkerProofAttemptResult, LeanWorkerRendered, LeanWorkerSessionImportProfile,
34};
35
36/// Wire protocol version negotiated between parent and child during the
37/// handshake frame. Bump only on a breaking wire change.
38pub const PROTOCOL_VERSION: u16 = 10;
39
40/// Default per-frame size limit applied by the parent when no explicit cap is
41/// configured on the capability builder.
42///
43/// The cap is a parent-side policy decision negotiated to the child at
44/// handshake time via [`Message::ConfigureFrameLimit`]. Both [`write_frame`]
45/// and [`read_frame`] reject frames whose serialised JSON payload exceeds the
46/// cap passed in, so a runaway producer cannot make the peer allocate without
47/// bound. The cap is per-connection—set once at handshake, applied to every
48/// subsequent frame in both directions.
49pub const MAX_FRAME_BYTES: u32 = 1024 * 1024;
50
51/// Floor on the configurable frame cap. Trivial requests and the handshake
52/// itself must fit inside this; callers cannot configure smaller.
53pub const MIN_FRAME_BYTES: u32 = 64 * 1024;
54
55/// Ceiling on the configurable frame cap. Prevents callers from defeating the
56/// memory-safety policy by passing an absurdly large value.
57pub const MAX_FRAME_BYTES_HARD_CAP: u32 = 256 * 1024 * 1024;
58
59/// Versioned envelope around a single protocol [`Message`].
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[non_exhaustive]
62pub struct Frame {
63    /// Protocol version the sender used. Receivers reject mismatches.
64    pub version: u16,
65    /// Inner message payload.
66    pub message: Message,
67}
68
69impl Frame {
70    /// Wrap `message` in a frame tagged with the current [`PROTOCOL_VERSION`].
71    #[must_use]
72    pub fn new(message: Message) -> Self {
73        Self {
74            version: PROTOCOL_VERSION,
75            message,
76        }
77    }
78}
79
80/// One protocol message exchanged over the worker boundary.
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(tag = "type", content = "body", rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum Message {
85    /// Sent by the child immediately after spawn to advertise its version and
86    /// supported protocol revision.
87    Handshake {
88        /// `lean-rs-worker-child` package version.
89        worker_version: String,
90        /// Protocol version the child speaks. Parent rejects mismatches.
91        protocol_version: u16,
92    },
93    /// Sent by the parent immediately after the handshake frame to negotiate
94    /// the per-frame size cap for the remainder of this connection.
95    ///
96    /// The parent owns the memory-safety policy: it clamps the requested cap
97    /// into <code>[[MIN_FRAME_BYTES], [MAX_FRAME_BYTES_HARD_CAP]]</code>
98    /// before sending. The child installs the value as-is.
99    ConfigureFrameLimit {
100        /// Per-frame byte cap applied in both directions for this connection.
101        max_frame_bytes: u32,
102    },
103    /// Parent → child request frame.
104    Request(Request),
105    /// Child → parent terminal response for one request.
106    Response(Response),
107    /// Child → parent intermediate diagnostic frame.
108    Diagnostic(Diagnostic),
109    /// Child → parent intermediate progress frame.
110    ProgressTick(ProgressTick),
111    /// Child → parent streaming data row frame.
112    DataRow(DataRow),
113    /// Child → parent fatal exit notification carrying the captured stderr
114    /// just before the child process tears down.
115    FatalExit(FatalExit),
116}
117
118/// Parent-issued worker request body.
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[serde(tag = "op", rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum Request {
123    Health,
124    LoadFixtureCapability {
125        manifest_path: String,
126    },
127    CallFixtureMul {
128        manifest_path: String,
129        lhs: u64,
130        rhs: u64,
131    },
132    TriggerLeanPanic {
133        manifest_path: String,
134    },
135    OpenHostSession {
136        project_root: String,
137        mode: HostSessionMode,
138        imports: Vec<String>,
139        import_profile: LeanWorkerSessionImportProfile,
140    },
141    Elaborate {
142        source: String,
143        options: LeanWorkerElabOptions,
144    },
145    KernelCheck {
146        source: String,
147        options: LeanWorkerElabOptions,
148        progress: bool,
149    },
150    DeclarationKinds {
151        names: Vec<String>,
152        progress: bool,
153    },
154    DeclarationNames {
155        names: Vec<String>,
156        progress: bool,
157    },
158    RunDataStream {
159        export: String,
160        request_json: String,
161        progress: bool,
162    },
163    CapabilityMetadata {
164        export: String,
165        request_json: String,
166    },
167    CapabilityDoctor {
168        export: String,
169        request_json: String,
170    },
171    JsonCommand {
172        export: String,
173        request_json: String,
174    },
175    InferType {
176        source: String,
177        options: LeanWorkerElabOptions,
178    },
179    Whnf {
180        source: String,
181        options: LeanWorkerElabOptions,
182    },
183    IsDefEq {
184        lhs: String,
185        rhs: String,
186        transparency: LeanWorkerMetaTransparency,
187        options: LeanWorkerElabOptions,
188    },
189    Describe {
190        name: String,
191    },
192    SearchDeclarations {
193        search: LeanWorkerDeclarationSearch,
194    },
195    DeclarationType {
196        name: String,
197        max_bytes: usize,
198    },
199    InspectDeclaration {
200        request: LeanWorkerDeclarationInspectionRequest,
201    },
202    AttemptProof {
203        request: LeanWorkerProofAttemptRequest,
204        options: LeanWorkerElabOptions,
205        progress: bool,
206    },
207    VerifyDeclaration {
208        request: LeanWorkerDeclarationVerificationRequest,
209        options: LeanWorkerElabOptions,
210        progress: bool,
211    },
212    ListDeclarationsStrings {
213        filter: LeanWorkerDeclarationFilter,
214        progress: bool,
215    },
216    DescribeBulk {
217        names: Vec<String>,
218        progress: bool,
219    },
220    ProcessModuleQuery {
221        source: String,
222        query: LeanWorkerModuleQuery,
223        options: LeanWorkerElabOptions,
224    },
225    ProcessModuleQueryBatch {
226        source: String,
227        selectors: Vec<LeanWorkerModuleQuerySelector>,
228        budgets: LeanWorkerOutputBudgets,
229        options: LeanWorkerElabOptions,
230    },
231    ClearModuleSnapshotCache,
232    // Private harness requests that exercise streaming frame behavior.
233    // Not part of the public row sink API.
234    EmitTestRows {
235        streams: Vec<String>,
236    },
237    EmitTestRowsThenExit,
238    EmitTestRowsThenPanic,
239    Terminate,
240}
241
242/// How the worker child should load host-session capabilities.
243#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
244#[serde(tag = "kind", rename_all = "snake_case")]
245#[non_exhaustive]
246pub enum HostSessionMode {
247    /// Open a user capability dylib and the bundled host shims.
248    Capability {
249        package: String,
250        lib_name: String,
251        manifest_path: Option<String>,
252    },
253    /// Open only the bundled host shims.
254    ShimsOnly,
255}
256
257/// Child-issued terminal response body for one [`Request`].
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259#[serde(tag = "status", rename_all = "snake_case")]
260#[non_exhaustive]
261pub enum Response {
262    HealthOk,
263    CapabilityLoaded,
264    U64 {
265        value: u64,
266    },
267    HostSessionOpened {
268        import_stats: LeanWorkerImportStats,
269    },
270    Elaboration {
271        outcome: LeanWorkerElabResult,
272    },
273    KernelCheck {
274        outcome: LeanWorkerKernelResult,
275    },
276    Strings {
277        values: Vec<String>,
278    },
279    StreamComplete {
280        summary: StreamSummary,
281    },
282    StreamExportFailed {
283        status_byte: u8,
284    },
285    StreamCallbackFailed {
286        status_byte: u8,
287        description: String,
288    },
289    StreamRowMalformed {
290        message: String,
291    },
292    CapabilityMetadata {
293        metadata: LeanWorkerCapabilityMetadata,
294    },
295    CapabilityDoctor {
296        report: LeanWorkerDoctorReport,
297    },
298    CapabilityMetadataMalformed {
299        message: String,
300    },
301    CapabilityDoctorMalformed {
302        message: String,
303    },
304    JsonCommand {
305        response_json: String,
306    },
307    MetaExpr {
308        result: LeanWorkerMetaResult<LeanWorkerRendered>,
309    },
310    MetaBool {
311        result: LeanWorkerMetaResult<bool>,
312    },
313    Declaration {
314        row: Option<LeanWorkerDeclarationRow>,
315    },
316    DeclarationSearch {
317        result: LeanWorkerDeclarationSearchResult,
318    },
319    DeclarationType {
320        row: Option<LeanWorkerDeclarationType>,
321    },
322    DeclarationInspection {
323        result: LeanWorkerDeclarationInspectionResult,
324    },
325    ProofAttempt {
326        result: LeanWorkerProofAttemptResult,
327    },
328    DeclarationVerification {
329        result: LeanWorkerDeclarationVerificationResult,
330    },
331    DeclarationBulk {
332        rows: Vec<LeanWorkerDeclarationRow>,
333    },
334    ProcessModuleQuery {
335        outcome: LeanWorkerModuleQueryOutcome,
336    },
337    ProcessModuleQueryBatch {
338        outcome: LeanWorkerModuleQueryBatchOutcome,
339    },
340    ModuleSnapshotCacheCleared {
341        result: crate::types::LeanWorkerModuleSnapshotCacheClearResult,
342    },
343    RowsComplete {
344        count: u64,
345    },
346    Terminating,
347    Error {
348        code: String,
349        message: String,
350    },
351}
352
353/// Intermediate diagnostic frame emitted by the child during a request.
354#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
355#[non_exhaustive]
356pub struct Diagnostic {
357    /// Stable diagnostic code identifier.
358    pub code: String,
359    /// Bounded human-readable diagnostic message.
360    pub message: String,
361}
362
363impl Diagnostic {
364    /// Build a diagnostic frame payload.
365    #[must_use]
366    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
367        Self {
368            code: code.into(),
369            message: message.into(),
370        }
371    }
372}
373
374/// Intermediate progress frame emitted by the child during a long-running
375/// request.
376#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
377#[non_exhaustive]
378pub struct ProgressTick {
379    /// Phase name the child is reporting progress for.
380    pub phase: String,
381    /// Items completed so far in this phase.
382    pub current: u64,
383    /// Total expected items in this phase, if known.
384    pub total: Option<u64>,
385}
386
387impl ProgressTick {
388    /// Build a progress-tick frame payload.
389    #[must_use]
390    pub fn new(phase: impl Into<String>, current: u64, total: Option<u64>) -> Self {
391        Self {
392            phase: phase.into(),
393            current,
394            total,
395        }
396    }
397}
398
399/// One row in a streaming response.
400///
401/// Construction goes through [`DataRowEmitter::next`] in the child runtime;
402/// direct struct-literal construction is permitted in tests and harnesses.
403/// This struct intentionally stays exhaustive: see the module-level note on
404/// additive evolution.
405#[derive(Clone, Debug, Deserialize, Serialize)]
406pub struct DataRow {
407    /// Logical stream this row belongs to.
408    pub stream: String,
409    /// Per-stream monotonically increasing sequence number.
410    pub sequence: u64,
411    /// Opaque JSON payload (deserialised lazily by the parent).
412    pub payload: Box<RawValue>,
413}
414
415impl PartialEq for DataRow {
416    fn eq(&self, other: &Self) -> bool {
417        self.stream == other.stream && self.sequence == other.sequence && self.payload.get() == other.payload.get()
418    }
419}
420
421impl Eq for DataRow {}
422
423/// Terminal stream-completion summary returned alongside a streaming response.
424#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
425#[non_exhaustive]
426pub struct StreamSummary {
427    /// Total rows emitted across all streams.
428    pub total_rows: u64,
429    /// Per-stream row counts at completion.
430    pub per_stream_counts: BTreeMap<String, u64>,
431    /// Child-side elapsed time in microseconds.
432    pub elapsed_micros: u64,
433    /// Optional downstream-defined terminal metadata.
434    pub metadata: Option<Value>,
435}
436
437impl StreamSummary {
438    /// Build a stream-completion summary, clamping the elapsed duration into
439    /// the `u64` micros field.
440    #[must_use]
441    pub fn new(
442        total_rows: u64,
443        per_stream_counts: BTreeMap<String, u64>,
444        elapsed: Duration,
445        metadata: Option<Value>,
446    ) -> Self {
447        Self {
448            total_rows,
449            per_stream_counts,
450            elapsed_micros: elapsed.as_micros().try_into().unwrap_or(u64::MAX),
451            metadata,
452        }
453    }
454}
455
456/// Stateful emitter that assigns per-stream sequence numbers and tracks the
457/// running row count for the terminal [`StreamSummary`].
458#[derive(Debug, Default)]
459#[non_exhaustive]
460pub struct DataRowEmitter {
461    sequences: BTreeMap<String, u64>,
462    count: u64,
463}
464
465impl DataRowEmitter {
466    /// Allocate the next [`DataRow`] for `stream`, advancing the per-stream
467    /// sequence and the overall count.
468    pub fn next(&mut self, stream: impl Into<String>, payload: Box<RawValue>) -> DataRow {
469        let stream = stream.into();
470        let sequence = self.sequences.entry(stream.clone()).or_insert(0);
471        let row = DataRow {
472            stream,
473            sequence: *sequence,
474            payload,
475        };
476        *sequence = sequence.saturating_add(1);
477        self.count = self.count.saturating_add(1);
478        row
479    }
480
481    #[cfg(test)]
482    fn emit(
483        &mut self,
484        writer: &mut impl Write,
485        stream: impl Into<String>,
486        payload: &Value,
487    ) -> Result<(), ProtocolError> {
488        let row = self.next(stream, serde_json::value::to_raw_value(payload)?);
489        write_frame(writer, Message::DataRow(row), MAX_FRAME_BYTES)
490    }
491
492    /// Total rows emitted across all streams.
493    #[must_use]
494    pub fn count(&self) -> u64 {
495        self.count
496    }
497
498    /// Snapshot of per-stream row counts.
499    #[must_use]
500    pub fn per_stream_counts(&self) -> BTreeMap<String, u64> {
501        self.sequences.clone()
502    }
503}
504
505/// Final frame the child writes before it tears down on an unrecoverable
506/// failure.
507#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
508#[non_exhaustive]
509pub struct FatalExit {
510    /// Stringified `ExitStatus` of the child process.
511    pub status: String,
512    /// Captured stderr tail at fatal-exit time.
513    pub stderr: String,
514}
515
516impl FatalExit {
517    /// Build a fatal-exit frame payload.
518    #[must_use]
519    pub fn new(status: impl Into<String>, stderr: impl Into<String>) -> Self {
520        Self {
521            status: status.into(),
522            stderr: stderr.into(),
523        }
524    }
525}
526
527/// Failure modes the codec can produce while reading or writing a frame.
528#[derive(Debug)]
529#[non_exhaustive]
530pub enum ProtocolError {
531    /// Underlying I/O failure (pipe closed, partial read, etc.).
532    Io(io::Error),
533    /// JSON serialisation or deserialisation failure.
534    Json(serde_json::Error),
535    /// A frame body exceeded [`MAX_FRAME_BYTES`].
536    FrameTooLarge {
537        /// Observed frame size in bytes.
538        len: u32,
539        /// Maximum allowed frame size.
540        max: u32,
541    },
542    /// Peer's frame version did not match this binary's [`PROTOCOL_VERSION`].
543    VersionMismatch {
544        /// Version this binary expected.
545        expected: u16,
546        /// Version the peer used.
547        actual: u16,
548    },
549}
550
551impl ProtocolError {
552    /// Whether the underlying I/O error indicates the peer's pipe was closed
553    /// (`UnexpectedEof`). Used by callers to distinguish a clean fatal exit
554    /// from a true protocol failure.
555    #[must_use]
556    pub fn is_eof(&self) -> bool {
557        matches!(self, Self::Io(err) if err.kind() == io::ErrorKind::UnexpectedEof)
558    }
559}
560
561impl fmt::Display for ProtocolError {
562    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563        match self {
564            Self::Io(err) => write!(f, "worker protocol I/O failed: {err}"),
565            Self::Json(err) => write!(f, "worker protocol JSON decode failed: {err}"),
566            Self::FrameTooLarge { len, max } => {
567                write!(f, "worker protocol frame too large: {len} bytes exceeds {max}")
568            }
569            Self::VersionMismatch { expected, actual } => {
570                write!(
571                    f,
572                    "worker protocol version mismatch: expected {expected}, received {actual}"
573                )
574            }
575        }
576    }
577}
578
579impl std::error::Error for ProtocolError {}
580
581impl From<io::Error> for ProtocolError {
582    fn from(value: io::Error) -> Self {
583        Self::Io(value)
584    }
585}
586
587impl From<serde_json::Error> for ProtocolError {
588    fn from(value: serde_json::Error) -> Self {
589        Self::Json(value)
590    }
591}
592
593/// Serialise `message` as a length-delimited JSON frame to `writer`.
594///
595/// `max_frame_bytes` is the per-frame cap negotiated for this connection.
596/// Until the handshake completes, callers pass [`MAX_FRAME_BYTES`] as the
597/// default; afterwards the supervisor passes the
598/// [`Message::ConfigureFrameLimit`] value installed on the connection.
599///
600/// # Errors
601///
602/// Returns [`ProtocolError::FrameTooLarge`] if the serialised body would
603/// exceed `max_frame_bytes`, or the underlying [`ProtocolError::Io`] /
604/// [`ProtocolError::Json`] for codec failures.
605pub fn write_frame(writer: &mut impl Write, message: Message, max_frame_bytes: u32) -> Result<(), ProtocolError> {
606    let bytes = serde_json::to_vec(&Frame::new(message))?;
607    let len = u32::try_from(bytes.len()).map_err(|_| ProtocolError::FrameTooLarge {
608        len: u32::MAX,
609        max: max_frame_bytes,
610    })?;
611    if len > max_frame_bytes {
612        return Err(ProtocolError::FrameTooLarge {
613            len,
614            max: max_frame_bytes,
615        });
616    }
617    writer.write_all(&len.to_be_bytes())?;
618    writer.write_all(&bytes)?;
619    writer.flush()?;
620    Ok(())
621}
622
623/// Read one length-delimited JSON frame from `reader`.
624///
625/// `max_frame_bytes` is the per-frame cap negotiated for this connection. See
626/// [`write_frame`] for the back-compat default and post-handshake semantics.
627///
628/// # Errors
629///
630/// Returns [`ProtocolError::FrameTooLarge`] if the framed length exceeds
631/// `max_frame_bytes` (rejected before allocation),
632/// [`ProtocolError::VersionMismatch`] if the peer's version does not match
633/// [`PROTOCOL_VERSION`], or the underlying [`ProtocolError::Io`] /
634/// [`ProtocolError::Json`] for codec failures.
635pub fn read_frame(reader: &mut impl Read, max_frame_bytes: u32) -> Result<Frame, ProtocolError> {
636    let mut len_bytes = [0_u8; 4];
637    reader.read_exact(&mut len_bytes)?;
638    let len = u32::from_be_bytes(len_bytes);
639    if len > max_frame_bytes {
640        return Err(ProtocolError::FrameTooLarge {
641            len,
642            max: max_frame_bytes,
643        });
644    }
645    let mut bytes = vec![0_u8; len as usize];
646    reader.read_exact(&mut bytes)?;
647    let frame: Frame = serde_json::from_slice(&bytes)?;
648    if frame.version != PROTOCOL_VERSION {
649        return Err(ProtocolError::VersionMismatch {
650            expected: PROTOCOL_VERSION,
651            actual: frame.version,
652        });
653    }
654    Ok(frame)
655}
656
657#[cfg(test)]
658mod tests {
659    #![allow(clippy::expect_used, clippy::panic)]
660
661    use std::io::Cursor;
662
663    use serde_json::json;
664    use serde_json::value::RawValue;
665
666    use super::{
667        DataRow, DataRowEmitter, MAX_FRAME_BYTES, MAX_FRAME_BYTES_HARD_CAP, MIN_FRAME_BYTES, Message, ProtocolError,
668        Request, Response, read_frame, write_frame,
669    };
670    use crate::types::{
671        LeanWorkerDeclarationFilter, LeanWorkerDeclarationFlags, LeanWorkerDeclarationInspection,
672        LeanWorkerDeclarationInspectionFields, LeanWorkerDeclarationInspectionRequest,
673        LeanWorkerDeclarationInspectionResult, LeanWorkerDeclarationNameMatch, LeanWorkerDeclarationProofSearchFacts,
674        LeanWorkerDeclarationSearch, LeanWorkerDeclarationSearchBias, LeanWorkerDeclarationSearchFacts,
675        LeanWorkerDeclarationSearchPruning, LeanWorkerDeclarationSearchResult, LeanWorkerDeclarationSearchRow,
676        LeanWorkerDeclarationSearchScope, LeanWorkerDeclarationSearchTimings, LeanWorkerDeclarationTargetInfo,
677        LeanWorkerDeclarationVerificationFacts, LeanWorkerDeclarationVerificationRequest,
678        LeanWorkerDeclarationVerificationResult, LeanWorkerDeclarationVerificationStatus,
679        LeanWorkerDeclarationVerificationTarget, LeanWorkerDerivedWorkFacts, LeanWorkerElabFailure,
680        LeanWorkerElabOptions, LeanWorkerModuleCacheStatus, LeanWorkerModuleQuery, LeanWorkerModuleQueryBatchEnvelope,
681        LeanWorkerModuleQueryBatchItem, LeanWorkerModuleQueryBatchOutcome, LeanWorkerModuleQueryBatchResult,
682        LeanWorkerModuleQueryCacheFacts, LeanWorkerModuleQueryOutcome, LeanWorkerModuleQueryResult,
683        LeanWorkerModuleQuerySelector, LeanWorkerModuleQueryTimings, LeanWorkerModuleSourceSpan,
684        LeanWorkerOutputBudgets, LeanWorkerProofAttemptEnvelope, LeanWorkerProofAttemptRequest,
685        LeanWorkerProofAttemptResult, LeanWorkerProofAttemptRow, LeanWorkerProofAttemptStatus,
686        LeanWorkerProofCandidate, LeanWorkerProofEditTarget, LeanWorkerProofPositionSelector,
687        LeanWorkerProofPositionSummary, LeanWorkerProofStateResult, LeanWorkerRenderedInfo, LeanWorkerRendering,
688        LeanWorkerResourceExhaustedFacts, LeanWorkerSorryPolicy, LeanWorkerSourceRange, LeanWorkerTypeAtResult,
689    };
690
691    fn raw_json(value: &serde_json::Value) -> Box<RawValue> {
692        serde_json::value::to_raw_value(value).expect("test JSON converts to raw value")
693    }
694
695    fn assert_frame_round_trips(message: &Message) {
696        let mut bytes = Vec::new();
697        write_frame(&mut bytes, message.clone(), MAX_FRAME_BYTES).expect("frame writes");
698        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("frame reads");
699        assert_eq!(&frame.message, message);
700    }
701
702    fn declaration_target_info_fixture(declaration_name: &str) -> LeanWorkerDeclarationTargetInfo {
703        let span = LeanWorkerModuleSourceSpan {
704            start_line: 1,
705            start_column: 1,
706            end_line: 1,
707            end_column: 10,
708        };
709        let short_name = declaration_name.rsplit('.').next().unwrap_or(declaration_name);
710        LeanWorkerDeclarationTargetInfo {
711            short_name: short_name.to_owned(),
712            declaration_name: declaration_name.to_owned(),
713            namespace_name: declaration_name
714                .strip_suffix(&format!(".{short_name}"))
715                .unwrap_or("")
716                .to_owned(),
717            declaration_kind: "theorem".to_owned(),
718            declaration_span: span.clone(),
719            name_span: span.clone(),
720            body_span: span,
721        }
722    }
723
724    fn verification_facts_fixture(
725        candidates: Vec<LeanWorkerDeclarationTargetInfo>,
726        axioms_available: bool,
727    ) -> LeanWorkerDeclarationVerificationFacts {
728        LeanWorkerDeclarationVerificationFacts {
729            target: None,
730            diagnostics: LeanWorkerElabFailure {
731                diagnostics: Vec::new(),
732                truncated: false,
733            },
734            unresolved_goals: Vec::new(),
735            contains_sorry: false,
736            contains_admit: false,
737            contains_sorry_ax: false,
738            axioms: Vec::new(),
739            axioms_truncated: false,
740            output_truncated: false,
741            candidates,
742            axioms_available,
743        }
744    }
745
746    #[test]
747    fn data_row_round_trips_through_length_delimited_frame() {
748        let row = DataRow {
749            stream: "rows".to_owned(),
750            sequence: 7,
751            payload: raw_json(&json!({ "name": "Nat.add", "score": 3 })),
752        };
753        let mut bytes = Vec::new();
754        write_frame(&mut bytes, Message::DataRow(row.clone()), MAX_FRAME_BYTES).expect("data row writes");
755        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("data row reads");
756        assert_eq!(frame.message, Message::DataRow(row));
757    }
758
759    #[test]
760    fn data_row_emitter_assigns_per_stream_sequences() {
761        let mut emitter = DataRowEmitter::default();
762        let mut bytes = Vec::new();
763        emitter
764            .emit(&mut bytes, "rows", &json!({ "i": 0 }))
765            .expect("first row writes");
766        emitter
767            .emit(&mut bytes, "warnings", &json!({ "i": 1 }))
768            .expect("second row writes");
769        emitter
770            .emit(&mut bytes, "rows", &json!({ "i": 2 }))
771            .expect("third row writes");
772        assert_eq!(emitter.count(), 3);
773
774        let mut cursor = Cursor::new(bytes);
775        let rows = [
776            read_frame(&mut cursor, MAX_FRAME_BYTES).expect("first row reads"),
777            read_frame(&mut cursor, MAX_FRAME_BYTES).expect("second row reads"),
778            read_frame(&mut cursor, MAX_FRAME_BYTES).expect("third row reads"),
779        ];
780        assert_eq!(
781            rows.map(|frame| frame.message),
782            [
783                Message::DataRow(DataRow {
784                    stream: "rows".to_owned(),
785                    sequence: 0,
786                    payload: raw_json(&json!({ "i": 0 })),
787                }),
788                Message::DataRow(DataRow {
789                    stream: "warnings".to_owned(),
790                    sequence: 0,
791                    payload: raw_json(&json!({ "i": 1 })),
792                }),
793                Message::DataRow(DataRow {
794                    stream: "rows".to_owned(),
795                    sequence: 1,
796                    payload: raw_json(&json!({ "i": 2 })),
797                }),
798            ],
799        );
800    }
801
802    #[test]
803    fn oversized_data_row_is_rejected_before_write() {
804        let row = DataRow {
805            stream: "rows".to_owned(),
806            sequence: 0,
807            payload: raw_json(&json!({ "blob": "x".repeat(MAX_FRAME_BYTES as usize) })),
808        };
809        let mut bytes = Vec::new();
810        let err =
811            write_frame(&mut bytes, Message::DataRow(row), MAX_FRAME_BYTES).expect_err("oversized frame is rejected");
812        match err {
813            ProtocolError::FrameTooLarge { len, max } => {
814                assert!(len > max);
815                assert_eq!(max, MAX_FRAME_BYTES);
816            }
817            other @ (ProtocolError::Io(_) | ProtocolError::Json(_) | ProtocolError::VersionMismatch { .. }) => {
818                panic!("expected FrameTooLarge, got {other:?}");
819            }
820        }
821    }
822
823    #[test]
824    fn oversized_data_row_is_rejected_before_read_allocation() {
825        let mut bytes = Vec::new();
826        bytes.extend_from_slice(&(MAX_FRAME_BYTES.saturating_add(1)).to_be_bytes());
827        let err = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect_err("oversized frame is rejected");
828        match err {
829            ProtocolError::FrameTooLarge { len, max } => {
830                assert_eq!(len, MAX_FRAME_BYTES + 1);
831                assert_eq!(max, MAX_FRAME_BYTES);
832            }
833            other @ (ProtocolError::Io(_) | ProtocolError::Json(_) | ProtocolError::VersionMismatch { .. }) => {
834                panic!("expected FrameTooLarge, got {other:?}");
835            }
836        }
837    }
838
839    #[test]
840    fn larger_cap_accepts_frame_rejected_under_default() {
841        // A 2 MiB payload is rejected under MAX_FRAME_BYTES (1 MiB) but
842        // accepted when the cap is raised—proving the cap parameter is the
843        // only thing the codec consults.
844        let raised = MAX_FRAME_BYTES.saturating_mul(8);
845        let row = DataRow {
846            stream: "rows".to_owned(),
847            sequence: 0,
848            payload: raw_json(&json!({ "blob": "x".repeat(2 * MAX_FRAME_BYTES as usize) })),
849        };
850        let mut buf = Vec::new();
851        write_frame(&mut buf, Message::DataRow(row.clone()), raised).expect("oversize-under-default frame writes");
852        let frame = read_frame(&mut Cursor::new(buf), raised).expect("oversize-under-default frame reads");
853        assert_eq!(frame.message, Message::DataRow(row));
854    }
855
856    #[test]
857    fn frame_cap_bounds_constants_are_consistent() {
858        const { assert!(MIN_FRAME_BYTES <= MAX_FRAME_BYTES) };
859        const { assert!(MAX_FRAME_BYTES <= MAX_FRAME_BYTES_HARD_CAP) };
860    }
861
862    #[test]
863    fn malformed_frame_payload_is_protocol_error() {
864        let mut bytes = Vec::new();
865        bytes.extend_from_slice(&1_u32.to_be_bytes());
866        bytes.push(b'{');
867        let err = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect_err("malformed JSON is rejected");
868        match err {
869            ProtocolError::Json(_) => {}
870            other @ (ProtocolError::Io(_)
871            | ProtocolError::FrameTooLarge { .. }
872            | ProtocolError::VersionMismatch { .. }) => {
873                panic!("expected Json error, got {other:?}");
874            }
875        }
876    }
877
878    #[test]
879    fn rows_complete_response_round_trips() {
880        let mut bytes = Vec::new();
881        write_frame(
882            &mut bytes,
883            Message::Response(Response::RowsComplete { count: 2 }),
884            MAX_FRAME_BYTES,
885        )
886        .expect("rows complete writes");
887        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("rows complete reads");
888        assert_eq!(frame.message, Message::Response(Response::RowsComplete { count: 2 }));
889    }
890
891    #[test]
892    fn declaration_search_request_and_response_round_trip() {
893        let request = Message::Request(Request::SearchDeclarations {
894            search: LeanWorkerDeclarationSearch {
895                name_fragment: Some("map".to_owned()),
896                name_match: LeanWorkerDeclarationNameMatch::Suffix,
897                kind: Some("theorem".to_owned()),
898                required_constants: vec!["List.map".to_owned()],
899                conclusion_head: Some("Eq".to_owned()),
900                scope_biases: vec![LeanWorkerDeclarationSearchBias {
901                    scope: LeanWorkerDeclarationSearchScope::Namespace,
902                    prefix: "List".to_owned(),
903                    strict: false,
904                    weight: 7,
905                }],
906                limit: 3,
907                filter: LeanWorkerDeclarationFilter {
908                    include_private: false,
909                    include_generated: false,
910                    include_internal: false,
911                },
912                include_source: false,
913            },
914        });
915        let mut bytes = Vec::new();
916        write_frame(&mut bytes, request.clone(), MAX_FRAME_BYTES).expect("declaration search request writes");
917        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("declaration search request reads");
918        assert_eq!(frame.message, request);
919
920        let response = Message::Response(Response::DeclarationSearch {
921            result: LeanWorkerDeclarationSearchResult {
922                declarations: vec![LeanWorkerDeclarationSearchRow {
923                    name: "List.map_map".to_owned(),
924                    kind: "theorem".to_owned(),
925                    module: Some("Init.Data.List.Lemmas".to_owned()),
926                    source: None,
927                    match_reason: "name,kind,required_constants,conclusion_head".to_owned(),
928                    score: 127,
929                    rank: 1,
930                    flags: LeanWorkerDeclarationFlags::default(),
931                }],
932                truncated: true,
933                facts: LeanWorkerDeclarationSearchFacts {
934                    declarations_scanned: 100,
935                    after_name_filter: 10,
936                    after_kind_filter: 8,
937                    after_required_constants_filter: 4,
938                    after_conclusion_filter: 2,
939                    after_scope_filter: 2,
940                    source_lookups: 0,
941                    broad_pruning: vec![LeanWorkerDeclarationSearchPruning {
942                        stage: "limit".to_owned(),
943                        reason: "broad_search_limit".to_owned(),
944                        count: 1,
945                    }],
946                    truncated: true,
947                    timings: LeanWorkerDeclarationSearchTimings {
948                        scan_micros: 1000,
949                        rank_micros: 50,
950                        source_micros: 0,
951                    },
952                    derived_work: LeanWorkerDerivedWorkFacts::default(),
953                },
954            },
955        });
956        let mut bytes = Vec::new();
957        write_frame(&mut bytes, response.clone(), MAX_FRAME_BYTES).expect("declaration search response writes");
958        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("declaration search response reads");
959        assert_eq!(frame.message, response);
960    }
961
962    #[test]
963    fn declaration_inspection_request_and_response_round_trip() {
964        let request = Message::Request(Request::InspectDeclaration {
965            request: LeanWorkerDeclarationInspectionRequest {
966                name: "List.map_map".to_owned(),
967                fields: LeanWorkerDeclarationInspectionFields {
968                    source: true,
969                    statement: true,
970                    docstring: true,
971                    attributes: true,
972                    flags: true,
973                    rendering: LeanWorkerRendering::Pretty,
974                    proof_search: true,
975                },
976                budgets: LeanWorkerOutputBudgets {
977                    per_field_bytes: 128,
978                    total_bytes: 512,
979                },
980            },
981        });
982        let mut bytes = Vec::new();
983        write_frame(&mut bytes, request.clone(), MAX_FRAME_BYTES).expect("declaration inspection request writes");
984        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("declaration inspection request reads");
985        assert_eq!(frame.message, request);
986
987        let response = Message::Response(Response::DeclarationInspection {
988            result: LeanWorkerDeclarationInspectionResult::Found {
989                declaration: Box::new(LeanWorkerDeclarationInspection {
990                    name: "List.map_map".to_owned(),
991                    kind: "theorem".to_owned(),
992                    module: Some("Init.Data.List.Lemmas".to_owned()),
993                    source: Some(LeanWorkerSourceRange {
994                        file: "Init/Data/List/Lemmas.lean".to_owned(),
995                        start_line: 1,
996                        start_column: 1,
997                        end_line: 1,
998                        end_column: 10,
999                    }),
1000                    statement: Some(LeanWorkerRenderedInfo {
1001                        value: "forall ...".to_owned(),
1002                        truncated: true,
1003                    }),
1004                    docstring: Some(LeanWorkerRenderedInfo {
1005                        value: "doc".to_owned(),
1006                        truncated: false,
1007                    }),
1008                    attributes: vec!["simp".to_owned(), "rw".to_owned()],
1009                    proof_search: LeanWorkerDeclarationProofSearchFacts {
1010                        computed: true,
1011                        unavailable_reason: None,
1012                        is_simp: true,
1013                        is_rw_candidate: true,
1014                        is_instance: false,
1015                        is_class: false,
1016                        class_name: None,
1017                    },
1018                    flags: LeanWorkerDeclarationFlags::default(),
1019                    derived_work: LeanWorkerDerivedWorkFacts::default(),
1020                    statement_rendering: Some(LeanWorkerRendering::Pretty),
1021                }),
1022            },
1023        });
1024        let mut bytes = Vec::new();
1025        write_frame(&mut bytes, response.clone(), MAX_FRAME_BYTES).expect("declaration inspection response writes");
1026        let frame =
1027            read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("declaration inspection response reads");
1028        assert_eq!(frame.message, response);
1029
1030        let not_found = Message::Response(Response::DeclarationInspection {
1031            result: LeanWorkerDeclarationInspectionResult::NotFound {
1032                name: "Missing.name".to_owned(),
1033            },
1034        });
1035        let mut bytes = Vec::new();
1036        write_frame(&mut bytes, not_found.clone(), MAX_FRAME_BYTES)
1037            .expect("declaration inspection not-found response writes");
1038        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES)
1039            .expect("declaration inspection not-found response reads");
1040        assert_eq!(frame.message, not_found);
1041
1042        let unsupported = Message::Response(Response::DeclarationInspection {
1043            result: LeanWorkerDeclarationInspectionResult::Unsupported,
1044        });
1045        let mut bytes = Vec::new();
1046        write_frame(&mut bytes, unsupported.clone(), MAX_FRAME_BYTES)
1047            .expect("declaration inspection unsupported response writes");
1048        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES)
1049            .expect("declaration inspection unsupported response reads");
1050        assert_eq!(frame.message, unsupported);
1051    }
1052
1053    #[test]
1054    fn module_query_request_and_response_round_trip() {
1055        let request = Message::Request(Request::ProcessModuleQuery {
1056            source: "def x := 1\n#check x\n".to_owned(),
1057            query: LeanWorkerModuleQuery::TypeAt { line: 2, column: 8 },
1058            options: LeanWorkerElabOptions::default(),
1059        });
1060        let mut bytes = Vec::new();
1061        write_frame(&mut bytes, request.clone(), MAX_FRAME_BYTES).expect("module query request writes");
1062        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("module query request reads");
1063        assert_eq!(frame.message, request);
1064
1065        let response = Message::Response(Response::ProcessModuleQuery {
1066            outcome: LeanWorkerModuleQueryOutcome::Ok {
1067                imports: Vec::new(),
1068                result: LeanWorkerModuleQueryResult::TypeAt(LeanWorkerTypeAtResult::Term {
1069                    span: LeanWorkerModuleSourceSpan {
1070                        start_line: 2,
1071                        start_column: 8,
1072                        end_line: 2,
1073                        end_column: 9,
1074                    },
1075                    expr: LeanWorkerRenderedInfo {
1076                        value: "x".to_owned(),
1077                        truncated: false,
1078                    },
1079                    type_str: LeanWorkerRenderedInfo {
1080                        value: "Nat".to_owned(),
1081                        truncated: false,
1082                    },
1083                    expected_type: None,
1084                }),
1085            },
1086        });
1087        let mut bytes = Vec::new();
1088        write_frame(&mut bytes, response.clone(), MAX_FRAME_BYTES).expect("module query response writes");
1089        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("module query response reads");
1090        assert_eq!(frame.message, response);
1091
1092        let unsupported = LeanWorkerModuleQueryOutcome::Unsupported;
1093        let json = serde_json::to_value(&unsupported).expect("unsupported serializes");
1094        assert_eq!(json, json!({ "status": "unsupported" }));
1095
1096        let diagnostics = LeanWorkerModuleQueryResult::Diagnostics(LeanWorkerElabFailure {
1097            diagnostics: Vec::new(),
1098            truncated: false,
1099        });
1100        let json = serde_json::to_value(&diagnostics).expect("diagnostics serializes");
1101        assert_eq!(
1102            json,
1103            json!({
1104                "result": "diagnostics",
1105                "body": {
1106                    "diagnostics": [],
1107                    "truncated": false
1108                }
1109            })
1110        );
1111    }
1112
1113    #[test]
1114    fn module_query_batch_request_and_response_round_trip() {
1115        let request = Message::Request(Request::ProcessModuleQueryBatch {
1116            source: "theorem t : True := by\n  trivial\n".to_owned(),
1117            selectors: vec![
1118                LeanWorkerModuleQuerySelector::Diagnostics {
1119                    id: "diagnostics".to_owned(),
1120                },
1121                LeanWorkerModuleQuerySelector::ProofState {
1122                    id: "state".to_owned(),
1123                    line: 2,
1124                    column: 4,
1125                },
1126            ],
1127            budgets: LeanWorkerOutputBudgets::default(),
1128            options: LeanWorkerElabOptions::default(),
1129        });
1130        let mut bytes = Vec::new();
1131        write_frame(&mut bytes, request.clone(), MAX_FRAME_BYTES).expect("module query batch request writes");
1132        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("module query batch request reads");
1133        assert_eq!(frame.message, request);
1134
1135        let response = Message::Response(Response::ProcessModuleQueryBatch {
1136            outcome: LeanWorkerModuleQueryBatchOutcome::Ok {
1137                imports: Vec::new(),
1138                result: LeanWorkerModuleQueryBatchEnvelope {
1139                    items: vec![LeanWorkerModuleQueryBatchItem::Ok {
1140                        id: "diagnostics".to_owned(),
1141                        result: Box::new(LeanWorkerModuleQueryBatchResult::Diagnostics(LeanWorkerElabFailure {
1142                            diagnostics: Vec::new(),
1143                            truncated: false,
1144                        })),
1145                    }],
1146                    total_truncated: false,
1147                },
1148                facts: LeanWorkerModuleQueryCacheFacts {
1149                    cache_status: LeanWorkerModuleCacheStatus::Miss,
1150                    timings: LeanWorkerModuleQueryTimings::zero(),
1151                    output_bytes: 0,
1152                    cache_entry_count: Some(1),
1153                    cache_approx_bytes: Some(1024),
1154                    resource: None,
1155                },
1156            },
1157        });
1158        let mut bytes = Vec::new();
1159        write_frame(&mut bytes, response.clone(), MAX_FRAME_BYTES).expect("module query batch response writes");
1160        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("module query batch response reads");
1161        assert_eq!(frame.message, response);
1162    }
1163
1164    #[test]
1165    fn module_query_cache_facts_resource_is_additive_wire_field() {
1166        let old_json = serde_json::json!({
1167            "cache_status": "miss",
1168            "timings": {
1169                "header_import_micros": 0,
1170                "elaboration_micros": 0,
1171                "projection_micros": 0,
1172                "rendering_micros": 0
1173            },
1174            "output_bytes": 0,
1175            "cache_entry_count": null,
1176            "cache_approx_bytes": null
1177        });
1178        let old_facts: LeanWorkerModuleQueryCacheFacts =
1179            serde_json::from_value(old_json).expect("old cache facts deserialize without resource");
1180        assert!(old_facts.resource.is_none());
1181
1182        let resource = LeanWorkerResourceExhaustedFacts {
1183            cause: "worker_rss_hard_limit".to_owned(),
1184            work_entered_child: true,
1185            operation: Some("worker_process_module_query_batch".to_owned()),
1186            current_rss_kib: Some(2048),
1187            limit_kib: Some(1024),
1188            import_count: Some(1),
1189            worker_generation: Some(2),
1190            restart_reason: Some("rss_hard_limit".to_owned()),
1191            queue_wait_ms: None,
1192            duration_ms: None,
1193            cold_open_attempts: None,
1194            cold_open_admitted: None,
1195            cold_open_refusals: None,
1196            import_like_requests: Some(1),
1197            import_like_admitted: Some(1),
1198            last_import_stats: None,
1199        };
1200        let facts = LeanWorkerModuleQueryCacheFacts {
1201            resource: Some(Box::new(resource.clone())),
1202            ..LeanWorkerModuleQueryCacheFacts::uncached(0)
1203        };
1204        let round_trip: LeanWorkerModuleQueryCacheFacts =
1205            serde_json::from_value(serde_json::to_value(&facts).expect("facts serialize")).expect("facts deserialize");
1206        assert_eq!(round_trip.resource.as_deref(), Some(&resource));
1207    }
1208
1209    #[test]
1210    fn proof_attempt_request_and_response_round_trip() {
1211        let span = LeanWorkerModuleSourceSpan {
1212            start_line: 1,
1213            start_column: 22,
1214            end_line: 2,
1215            end_column: 7,
1216        };
1217        let request = Message::Request(Request::AttemptProof {
1218            request: LeanWorkerProofAttemptRequest {
1219                source: "theorem t : True := by\n  trivial\n".to_owned(),
1220                edit: LeanWorkerProofEditTarget::Declaration {
1221                    name: "t".to_owned(),
1222                    position: LeanWorkerProofPositionSelector::Default,
1223                },
1224                candidates: vec![LeanWorkerProofCandidate {
1225                    id: "rfl".to_owned(),
1226                    text: "by trivial".to_owned(),
1227                }],
1228                budgets: LeanWorkerOutputBudgets::default(),
1229            },
1230            options: LeanWorkerElabOptions::default(),
1231            progress: true,
1232        });
1233        let mut bytes = Vec::new();
1234        write_frame(&mut bytes, request.clone(), MAX_FRAME_BYTES).expect("proof attempt request writes");
1235        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("proof attempt request reads");
1236        assert_eq!(frame.message, request);
1237
1238        let response = Message::Response(Response::ProofAttempt {
1239            result: LeanWorkerProofAttemptResult::Ok {
1240                imports: Vec::new(),
1241                result: LeanWorkerProofAttemptEnvelope {
1242                    candidates: vec![LeanWorkerProofAttemptRow {
1243                        id: "rfl".to_owned(),
1244                        status: LeanWorkerProofAttemptStatus::Closed,
1245                        candidate_text: LeanWorkerRenderedInfo {
1246                            value: "rfl".to_owned(),
1247                            truncated: false,
1248                        },
1249                        diagnostics: LeanWorkerElabFailure {
1250                            diagnostics: Vec::new(),
1251                            truncated: false,
1252                        },
1253                        downstream_diagnostics: LeanWorkerElabFailure {
1254                            diagnostics: Vec::new(),
1255                            truncated: false,
1256                        },
1257                        goals: Vec::new(),
1258                        declaration: Some(LeanWorkerDeclarationTargetInfo {
1259                            short_name: "t".to_owned(),
1260                            declaration_name: "t".to_owned(),
1261                            namespace_name: String::new(),
1262                            declaration_kind: "theorem".to_owned(),
1263                            declaration_span: span.clone(),
1264                            name_span: span.clone(),
1265                            body_span: span,
1266                        }),
1267                        proof_position: Some(LeanWorkerProofPositionSummary {
1268                            index: 0,
1269                            tactic: LeanWorkerRenderedInfo {
1270                                value: "trivial".to_owned(),
1271                                truncated: false,
1272                            },
1273                        }),
1274                        output_truncated: false,
1275                    }],
1276                    candidate_limit: 8,
1277                    candidates_truncated: false,
1278                },
1279            },
1280        });
1281        let mut bytes = Vec::new();
1282        write_frame(&mut bytes, response.clone(), MAX_FRAME_BYTES).expect("proof attempt response writes");
1283        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("proof attempt response reads");
1284        assert_eq!(frame.message, response);
1285    }
1286
1287    #[test]
1288    fn proof_position_selector_tags_are_stable_and_round_trip() {
1289        let cases = [
1290            (
1291                LeanWorkerProofPositionSelector::Default,
1292                serde_json::json!({"kind": "default"}),
1293            ),
1294            (
1295                LeanWorkerProofPositionSelector::Index { index: 3 },
1296                serde_json::json!({"kind": "index", "index": 3}),
1297            ),
1298            (
1299                LeanWorkerProofPositionSelector::AfterText {
1300                    text: "intro x".to_owned(),
1301                    occurrence: Some(1),
1302                },
1303                serde_json::json!({"kind": "after_text", "text": "intro x", "occurrence": 1}),
1304            ),
1305            (
1306                LeanWorkerProofPositionSelector::Entry,
1307                serde_json::json!({"kind": "entry"}),
1308            ),
1309        ];
1310        for (selector, expected) in cases {
1311            let value = serde_json::to_value(&selector).expect("selector serializes");
1312            assert_eq!(value, expected, "selector tag must be stable: {selector:?}");
1313            let parsed: LeanWorkerProofPositionSelector =
1314                serde_json::from_value(expected).expect("selector deserializes");
1315            assert_eq!(parsed, selector, "selector must round-trip through JSON");
1316        }
1317    }
1318
1319    #[test]
1320    fn declaration_verification_request_and_response_round_trip() {
1321        let request = Message::Request(Request::VerifyDeclaration {
1322            request: LeanWorkerDeclarationVerificationRequest {
1323                source: "theorem t : True := by\n  trivial\n".to_owned(),
1324                target: LeanWorkerDeclarationVerificationTarget::Name { name: "t".to_owned() },
1325                sorry_policy: LeanWorkerSorryPolicy::Deny,
1326                report_axioms: true,
1327                budgets: LeanWorkerOutputBudgets::default(),
1328            },
1329            options: LeanWorkerElabOptions::default(),
1330            progress: false,
1331        });
1332        let mut bytes = Vec::new();
1333        write_frame(&mut bytes, request.clone(), MAX_FRAME_BYTES).expect("verification request writes");
1334        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("verification request reads");
1335        assert_eq!(frame.message, request);
1336
1337        let response = Message::Response(Response::DeclarationVerification {
1338            result: LeanWorkerDeclarationVerificationResult::Ok {
1339                verification_status: LeanWorkerDeclarationVerificationStatus::Accepted,
1340                facts: Box::new(LeanWorkerDeclarationVerificationFacts {
1341                    target: None,
1342                    diagnostics: LeanWorkerElabFailure {
1343                        diagnostics: Vec::new(),
1344                        truncated: false,
1345                    },
1346                    unresolved_goals: Vec::new(),
1347                    contains_sorry: false,
1348                    contains_admit: false,
1349                    contains_sorry_ax: false,
1350                    axioms: vec!["propext".to_owned(), "Classical.choice".to_owned()],
1351                    axioms_truncated: false,
1352                    output_truncated: false,
1353                    candidates: Vec::new(),
1354                    axioms_available: true,
1355                }),
1356                imports: Vec::new(),
1357            },
1358        });
1359        let mut bytes = Vec::new();
1360        write_frame(&mut bytes, response.clone(), MAX_FRAME_BYTES).expect("verification response writes");
1361        let frame = read_frame(&mut Cursor::new(bytes), MAX_FRAME_BYTES).expect("verification response reads");
1362        assert_eq!(frame.message, response);
1363    }
1364
1365    #[test]
1366    fn verification_needs_build_and_ambiguous_round_trip() {
1367        // NeedsBuild verdict: the enclosing MissingImports outcome names the
1368        // unbuilt modules; the status is the typed resolution verdict.
1369        let needs_build = Message::Response(Response::DeclarationVerification {
1370            result: LeanWorkerDeclarationVerificationResult::MissingImports {
1371                verification_status: LeanWorkerDeclarationVerificationStatus::NeedsBuild,
1372                facts: Box::new(verification_facts_fixture(Vec::new(), false)),
1373                imports: vec!["Mathlib.Tactic".to_owned()],
1374                missing: vec!["Mathlib.Unbuilt.Dep".to_owned()],
1375            },
1376        });
1377        assert_frame_round_trips(&needs_build);
1378
1379        // Ambiguous verdict carries the competing declarations.
1380        let ambiguous = Message::Response(Response::DeclarationVerification {
1381            result: LeanWorkerDeclarationVerificationResult::Ok {
1382                verification_status: LeanWorkerDeclarationVerificationStatus::Ambiguous,
1383                facts: Box::new(verification_facts_fixture(
1384                    vec![
1385                        declaration_target_info_fixture("A.foo"),
1386                        declaration_target_info_fixture("B.foo"),
1387                    ],
1388                    false,
1389                )),
1390                imports: Vec::new(),
1391            },
1392        });
1393        assert_frame_round_trips(&ambiguous);
1394    }
1395
1396    #[test]
1397    fn proof_state_ambiguous_and_needs_build_round_trip() {
1398        let ambiguous = Message::Response(Response::ProcessModuleQueryBatch {
1399            outcome: LeanWorkerModuleQueryBatchOutcome::Ok {
1400                result: LeanWorkerModuleQueryBatchEnvelope {
1401                    items: vec![LeanWorkerModuleQueryBatchItem::Ok {
1402                        id: "state".to_owned(),
1403                        result: Box::new(LeanWorkerModuleQueryBatchResult::ProofState(
1404                            LeanWorkerProofStateResult::Ambiguous {
1405                                candidates: vec![
1406                                    declaration_target_info_fixture("A.foo"),
1407                                    declaration_target_info_fixture("B.foo"),
1408                                ],
1409                            },
1410                        )),
1411                    }],
1412                    total_truncated: false,
1413                },
1414                imports: Vec::new(),
1415                facts: LeanWorkerModuleQueryCacheFacts::uncached(0),
1416            },
1417        });
1418        assert_frame_round_trips(&ambiguous);
1419
1420        let needs_build = Message::Response(Response::ProcessModuleQueryBatch {
1421            outcome: LeanWorkerModuleQueryBatchOutcome::Ok {
1422                result: LeanWorkerModuleQueryBatchEnvelope {
1423                    items: vec![LeanWorkerModuleQueryBatchItem::Ok {
1424                        id: "state".to_owned(),
1425                        result: Box::new(LeanWorkerModuleQueryBatchResult::ProofState(
1426                            LeanWorkerProofStateResult::NeedsBuild {
1427                                missing: vec!["Mathlib.Unbuilt.Dep".to_owned()],
1428                            },
1429                        )),
1430                    }],
1431                    total_truncated: false,
1432                },
1433                imports: Vec::new(),
1434                facts: LeanWorkerModuleQueryCacheFacts::uncached(0),
1435            },
1436        });
1437        assert_frame_round_trips(&needs_build);
1438    }
1439
1440    #[test]
1441    fn inspection_fields_default_rendering_is_pretty() {
1442        // A request serialized without an explicit `rendering` deserializes to
1443        // Pretty (the `#[serde(default)]`), so older callers get the useful
1444        // notation-aware form by default.
1445        let json = serde_json::json!({
1446            "source": true,
1447            "statement": true,
1448            "docstring": false,
1449            "attributes": false,
1450            "flags": false,
1451        });
1452        let fields: LeanWorkerDeclarationInspectionFields =
1453            serde_json::from_value(json).expect("fields without rendering deserialize");
1454        assert_eq!(fields.rendering, LeanWorkerRendering::Pretty);
1455        assert!(!fields.proof_search);
1456    }
1457}