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":    null
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}
78
79impl Envelope {
80    /// Build a fresh envelope. `summary` starts at zero — callers are
81    /// expected to push events with `Envelope::record` (or update fields
82    /// directly) so summary stays consistent with the event list.
83    pub fn new(command: Command) -> Self {
84        Self {
85            command,
86            status: Status::Success,
87            dry_run: false,
88            events: Vec::new(),
89            summary: Summary::default(),
90            error: None,
91            sidecars: Vec::new(),
92        }
93    }
94
95    /// Append an event and bump the matching summary counter. Centralizes
96    /// the "events list must agree with summary counts" invariant so per-
97    /// command code can't drift.
98    pub fn record(&mut self, event: PatchEvent) {
99        self.summary.bump(event.action);
100        self.events.push(event);
101    }
102
103    /// Append a sidecar fixup record. Called once per `ApplyResult`
104    /// whose `sidecar` field is `Some`. Order matches the order
105    /// `apply` processed packages, which is best-effort.
106    pub fn record_sidecar(&mut self, sidecar: SidecarRecord) {
107        self.sidecars.push(sidecar);
108    }
109
110    /// Mark the run as a partial failure. Idempotent.
111    pub fn mark_partial_failure(&mut self) {
112        if !matches!(self.status, Status::Error) {
113            self.status = Status::PartialFailure;
114        }
115    }
116
117    /// Mark the run as a top-level error (replaces any prior status).
118    pub fn mark_error(&mut self, error: EnvelopeError) {
119        self.status = Status::Error;
120        self.error = Some(error);
121    }
122
123    /// Serialize as pretty JSON for printing.
124    pub fn to_pretty_json(&self) -> String {
125        serde_json::to_string_pretty(self).expect("envelope serialize")
126    }
127}
128
129/// One observable thing that happened during a run.
130#[derive(Debug, Clone, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct PatchEvent {
133    /// What happened. See [`PatchAction`] for the full vocabulary.
134    pub action: PatchAction,
135    /// The package PURL this event is about, when applicable. Always set
136    /// for patch-level events; omitted for artifact-level events that
137    /// don't trace to a specific package.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub purl: Option<String>,
140    /// The patch UUID, when known. Always set when the event is about a
141    /// specific patch record; omitted for cleanup events that affect
142    /// many patches at once.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub uuid: Option<String>,
145    /// Files touched by an `Applied` / `Verified` / `Removed` event.
146    /// Empty for actions that don't operate on files (e.g. `Downloaded`).
147    #[serde(skip_serializing_if = "Vec::is_empty")]
148    pub files: Vec<PatchEventFile>,
149    /// Human-readable explanation for `Skipped` or `Failed` events.
150    /// Machine consumers should prefer `error_code` for routing decisions.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub reason: Option<String>,
153    /// Stable, lowercase, snake_case reason tag for programmatic routing.
154    /// Examples: `already_patched`, `package_not_installed`,
155    /// `hash_mismatch`, `no_local_source`, `paid_required`.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub error_code: Option<String>,
158    /// Underlying error message for `Failed` events.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub error: Option<String>,
161    /// Command-specific additional fields. Consumers MUST NOT depend on
162    /// the shape of this object — different subcommands attach different
163    /// keys here. Used today for `list` (vulnerabilities, license, tier,
164    /// description) and `scan` (discovered metadata not covered by the
165    /// other event fields).
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub details: Option<serde_json::Value>,
168}
169
170impl PatchEvent {
171    /// Construct an event with only the required `action` and `purl`.
172    /// Use the `with_*` builders to attach optional fields.
173    pub fn new(action: PatchAction, purl: impl Into<String>) -> Self {
174        Self {
175            action,
176            purl: Some(purl.into()),
177            uuid: None,
178            files: Vec::new(),
179            reason: None,
180            error_code: None,
181            error: None,
182            details: None,
183        }
184    }
185
186    /// Construct an event that isn't scoped to a single package (e.g. a
187    /// repair run that swept orphan blobs).
188    pub fn artifact(action: PatchAction) -> Self {
189        Self {
190            action,
191            purl: None,
192            uuid: None,
193            files: Vec::new(),
194            reason: None,
195            error_code: None,
196            error: None,
197            details: None,
198        }
199    }
200
201    pub fn with_uuid(mut self, uuid: impl Into<String>) -> Self {
202        self.uuid = Some(uuid.into());
203        self
204    }
205
206    pub fn with_files(mut self, files: Vec<PatchEventFile>) -> Self {
207        self.files = files;
208        self
209    }
210
211    pub fn with_reason(
212        mut self,
213        code: impl Into<String>,
214        message: impl Into<String>,
215    ) -> Self {
216        self.error_code = Some(code.into());
217        self.reason = Some(message.into());
218        self
219    }
220
221    pub fn with_error(
222        mut self,
223        code: impl Into<String>,
224        message: impl Into<String>,
225    ) -> Self {
226        self.error_code = Some(code.into());
227        self.error = Some(message.into());
228        self
229    }
230
231    /// Attach command-specific extra fields. See [`PatchEvent::details`]
232    /// for the contract — consumers should not depend on the shape.
233    pub fn with_details(mut self, details: serde_json::Value) -> Self {
234        self.details = Some(details);
235        self
236    }
237}
238
239/// One file referenced by a patch event.
240#[derive(Debug, Clone, Serialize)]
241#[serde(rename_all = "camelCase")]
242pub struct PatchEventFile {
243    /// Path relative to the package directory (e.g. `package/index.js`).
244    pub path: String,
245    /// True if the file's content was verified to match the expected
246    /// hash. For an `Applied` event this means post-write verification
247    /// succeeded; for `Verified` (dry-run) it means pre-write hashes
248    /// matched expectation.
249    pub verified: bool,
250    /// Which strategy produced the patched bytes — only set for `Applied`
251    /// events. One of `package`, `diff`, `blob`.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub applied_via: Option<AppliedVia>,
254}
255
256/// What kind of thing happened to a patch.
257///
258/// Serializes to lowercase camelCase strings — e.g. `Applied` → `"applied"`,
259/// `PaidRequired` → `"paidRequired"`. The full vocabulary is part of the
260/// CLI contract; new variants are MINOR-safe but renames are MAJOR.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
262#[serde(rename_all = "camelCase")]
263pub enum PatchAction {
264    /// `scan`: a patch exists upstream for this package, but no action
265    /// taken yet (no `--apply` / `--sync`).
266    Discovered,
267    /// `get` / `scan --apply` / `apply` (online): patch bytes were
268    /// fetched from the registry.
269    Downloaded,
270    /// `apply` / `scan --sync`: patch was applied to disk. `files`
271    /// enumerates which files changed.
272    Applied,
273    /// `apply` / `scan --sync`: patch replaced an older patch (the
274    /// manifest already had a different UUID for this PURL). `oldUuid`
275    /// carries the previous UUID.
276    Updated,
277    /// `apply` / `scan` / `get`: the patch was a no-op — already
278    /// applied, not in scope, or filtered out. `errorCode` carries the
279    /// reason tag.
280    Skipped,
281    /// Any command: an attempt failed. `errorCode` is the routing tag,
282    /// `error` is the human message.
283    Failed,
284    /// `gc` / `repair` / `remove` / `rollback`: data was removed from
285    /// `.socket/` (or from disk in the rollback case).
286    Removed,
287    /// `apply --dry-run` / `scan --dry-run`: patch *would* apply
288    /// cleanly. `files` lists what would change.
289    Verified,
290}
291
292/// Patch-source strategy used to apply a file. Mirrors the existing
293/// `socket_patch_core::patch::apply::AppliedVia` enum, but lives here so
294/// the JSON layer doesn't depend on core internals.
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
296#[serde(rename_all = "camelCase")]
297pub enum AppliedVia {
298    Package,
299    Diff,
300    Blob,
301}
302
303impl AppliedVia {
304    pub fn from_core(via: socket_patch_core::patch::apply::AppliedVia) -> Self {
305        use socket_patch_core::patch::apply::AppliedVia as Core;
306        match via {
307            Core::Package => AppliedVia::Package,
308            Core::Diff => AppliedVia::Diff,
309            Core::Blob => AppliedVia::Blob,
310        }
311    }
312}
313
314/// Which subcommand produced the envelope. Serializes lowercase.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
316#[serde(rename_all = "camelCase")]
317pub enum Command {
318    Apply,
319    Rollback,
320    Get,
321    Scan,
322    List,
323    Remove,
324    Repair,
325    Setup,
326    Unlock,
327    Vex,
328}
329
330
331/// Top-level status. Serializes camelCase.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
333#[serde(rename_all = "camelCase")]
334pub enum Status {
335    Success,
336    PartialFailure,
337    Error,
338    /// Special case for `apply`: the manifest doesn't exist yet, so
339    /// there's nothing to apply. Distinct from `Success` because some
340    /// consumers want to early-exit on this state.
341    NoManifest,
342    /// `get` / `scan`: the requested patch requires a paid plan but the
343    /// caller's API token isn't entitled. Distinct from `Error` so PR
344    /// bots can post a "upgrade your plan" comment instead of failing.
345    PaidRequired,
346    /// `remove` / `rollback`: the patch identifier didn't resolve to
347    /// anything in the local manifest.
348    NotFound,
349}
350
351/// Pre-aggregated counts across all events in this envelope. Field names
352/// match `PatchAction` variants for clarity.
353#[derive(Debug, Clone, Default, Serialize)]
354#[serde(rename_all = "camelCase")]
355pub struct Summary {
356    pub discovered: u32,
357    pub downloaded: u32,
358    pub applied: u32,
359    pub updated: u32,
360    pub skipped: u32,
361    pub failed: u32,
362    pub removed: u32,
363    pub verified: u32,
364}
365
366impl Summary {
367    fn bump(&mut self, action: PatchAction) {
368        match action {
369            PatchAction::Discovered => self.discovered += 1,
370            PatchAction::Downloaded => self.downloaded += 1,
371            PatchAction::Applied => self.applied += 1,
372            PatchAction::Updated => self.updated += 1,
373            PatchAction::Skipped => self.skipped += 1,
374            PatchAction::Failed => self.failed += 1,
375            PatchAction::Removed => self.removed += 1,
376            PatchAction::Verified => self.verified += 1,
377        }
378    }
379}
380
381/// Top-level error payload set when the command failed before producing
382/// patch events.
383#[derive(Debug, Clone, Serialize)]
384#[serde(rename_all = "camelCase")]
385pub struct EnvelopeError {
386    /// Routing tag — examples: `manifest_unreadable`, `network_error`,
387    /// `not_found`, `paid_required`.
388    pub code: String,
389    /// Human-readable message.
390    pub message: String,
391}
392
393impl EnvelopeError {
394    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
395        Self {
396            code: code.into(),
397            message: message.into(),
398        }
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Tests — pin the JSON serialization shape that downstream consumers see.
404// ---------------------------------------------------------------------------
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn action_tags_round_trip() {
412        // Each variant's serde representation must match the
413        // documented snake_case tag.
414        for (action, tag) in [
415            (PatchAction::Discovered, "discovered"),
416            (PatchAction::Downloaded, "downloaded"),
417            (PatchAction::Applied, "applied"),
418            (PatchAction::Updated, "updated"),
419            (PatchAction::Skipped, "skipped"),
420            (PatchAction::Failed, "failed"),
421            (PatchAction::Removed, "removed"),
422            (PatchAction::Verified, "verified"),
423        ] {
424            let serialized = serde_json::to_string(&action).unwrap();
425            assert_eq!(serialized, format!("\"{tag}\""));
426        }
427    }
428
429    #[test]
430    fn empty_envelope_has_stable_shape() {
431        let env = Envelope::new(Command::Scan);
432        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
433        let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(|s| s.as_str()).collect();
434        keys.sort();
435        // `error` is skipped when None, so it shouldn't appear.
436        assert_eq!(keys, vec!["command", "dryRun", "events", "status", "summary"]);
437        assert_eq!(v["command"], "scan");
438        assert_eq!(v["status"], "success");
439        assert_eq!(v["dryRun"], false);
440        assert_eq!(v["events"].as_array().unwrap().len(), 0);
441    }
442
443    #[test]
444    fn record_keeps_summary_in_sync() {
445        let mut env = Envelope::new(Command::Apply);
446        env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
447        env.record(PatchEvent::new(PatchAction::Downloaded, "pkg:npm/foo@1.0.0"));
448        env.record(
449            PatchEvent::new(PatchAction::Skipped, "pkg:npm/bar@2.0.0")
450                .with_reason("already_patched", "Files match afterHash"),
451        );
452
453        assert_eq!(env.summary.applied, 1);
454        assert_eq!(env.summary.downloaded, 1);
455        assert_eq!(env.summary.skipped, 1);
456        assert_eq!(env.events.len(), 3);
457    }
458
459    #[test]
460    fn skipped_event_omits_uuid_and_files() {
461        let event = PatchEvent::new(PatchAction::Skipped, "pkg:npm/foo@1.0.0")
462            .with_reason("package_not_installed", "no matching package on disk");
463        let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
464        let obj = v.as_object().unwrap();
465        assert!(!obj.contains_key("uuid"));
466        assert!(!obj.contains_key("files"));
467        assert!(!obj.contains_key("oldUuid"));
468        assert!(!obj.contains_key("error"));
469        assert_eq!(obj.get("errorCode").and_then(|v| v.as_str()), Some("package_not_installed"));
470        assert_eq!(obj.get("reason").and_then(|v| v.as_str()), Some("no matching package on disk"));
471    }
472
473    #[test]
474    fn applied_event_with_files_includes_applied_via() {
475        let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0")
476            .with_uuid("uuid-2222")
477            .with_files(vec![
478                PatchEventFile {
479                    path: "package/index.js".into(),
480                    verified: true,
481                    applied_via: Some(AppliedVia::Diff),
482                },
483                PatchEventFile {
484                    path: "package/lib/util.js".into(),
485                    verified: true,
486                    applied_via: Some(AppliedVia::Blob),
487                },
488            ]);
489        let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
490        let files = v["files"].as_array().unwrap();
491        assert_eq!(files.len(), 2);
492        assert_eq!(files[0]["path"], "package/index.js");
493        assert_eq!(files[0]["verified"], true);
494        assert_eq!(files[0]["appliedVia"], "diff");
495        assert_eq!(files[1]["appliedVia"], "blob");
496    }
497
498    #[test]
499    fn mark_partial_failure_does_not_clobber_error() {
500        let mut env = Envelope::new(Command::Apply);
501        env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
502        env.mark_partial_failure();
503        // mark_error wins — we don't want a sequence of marks to demote
504        // a hard error to a partial failure.
505        assert_eq!(env.status, Status::Error);
506    }
507
508    #[test]
509    fn top_level_error_serializes_inline() {
510        let mut env = Envelope::new(Command::Get);
511        env.mark_error(EnvelopeError::new("paid_required", "Patch requires paid plan"));
512        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
513        assert_eq!(v["status"], "error");
514        assert_eq!(v["error"]["code"], "paid_required");
515        assert_eq!(v["error"]["message"], "Patch requires paid plan");
516    }
517
518    #[test]
519    fn status_serializes_camel_case() {
520        // PartialFailure is the high-traffic one — confirm camelCase.
521        let mut env = Envelope::new(Command::Apply);
522        env.mark_partial_failure();
523        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
524        assert_eq!(v["status"], "partialFailure");
525    }
526
527    #[test]
528    fn artifact_event_omits_purl() {
529        // GC sweep events aren't scoped to a single PURL.
530        let event = PatchEvent::artifact(PatchAction::Removed)
531            .with_reason("orphan_blob", "Blob not referenced by any manifest entry");
532        let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
533        let obj = v.as_object().unwrap();
534        assert!(!obj.contains_key("purl"));
535        assert_eq!(obj["action"], "removed");
536        assert_eq!(obj["errorCode"], "orphan_blob");
537    }
538}