Skip to main content

socket_patch_cli/
json_envelope.rs

1//! Unified JSON output envelope shared across every subcommand.
2//!
3//! Every `--json` invocation of socket-patch (whether `scan`, `apply`,
4//! `get`, `list`, `gc`/`repair`, `remove`, or `rollback`) emits the same
5//! top-level shape:
6//!
7//! ```json
8//! {
9//!   "command":  "scan" | "apply" | "get" | ...,
10//!   "status":   "success" | "partialFailure" | "error" | "noManifest" | ...,
11//!   "dryRun":   false,
12//!   "events":   [ { "action": "...", "purl": "...", ... }, ... ],
13//!   "summary":  { "applied": 0, "downloaded": 0, ... }
14//!   // "error":  { "code": ..., "message": ... }  — present only on failure
15//! }
16//! ```
17//!
18//! The `events` array is the load-bearing payload — each entry describes
19//! one observable thing that happened during the run (a patch was
20//! downloaded, applied, skipped, etc.). A downstream consumer (PR-comment
21//! bot, dashboard, log shipper) only needs to learn this single vocabulary
22//! to interpret output from every subcommand.
23//!
24//! See `CLI_CONTRACT.md` for the per-subcommand action matrix and example
25//! `jq` recipes.
26
27use serde::Serialize;
28
29pub use socket_patch_core::patch::sidecars::{
30    SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord,
31    SidecarSeverity,
32};
33
34/// Top-level JSON envelope emitted by every `--json` invocation.
35#[derive(Debug, Clone, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Envelope {
38    /// Which subcommand produced this output. Lets a generic consumer
39    /// (one that doesn't know which subcommand it's piping) route on it.
40    pub command: Command,
41    /// High-level success/failure summary. Use `Status::PartialFailure`
42    /// when at least one event has `action = Failed` but the run as a
43    /// whole completed.
44    pub status: Status,
45    /// True if the command was a preview (`--dry-run`, `--prune-dry-run`,
46    /// etc.). When true, `events` describe what *would* happen — no disk
47    /// state was modified.
48    pub dry_run: bool,
49    /// Per-patch (and per-artifact) observations from the run. Ordering
50    /// is best-effort: events appear in the order the engine produced
51    /// them, but downstream consumers should not rely on it.
52    pub events: Vec<PatchEvent>,
53    /// Aggregate counts derived from `events`. Pre-computed so consumers
54    /// don't need to re-walk the array.
55    pub summary: Summary,
56    /// Set when the command itself failed before producing meaningful
57    /// events (manifest unreadable, network unreachable in non-offline
58    /// mode, etc.). Implies `events` is empty.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub error: Option<EnvelopeError>,
61    /// Per-package sidecar fixup records. Each entry describes what
62    /// the post-apply integrity fixup did for one package — rewriting
63    /// `.cargo-checksum.json`, deleting `.nupkg.metadata`, surfacing
64    /// an advisory for PyPI / gem / Go, etc.
65    ///
66    /// Top-level (not per-event) so consumers can iterate sidecar
67    /// outcomes directly with `jq '.sidecars[]'`. Records carry
68    /// `purl` so a consumer that needs the matching apply event can
69    /// JOIN against `events[]`.
70    ///
71    /// Empty (and omitted from JSON via `skip_serializing_if`) for
72    /// commands that don't produce sidecar work — `rollback`,
73    /// `repair`, `list`, etc. — and for apply runs against ecosystems
74    /// with no sidecar contract (e.g. npm).
75    #[serde(skip_serializing_if = "Vec::is_empty")]
76    pub sidecars: Vec<SidecarRecord>,
77    /// Present only when `--vex <path>` was passed to `apply`/`scan` and
78    /// an OpenVEX document was successfully generated as a side-effect of
79    /// the run. Describes where it landed and how many statements it
80    /// carries. A *failed* embedded VEX generation surfaces via `error`
81    /// (and flips the exit code), not here.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub vex: Option<VexSummary>,
84}
85
86/// Summary of an OpenVEX document emitted as a side-effect of an
87/// `apply`/`scan` run via `--vex`. The full document is written to
88/// `path`; this is just the pointer + headline count for JSON consumers.
89#[derive(Debug, Clone, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct VexSummary {
92    /// Filesystem path the OpenVEX document was written to.
93    pub path: String,
94    /// Number of OpenVEX statements in the document.
95    pub statements: usize,
96    /// Document format tag, e.g. `"openvex-0.2.0"`.
97    pub format: String,
98}
99
100impl Envelope {
101    /// Build a fresh envelope. `summary` starts at zero — callers are
102    /// expected to push events with `Envelope::record` (or update fields
103    /// directly) so summary stays consistent with the event list.
104    pub fn new(command: Command) -> Self {
105        Self {
106            command,
107            status: Status::Success,
108            dry_run: false,
109            events: Vec::new(),
110            summary: Summary::default(),
111            error: None,
112            sidecars: Vec::new(),
113            vex: None,
114        }
115    }
116
117    /// Append an event and bump the matching summary counter. Centralizes
118    /// the "events list must agree with summary counts" invariant so per-
119    /// command code can't drift.
120    ///
121    /// Recording a `Failed` event also marks the run as a partial failure
122    /// (unless it's already a hard `Error`), enforcing the `status`
123    /// invariant documented on [`Envelope::status`] here rather than
124    /// relying on every command to remember a follow-up
125    /// `mark_partial_failure` call. A run can never end up reporting
126    /// `Success` while carrying a `Failed` event.
127    pub fn record(&mut self, event: PatchEvent) {
128        self.summary.bump(event.action);
129        if matches!(event.action, PatchAction::Failed) {
130            self.mark_partial_failure();
131        }
132        self.events.push(event);
133    }
134
135    /// Append a sidecar fixup record. Called once per `ApplyResult`
136    /// whose `sidecar` field is `Some`. Order matches the order
137    /// `apply` processed packages, which is best-effort.
138    pub fn record_sidecar(&mut self, sidecar: SidecarRecord) {
139        self.sidecars.push(sidecar);
140    }
141
142    /// Mark the run as a partial failure. Idempotent.
143    pub fn mark_partial_failure(&mut self) {
144        if !matches!(self.status, Status::Error) {
145            self.status = Status::PartialFailure;
146        }
147    }
148
149    /// Mark the run as a top-level error (replaces any prior status).
150    pub fn mark_error(&mut self, error: EnvelopeError) {
151        self.status = Status::Error;
152        self.error = Some(error);
153    }
154
155    /// Serialize as pretty JSON for printing.
156    pub fn to_pretty_json(&self) -> String {
157        serde_json::to_string_pretty(self).expect("envelope serialize")
158    }
159}
160
161/// One observable thing that happened during a run.
162#[derive(Debug, Clone, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct PatchEvent {
165    /// What happened. See [`PatchAction`] for the full vocabulary.
166    pub action: PatchAction,
167    /// The package PURL this event is about, when applicable. Always set
168    /// for patch-level events; omitted for artifact-level events that
169    /// don't trace to a specific package.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub purl: Option<String>,
172    /// The patch UUID, when known. Always set when the event is about a
173    /// specific patch record; omitted for cleanup events that affect
174    /// many patches at once.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub uuid: Option<String>,
177    /// The UUID this patch replaced. Set only on `Updated` events so a
178    /// consumer can diff a manifest update — the new UUID lives in
179    /// `uuid`, the one it overwrote here. Omitted for every other action.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub old_uuid: Option<String>,
182    /// Files touched by an `Applied` / `Verified` / `Removed` event.
183    /// Empty for actions that don't operate on files (e.g. `Downloaded`).
184    #[serde(skip_serializing_if = "Vec::is_empty")]
185    pub files: Vec<PatchEventFile>,
186    /// Human-readable explanation for `Skipped` or `Failed` events.
187    /// Machine consumers should prefer `error_code` for routing decisions.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub reason: Option<String>,
190    /// Stable, lowercase, snake_case reason tag for programmatic routing.
191    /// Examples: `already_patched`, `package_not_installed`,
192    /// `hash_mismatch`, `no_local_source`, `paid_required`.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub error_code: Option<String>,
195    /// Underlying error message for `Failed` events.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub error: Option<String>,
198    /// Command-specific additional fields. Consumers MUST NOT depend on
199    /// the shape of this object — different subcommands attach different
200    /// keys here. Used today for `list` (vulnerabilities, license, tier,
201    /// description) and `scan` (discovered metadata not covered by the
202    /// other event fields).
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub details: Option<serde_json::Value>,
205}
206
207impl PatchEvent {
208    /// Construct an event with only the required `action` and `purl`.
209    /// Use the `with_*` builders to attach optional fields.
210    pub fn new(action: PatchAction, purl: impl Into<String>) -> Self {
211        Self {
212            action,
213            purl: Some(purl.into()),
214            uuid: None,
215            old_uuid: None,
216            files: Vec::new(),
217            reason: None,
218            error_code: None,
219            error: None,
220            details: None,
221        }
222    }
223
224    /// Construct an event that isn't scoped to a single package (e.g. a
225    /// repair run that swept orphan blobs).
226    pub fn artifact(action: PatchAction) -> Self {
227        Self {
228            action,
229            purl: None,
230            uuid: None,
231            old_uuid: None,
232            files: Vec::new(),
233            reason: None,
234            error_code: None,
235            error: None,
236            details: None,
237        }
238    }
239
240    pub fn with_uuid(mut self, uuid: impl Into<String>) -> Self {
241        self.uuid = Some(uuid.into());
242        self
243    }
244
245    /// Attach the UUID this event's patch replaced. Use on `Updated`
246    /// events so consumers can diff against the prior manifest entry;
247    /// serializes as `oldUuid`.
248    pub fn with_old_uuid(mut self, old_uuid: impl Into<String>) -> Self {
249        self.old_uuid = Some(old_uuid.into());
250        self
251    }
252
253    pub fn with_files(mut self, files: Vec<PatchEventFile>) -> Self {
254        self.files = files;
255        self
256    }
257
258    pub fn with_reason(
259        mut self,
260        code: impl Into<String>,
261        message: impl Into<String>,
262    ) -> Self {
263        self.error_code = Some(code.into());
264        self.reason = Some(message.into());
265        self
266    }
267
268    pub fn with_error(
269        mut self,
270        code: impl Into<String>,
271        message: impl Into<String>,
272    ) -> Self {
273        self.error_code = Some(code.into());
274        self.error = Some(message.into());
275        self
276    }
277
278    /// Attach command-specific extra fields. See [`PatchEvent::details`]
279    /// for the contract — consumers should not depend on the shape.
280    pub fn with_details(mut self, details: serde_json::Value) -> Self {
281        self.details = Some(details);
282        self
283    }
284}
285
286/// One file referenced by a patch event.
287#[derive(Debug, Clone, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub struct PatchEventFile {
290    /// Path relative to the package directory (e.g. `package/index.js`).
291    pub path: String,
292    /// True if the file's content was verified to match the expected
293    /// hash. For an `Applied` event this means post-write verification
294    /// succeeded; for `Verified` (dry-run) it means pre-write hashes
295    /// matched expectation.
296    pub verified: bool,
297    /// Which strategy produced the patched bytes — only set for `Applied`
298    /// events. One of `package`, `diff`, `blob`.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub applied_via: Option<AppliedVia>,
301}
302
303/// What kind of thing happened to a patch.
304///
305/// Serializes to camelCase strings — e.g. `Applied` → `"applied"`,
306/// `Downloaded` → `"downloaded"` (a hypothetical multi-word variant would
307/// lower-camel, e.g. `FooBar` → `"fooBar"`). The full vocabulary is part of
308/// the CLI contract; new variants are MINOR-safe but renames are MAJOR.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
310#[serde(rename_all = "camelCase")]
311pub enum PatchAction {
312    /// `scan`: a patch exists upstream for this package, but no action
313    /// taken yet (no `--apply` / `--sync`).
314    Discovered,
315    /// `get` / `scan --apply` / `apply` (online): patch bytes were
316    /// fetched from the registry.
317    Downloaded,
318    /// `apply` / `scan --sync`: patch was applied to disk. `files`
319    /// enumerates which files changed.
320    Applied,
321    /// `apply` / `scan --sync`: patch replaced an older patch (the
322    /// manifest already had a different UUID for this PURL). `oldUuid`
323    /// carries the previous UUID.
324    Updated,
325    /// `apply` / `scan` / `get`: the patch was a no-op — already
326    /// applied, not in scope, or filtered out. `errorCode` carries the
327    /// reason tag.
328    Skipped,
329    /// Any command: an attempt failed. `errorCode` is the routing tag,
330    /// `error` is the human message.
331    Failed,
332    /// `gc` / `repair` / `remove` / `rollback`: data was removed from
333    /// `.socket/` (or from disk in the rollback case).
334    Removed,
335    /// `apply --dry-run` / `scan --dry-run`: patch *would* apply
336    /// cleanly. `files` lists what would change.
337    Verified,
338}
339
340/// Patch-source strategy used to apply a file. Mirrors the existing
341/// `socket_patch_core::patch::apply::AppliedVia` enum, but lives here so
342/// the JSON layer doesn't depend on core internals.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
344#[serde(rename_all = "camelCase")]
345pub enum AppliedVia {
346    Package,
347    Diff,
348    Blob,
349}
350
351impl AppliedVia {
352    pub fn from_core(via: socket_patch_core::patch::apply::AppliedVia) -> Self {
353        use socket_patch_core::patch::apply::AppliedVia as Core;
354        match via {
355            Core::Package => AppliedVia::Package,
356            Core::Diff => AppliedVia::Diff,
357            Core::Blob => AppliedVia::Blob,
358        }
359    }
360}
361
362/// Which subcommand produced the envelope. Serializes lowercase.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
364#[serde(rename_all = "camelCase")]
365pub enum Command {
366    Apply,
367    Rollback,
368    Get,
369    Scan,
370    List,
371    Remove,
372    Repair,
373    Setup,
374    Unlock,
375    Vex,
376}
377
378
379/// Top-level status. Serializes camelCase.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
381#[serde(rename_all = "camelCase")]
382pub enum Status {
383    Success,
384    PartialFailure,
385    Error,
386    /// Special case for `apply`: the manifest doesn't exist yet, so
387    /// there's nothing to apply. Distinct from `Success` because some
388    /// consumers want to early-exit on this state.
389    NoManifest,
390    /// `get` / `scan`: the requested patch requires a paid plan but the
391    /// caller's API token isn't entitled. Distinct from `Error` so PR
392    /// bots can post a "upgrade your plan" comment instead of failing.
393    PaidRequired,
394    /// `remove` / `rollback`: the patch identifier didn't resolve to
395    /// anything in the local manifest.
396    NotFound,
397}
398
399/// Pre-aggregated counts across all events in this envelope. Field names
400/// match `PatchAction` variants for clarity.
401#[derive(Debug, Clone, Default, Serialize)]
402#[serde(rename_all = "camelCase")]
403pub struct Summary {
404    pub discovered: u32,
405    pub downloaded: u32,
406    pub applied: u32,
407    pub updated: u32,
408    pub skipped: u32,
409    pub failed: u32,
410    pub removed: u32,
411    pub verified: u32,
412}
413
414impl Summary {
415    fn bump(&mut self, action: PatchAction) {
416        match action {
417            PatchAction::Discovered => self.discovered += 1,
418            PatchAction::Downloaded => self.downloaded += 1,
419            PatchAction::Applied => self.applied += 1,
420            PatchAction::Updated => self.updated += 1,
421            PatchAction::Skipped => self.skipped += 1,
422            PatchAction::Failed => self.failed += 1,
423            PatchAction::Removed => self.removed += 1,
424            PatchAction::Verified => self.verified += 1,
425        }
426    }
427}
428
429/// Top-level error payload set when the command failed before producing
430/// patch events.
431#[derive(Debug, Clone, Serialize)]
432#[serde(rename_all = "camelCase")]
433pub struct EnvelopeError {
434    /// Routing tag — examples: `manifest_unreadable`, `network_error`,
435    /// `not_found`, `paid_required`.
436    pub code: String,
437    /// Human-readable message.
438    pub message: String,
439}
440
441impl EnvelopeError {
442    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
443        Self {
444            code: code.into(),
445            message: message.into(),
446        }
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Tests — pin the JSON serialization shape that downstream consumers see.
452// ---------------------------------------------------------------------------
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn action_tags_round_trip() {
460        // Each variant's serde representation must match the
461        // documented snake_case tag.
462        for (action, tag) in [
463            (PatchAction::Discovered, "discovered"),
464            (PatchAction::Downloaded, "downloaded"),
465            (PatchAction::Applied, "applied"),
466            (PatchAction::Updated, "updated"),
467            (PatchAction::Skipped, "skipped"),
468            (PatchAction::Failed, "failed"),
469            (PatchAction::Removed, "removed"),
470            (PatchAction::Verified, "verified"),
471        ] {
472            let serialized = serde_json::to_string(&action).unwrap();
473            assert_eq!(serialized, format!("\"{tag}\""));
474        }
475    }
476
477    #[test]
478    fn empty_envelope_has_stable_shape() {
479        let env = Envelope::new(Command::Scan);
480        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
481        let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(|s| s.as_str()).collect();
482        keys.sort();
483        // `error` is skipped when None, so it shouldn't appear.
484        assert_eq!(keys, vec!["command", "dryRun", "events", "status", "summary"]);
485        assert_eq!(v["command"], "scan");
486        assert_eq!(v["status"], "success");
487        assert_eq!(v["dryRun"], false);
488        assert_eq!(v["events"].as_array().unwrap().len(), 0);
489    }
490
491    #[test]
492    fn record_keeps_summary_in_sync() {
493        let mut env = Envelope::new(Command::Apply);
494        env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
495        env.record(PatchEvent::new(PatchAction::Downloaded, "pkg:npm/foo@1.0.0"));
496        env.record(
497            PatchEvent::new(PatchAction::Skipped, "pkg:npm/bar@2.0.0")
498                .with_reason("already_patched", "Files match afterHash"),
499        );
500
501        assert_eq!(env.summary.applied, 1);
502        assert_eq!(env.summary.downloaded, 1);
503        assert_eq!(env.summary.skipped, 1);
504        assert_eq!(env.events.len(), 3);
505    }
506
507    #[test]
508    fn recording_failed_event_marks_partial_failure() {
509        // The `status` invariant — "PartialFailure when any event has
510        // action = Failed" — must be enforced by `record` itself, not
511        // left to each command to remember. Otherwise a Success envelope
512        // can carry a `failed` event (and a non-zero `summary.failed`).
513        let mut env = Envelope::new(Command::Apply);
514        env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
515        assert_eq!(env.status, Status::Success);
516        env.record(
517            PatchEvent::new(PatchAction::Failed, "pkg:npm/bar@2.0.0")
518                .with_error("apply_failed", "boom"),
519        );
520        assert_eq!(env.status, Status::PartialFailure);
521        assert_eq!(env.summary.failed, 1);
522    }
523
524    #[test]
525    fn recording_failed_event_does_not_demote_hard_error() {
526        // A prior hard error outranks the per-event partial failure that
527        // `record` raises — recording a Failed event must not downgrade
528        // Error to PartialFailure regardless of ordering.
529        let mut env = Envelope::new(Command::Apply);
530        env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
531        env.record(
532            PatchEvent::new(PatchAction::Failed, "pkg:npm/bar@2.0.0")
533                .with_error("apply_failed", "boom"),
534        );
535        assert_eq!(env.status, Status::Error);
536    }
537
538    #[test]
539    fn updated_event_carries_old_uuid() {
540        // The CLI contract promises `oldUuid` on `updated` events. The
541        // new UUID lives in `uuid`; the replaced one in `oldUuid`.
542        let event = PatchEvent::new(PatchAction::Updated, "pkg:npm/foo@1.0.0")
543            .with_uuid("uuid-new")
544            .with_old_uuid("uuid-old");
545        let v: serde_json::Value =
546            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
547        assert_eq!(v["action"], "updated");
548        assert_eq!(v["uuid"], "uuid-new");
549        assert_eq!(v["oldUuid"], "uuid-old");
550    }
551
552    #[test]
553    fn old_uuid_omitted_when_unset() {
554        // Non-Updated events must not leak an `oldUuid` key.
555        let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0");
556        let v: serde_json::Value =
557            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
558        assert!(!v.as_object().unwrap().contains_key("oldUuid"));
559    }
560
561    #[test]
562    fn skipped_event_omits_uuid_and_files() {
563        let event = PatchEvent::new(PatchAction::Skipped, "pkg:npm/foo@1.0.0")
564            .with_reason("package_not_installed", "no matching package on disk");
565        let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
566        let obj = v.as_object().unwrap();
567        assert!(!obj.contains_key("uuid"));
568        assert!(!obj.contains_key("files"));
569        assert!(!obj.contains_key("oldUuid"));
570        assert!(!obj.contains_key("error"));
571        assert_eq!(obj.get("errorCode").and_then(|v| v.as_str()), Some("package_not_installed"));
572        assert_eq!(obj.get("reason").and_then(|v| v.as_str()), Some("no matching package on disk"));
573    }
574
575    #[test]
576    fn applied_event_with_files_includes_applied_via() {
577        let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0")
578            .with_uuid("uuid-2222")
579            .with_files(vec![
580                PatchEventFile {
581                    path: "package/index.js".into(),
582                    verified: true,
583                    applied_via: Some(AppliedVia::Diff),
584                },
585                PatchEventFile {
586                    path: "package/lib/util.js".into(),
587                    verified: true,
588                    applied_via: Some(AppliedVia::Blob),
589                },
590            ]);
591        let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
592        let files = v["files"].as_array().unwrap();
593        assert_eq!(files.len(), 2);
594        assert_eq!(files[0]["path"], "package/index.js");
595        assert_eq!(files[0]["verified"], true);
596        assert_eq!(files[0]["appliedVia"], "diff");
597        assert_eq!(files[1]["appliedVia"], "blob");
598    }
599
600    #[test]
601    fn mark_partial_failure_does_not_clobber_error() {
602        let mut env = Envelope::new(Command::Apply);
603        env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
604        env.mark_partial_failure();
605        // mark_error wins — we don't want a sequence of marks to demote
606        // a hard error to a partial failure.
607        assert_eq!(env.status, Status::Error);
608    }
609
610    #[test]
611    fn top_level_error_serializes_inline() {
612        let mut env = Envelope::new(Command::Get);
613        env.mark_error(EnvelopeError::new("paid_required", "Patch requires paid plan"));
614        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
615        assert_eq!(v["status"], "error");
616        assert_eq!(v["error"]["code"], "paid_required");
617        assert_eq!(v["error"]["message"], "Patch requires paid plan");
618    }
619
620    #[test]
621    fn status_serializes_camel_case() {
622        // PartialFailure is the high-traffic one — confirm camelCase.
623        let mut env = Envelope::new(Command::Apply);
624        env.mark_partial_failure();
625        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
626        assert_eq!(v["status"], "partialFailure");
627    }
628
629    #[test]
630    fn artifact_event_omits_purl() {
631        // GC sweep events aren't scoped to a single PURL.
632        let event = PatchEvent::artifact(PatchAction::Removed)
633            .with_reason("orphan_blob", "Blob not referenced by any manifest entry");
634        let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
635        let obj = v.as_object().unwrap();
636        assert!(!obj.contains_key("purl"));
637        assert_eq!(obj["action"], "removed");
638        assert_eq!(obj["errorCode"], "orphan_blob");
639    }
640}