Skip to main content

harn_cli/
json_envelope.rs

1//! Canonical JSON envelope for `harn` CLI commands.
2//!
3//! Every `--json` mode returns a [`JsonEnvelope<T>`] — a versioned
4//! wrapper that exposes `schemaVersion`, `ok`, and either `data` or
5//! `error`. Soft signals attach as `warnings` so `ok: true` stays
6//! stable as long as the command succeeds.
7//!
8//! Schema versions are per-command and monotonically increasing.
9//! [`catalog`] returns the registry consumed by `harn --json-schemas`.
10//! New commands extend the catalog (and bump their own
11//! [`JsonOutput::SCHEMA_VERSION`]) when their JSON shape changes in a
12//! way agents need to detect.
13//!
14//! See epic #1753 (`--json` everywhere) for the broader contract.
15
16use serde::{Deserialize, Serialize};
17
18/// Schema version of the `harn --json-schemas` catalog itself. Bump
19/// when the shape of [`SchemaEntry`] or the catalog envelope changes.
20pub const CATALOG_SCHEMA_VERSION: u32 = 1;
21
22/// Versioned wrapper for every `--json` CLI output. All five fields
23/// are always serialized so consumers can rely on a flat shape:
24/// missing payloads surface as `null` and the empty `warnings` array
25/// is `[]` rather than absent.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct JsonEnvelope<T: Serialize> {
28    #[serde(rename = "schemaVersion")]
29    pub schema_version: u32,
30    pub ok: bool,
31    pub data: Option<T>,
32    pub error: Option<JsonError>,
33    #[serde(default)]
34    pub warnings: Vec<JsonWarning>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct JsonError {
39    pub code: String,
40    pub message: String,
41    /// Free-form structured context. `null` when the error has no
42    /// structured payload — the field is always present so consumers
43    /// can read `error.details` without an existence check.
44    #[serde(default)]
45    pub details: serde_json::Value,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct JsonWarning {
50    pub code: String,
51    pub message: String,
52}
53
54/// Implemented by every CLI command that exposes a `--json` mode. The
55/// associated `SCHEMA_VERSION` is also surfaced in [`catalog`] so
56/// agents can negotiate per-command compatibility without parsing
57/// every payload.
58pub trait JsonOutput {
59    const SCHEMA_VERSION: u32;
60    type Data: Serialize;
61    fn into_envelope(self) -> JsonEnvelope<Self::Data>;
62}
63
64impl<T: Serialize> JsonEnvelope<T> {
65    pub fn ok(schema_version: u32, data: T) -> Self {
66        Self {
67            schema_version,
68            ok: true,
69            data: Some(data),
70            error: None,
71            warnings: Vec::new(),
72        }
73    }
74
75    pub fn err(
76        schema_version: u32,
77        code: impl Into<String>,
78        message: impl Into<String>,
79    ) -> JsonEnvelope<T> {
80        Self {
81            schema_version,
82            ok: false,
83            data: None,
84            error: Some(JsonError {
85                code: code.into(),
86                message: message.into(),
87                details: serde_json::Value::Null,
88            }),
89            warnings: Vec::new(),
90        }
91    }
92
93    pub fn with_details(mut self, details: serde_json::Value) -> Self {
94        if let Some(err) = self.error.as_mut() {
95            err.details = details;
96        }
97        self
98    }
99
100    pub fn with_warning(mut self, code: impl Into<String>, message: impl Into<String>) -> Self {
101        self.warnings.push(JsonWarning {
102            code: code.into(),
103            message: message.into(),
104        });
105        self
106    }
107}
108
109/// One row of the `harn --json-schemas` catalog. `schema_json` is
110/// inline when small; richer schemas live behind a future
111/// `schema_url` field documented per-command.
112#[derive(Debug, Clone, Serialize)]
113pub struct SchemaEntry {
114    pub command: &'static str,
115    #[serde(rename = "schemaVersion")]
116    pub schema_version: u32,
117    pub description: &'static str,
118    #[serde(skip_serializing_if = "Option::is_none", rename = "schemaJson")]
119    pub schema_json: Option<serde_json::Value>,
120}
121
122/// Static catalog of commands that already emit a stable JSON shape.
123///
124/// E2.1 seeds the commands that ship a `schema_version` today (doctor,
125/// session export, the provider catalog). New commands register here as
126/// they migrate to [`JsonEnvelope`] — for example, the `skills` family
127/// added in E3.2.
128pub fn catalog() -> Vec<SchemaEntry> {
129    vec![
130        SchemaEntry {
131            command: "doctor",
132            schema_version: crate::commands::doctor::DOCTOR_SCHEMA_VERSION,
133            description: "Capability matrix: host, per-target buildability, per-provider reachability, per-stdlib-effect availability.",
134            schema_json: None,
135        },
136        SchemaEntry {
137            command: "session export",
138            schema_version: 1,
139            description: "Portable Harn session bundle export.",
140            schema_json: None,
141        },
142        SchemaEntry {
143            command: "provider-catalog",
144            schema_version: 1,
145            description: "Resolved provider/model catalog snapshot.",
146            schema_json: None,
147        },
148        SchemaEntry {
149            command: "connect status",
150            schema_version: 1,
151            description: "Outbound-connector readiness report.",
152            schema_json: None,
153        },
154        SchemaEntry {
155            command: "connect setup-plan",
156            schema_version: 1,
157            description: "Step-by-step plan to bring a connector online.",
158            schema_json: None,
159        },
160        SchemaEntry {
161            command: "run",
162            schema_version: crate::commands::run::json_events::RUN_JSON_SCHEMA_VERSION,
163            description: "Pipeline-run NDJSON event stream (stdout, stderr, transcript, tool, hook, persona, result, error).",
164            schema_json: None,
165        },
166        SchemaEntry {
167            command: "parse",
168            schema_version: crate::commands::parse_tokens::PARSE_JSON_SCHEMA_VERSION,
169            description: "Tagged Harn AST tree with byte spans for parser tooling.",
170            schema_json: None,
171        },
172        SchemaEntry {
173            command: "tokens",
174            schema_version: crate::commands::parse_tokens::TOKENS_JSON_SCHEMA_VERSION,
175            description: "Lexer token stream with source lexemes and byte spans.",
176            schema_json: None,
177        },
178        SchemaEntry {
179            command: "check",
180            schema_version: crate::commands::check::CHECK_SCHEMA_VERSION,
181            description: "Per-file static check results with diagnostics and summary counts.",
182            schema_json: None,
183        },
184        SchemaEntry {
185            command: "fmt",
186            schema_version: crate::commands::check::FMT_SCHEMA_VERSION,
187            description: "Per-file formatting result report for write and check modes.",
188            schema_json: None,
189        },
190        SchemaEntry {
191            command: "check provider-matrix",
192            schema_version: crate::commands::check::provider_matrix::PROVIDER_MATRIX_SCHEMA_VERSION,
193            description: "Provider/model capability matrix rows.",
194            schema_json: None,
195        },
196        SchemaEntry {
197            command: "providers support",
198            schema_version: crate::commands::provider_support::PROVIDER_SUPPORT_SCHEMA_VERSION,
199            description: "Generated provider recommendation and support matrix.",
200            schema_json: None,
201        },
202        SchemaEntry {
203            command: "check connector-matrix",
204            schema_version: crate::commands::check::connector_matrix::CONNECTOR_MATRIX_SCHEMA_VERSION,
205            description: "Connector package capability matrix rows.",
206            schema_json: None,
207        },
208        SchemaEntry {
209            command: "test conformance",
210            schema_version: crate::commands::test::CONFORMANCE_TEST_SCHEMA_VERSION,
211            description:
212                "Conformance test results with xfail accounting and a stable fixture snapshot key.",
213            schema_json: None,
214        },
215        SchemaEntry {
216            command: "test --json-out",
217            schema_version: crate::test_report::USER_TEST_REPORT_SCHEMA_VERSION,
218            description:
219                "User-test report (`--json-out`): per-case name/file/classname/outcome/duration plus suite-level summary.",
220            schema_json: None,
221        },
222        SchemaEntry {
223            command: "time run",
224            schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
225            description:
226                "Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
227            schema_json: None,
228        },
229        SchemaEntry {
230            command: "fix plan",
231            schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
232            description: "Plan repair-bearing diagnostics without editing files.",
233            schema_json: None,
234        },
235        SchemaEntry {
236            command: "fix apply",
237            schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
238            description: "Apply clean repair edits at or below a declared safety ceiling.",
239            schema_json: None,
240        },
241        SchemaEntry {
242            command: "skills list",
243            schema_version: 1,
244            description: "Canonical Harn skill corpus, frontmatter only.",
245            schema_json: None,
246        },
247        SchemaEntry {
248            command: "skills get",
249            schema_version: 1,
250            description: "One canonical skill's frontmatter (and body with --full).",
251            schema_json: None,
252        },
253        SchemaEntry {
254            command: "pack",
255            schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
256            description: "Signed-ready .harnpack run-bundle build summary.",
257            schema_json: Some(crate::commands::pack::json_schema()),
258        },
259        SchemaEntry {
260            command: "pack verify",
261            schema_version: crate::commands::pack::PACK_VERIFY_SCHEMA_VERSION,
262            description:
263                "Result of verifying a .harnpack: bundle hash, signature, per-module hashes.",
264            schema_json: Some(crate::commands::pack::verify_json_schema()),
265        },
266        SchemaEntry {
267            command: "dev",
268            schema_version: 1,
269            description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
270            schema_json: None,
271        },
272        SchemaEntry {
273            command: "routes",
274            schema_version: 1,
275            description: "Static trigger route, budget, capability, and vendor-lock inventory.",
276            schema_json: None,
277        },
278        SchemaEntry {
279            command: "graph",
280            schema_version: crate::commands::graph::GRAPH_SCHEMA_VERSION,
281            description:
282                "Static module graph with public symbols, imports, capabilities, effects, and host-call surface.",
283            schema_json: None,
284        },
285        SchemaEntry {
286            command: "lint",
287            schema_version: crate::commands::check::LINT_SCHEMA_VERSION,
288            description:
289                "Per-file lint diagnostics with severity, fixable/fixed counts, and summary.",
290            schema_json: None,
291        },
292        SchemaEntry {
293            command: "replay",
294            schema_version: crate::commands::replay::REPLAY_SCHEMA_VERSION,
295            description:
296                "Replay summary: per-stage status/outcome/branch plus the embedded replay-fixture verdict.",
297            schema_json: None,
298        },
299        SchemaEntry {
300            command: "version",
301            schema_version: crate::VERSION_SCHEMA_VERSION,
302            description: "CLI build metadata: name, version, description.",
303            schema_json: None,
304        },
305        SchemaEntry {
306            command: "upgrade",
307            schema_version: crate::commands::upgrade::UPGRADE_SCHEMA_VERSION,
308            description:
309                "Self-update probe (`--check`) or install summary: current, target, archive URL, install outcome.",
310            schema_json: None,
311        },
312        SchemaEntry {
313            command: "explain --catalog",
314            schema_version: crate::commands::diagnostics_catalog::SCHEMA_VERSION,
315            description:
316                "Diagnostic-code catalog: per-code summary, repair, safety, related codes.",
317            schema_json: None,
318        },
319    ]
320}
321
322/// Encode an envelope as JSON. Uses pretty form so humans tailing the
323/// terminal can still read it; agents `jq`-pipe either form.
324pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
325    serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use serde_json::json;
332
333    #[derive(Serialize)]
334    struct Payload {
335        value: u32,
336    }
337
338    #[test]
339    fn ok_envelope_round_trips() {
340        let env = JsonEnvelope::ok(7, Payload { value: 42 });
341        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
342        assert_eq!(v["schemaVersion"], 7);
343        assert_eq!(v["ok"], true);
344        assert_eq!(v["data"]["value"], 42);
345        // All envelope fields are always serialized; absent payloads
346        // surface as JSON `null` / `[]`.
347        assert!(v["error"].is_null());
348        assert_eq!(v["warnings"], json!([]));
349    }
350
351    #[test]
352    fn err_envelope_carries_details() {
353        let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
354            .with_details(json!({ "path": "/var/log/harn" }));
355        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
356        assert_eq!(v["schemaVersion"], 2);
357        assert_eq!(v["ok"], false);
358        assert_eq!(v["error"]["code"], "io");
359        assert_eq!(v["error"]["message"], "disk full");
360        assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
361        assert!(v["data"].is_null());
362    }
363
364    #[test]
365    fn warnings_serialize_when_present() {
366        let env = JsonEnvelope::ok(1, Payload { value: 1 })
367            .with_warning("deprecated.flag", "--format=json is deprecated");
368        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
369        assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
370        assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
371    }
372
373    #[test]
374    fn catalog_is_nonempty_and_unique() {
375        let entries = catalog();
376        assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
377        let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
378        commands.sort();
379        let unique_count = {
380            let mut deduped = commands.clone();
381            deduped.dedup();
382            deduped.len()
383        };
384        assert_eq!(commands.len(), unique_count, "command names must be unique");
385    }
386
387    #[test]
388    fn catalog_includes_fix_plan() {
389        let entries = catalog();
390        let entry = entries
391            .iter()
392            .find(|entry| entry.command == "fix plan")
393            .expect("fix plan schema should be registered");
394        assert_eq!(
395            entry.schema_version,
396            crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
397        );
398        let entry = entries
399            .iter()
400            .find(|entry| entry.command == "fix apply")
401            .expect("fix apply schema should be registered");
402        assert_eq!(
403            entry.schema_version,
404            crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
405        );
406    }
407
408    #[test]
409    fn schema_versions_are_positive() {
410        for entry in catalog() {
411            assert!(
412                entry.schema_version >= 1,
413                "{} should have schemaVersion >= 1",
414                entry.command
415            );
416        }
417    }
418}