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: "host lease",
138            schema_version: crate::commands::host::HOST_LEASE_CLI_SCHEMA_VERSION,
139            description: "Machine-global host lease acquire, renew, release, and status receipts.",
140            schema_json: None,
141        },
142        SchemaEntry {
143            command: "session export",
144            schema_version: 1,
145            description: "Portable Harn session bundle export.",
146            schema_json: None,
147        },
148        SchemaEntry {
149            command: "provider catalog show",
150            schema_version: 1,
151            description: "Resolved provider/model catalog snapshot.",
152            schema_json: None,
153        },
154        SchemaEntry {
155            command: "connect status",
156            schema_version: crate::commands::connect::status::CONNECT_STATUS_SCHEMA_VERSION,
157            description: "Outbound-connector readiness report.",
158            schema_json: None,
159        },
160        SchemaEntry {
161            command: "connect setup-plan",
162            schema_version: crate::commands::connect::status::CONNECT_SETUP_PLAN_SCHEMA_VERSION,
163            description: "Step-by-step plan to bring a connector online.",
164            schema_json: None,
165        },
166        SchemaEntry {
167            command: "connect",
168            schema_version: crate::commands::connect::setup_events::CONNECT_SETUP_EVENT_SCHEMA_VERSION,
169            description: "Secret-free connector setup progress and terminal events as NDJSON.",
170            schema_json: None,
171        },
172        SchemaEntry {
173            command: "mcp status",
174            schema_version: crate::commands::mcp::MCP_STATUS_SCHEMA_VERSION,
175            description: "Per-server MCP readiness: transport, connection state, tool/resource/prompt counts, last error.",
176            schema_json: None,
177        },
178        SchemaEntry {
179            command: "mcp discover",
180            schema_version: crate::commands::mcp::MCP_DISCOVERY_SCHEMA_VERSION,
181            description:
182                "Unofficial MCP endpoint discovery from /.well-known/mcp.json: source URL, found flag, and descriptor.",
183            schema_json: None,
184        },
185        SchemaEntry {
186            command: "run",
187            schema_version: crate::commands::run::json_events::RUN_JSON_SCHEMA_VERSION,
188            description: "Pipeline-run NDJSON event stream (stdout, stderr, transcript, tool, hook, persona, result, error).",
189            schema_json: None,
190        },
191        SchemaEntry {
192            command: "portable compile|start|resume",
193            schema_version: crate::commands::portable::PORTABLE_CLI_SCHEMA_VERSION,
194            description: "Portable artifact compilation and deterministic execute/resume terminal states.",
195            schema_json: None,
196        },
197        SchemaEntry {
198            command: "parse",
199            schema_version: crate::commands::parse_tokens::PARSE_JSON_SCHEMA_VERSION,
200            description: "Tagged Harn AST tree with byte spans for parser tooling.",
201            schema_json: None,
202        },
203        SchemaEntry {
204            command: "tokens",
205            schema_version: crate::commands::parse_tokens::TOKENS_JSON_SCHEMA_VERSION,
206            description: "Lexer token stream with source lexemes and byte spans.",
207            schema_json: None,
208        },
209        SchemaEntry {
210            command: "check",
211            schema_version: crate::commands::check::CHECK_SCHEMA_VERSION,
212            description: "Per-file static check results with diagnostics and summary counts.",
213            schema_json: None,
214        },
215        SchemaEntry {
216            command: "package verify",
217            schema_version:
218                crate::commands::package_verify::PACKAGE_VERIFY_SCHEMA_VERSION,
219            description: "Complete package verification receipt with inferred package kinds and per-gate applicability, reachability, and results.",
220            schema_json: None,
221        },
222        SchemaEntry {
223            command: "skill list",
224            schema_version: crate::commands::skills::SKILLS_LIST_SCHEMA_VERSION,
225            description: "Canonical embedded or disk-backed Harn skill catalog.",
226            schema_json: None,
227        },
228        SchemaEntry {
229            command: "skill get",
230            schema_version: crate::commands::skills::SKILLS_GET_SCHEMA_VERSION,
231            description: "One canonical skill card with an optional full SKILL.md body.",
232            schema_json: None,
233        },
234        SchemaEntry {
235            command: "skill validate",
236            schema_version: crate::commands::skills::SKILLS_VALIDATE_SCHEMA_VERSION,
237            description: "Skill bundle validation result from the runtime's canonical parser.",
238            schema_json: None,
239        },
240        SchemaEntry {
241            command: "fmt",
242            schema_version: crate::commands::check::FMT_SCHEMA_VERSION,
243            description: "Per-file formatting result report for write and check modes.",
244            schema_json: None,
245        },
246        SchemaEntry {
247            command: "check --provider-matrix",
248            schema_version: crate::commands::check::provider_matrix::PROVIDER_MATRIX_SCHEMA_VERSION,
249            description: "Provider/model capability matrix rows.",
250            schema_json: None,
251        },
252        SchemaEntry {
253            command: "provider catalog support",
254            schema_version: crate::commands::provider_support::PROVIDER_SUPPORT_SCHEMA_VERSION,
255            description: "Generated provider recommendation and support matrix.",
256            schema_json: None,
257        },
258        SchemaEntry {
259            command: "models batch plan",
260            schema_version: 1,
261            description:
262                "Provider Batch API candidates plus Harn live-adapter support for offline workloads.",
263            schema_json: None,
264        },
265        SchemaEntry {
266            command: "models batch manifest",
267            schema_version: 1,
268            description:
269                "Provider-neutral offline batch manifest summary and request groups.",
270            schema_json: None,
271        },
272        SchemaEntry {
273            command: "models batch prepare",
274            schema_version: 1,
275            description:
276                "Provider-native batch request files, deterministic prepare receipt, and lifecycle state.",
277            schema_json: None,
278        },
279        SchemaEntry {
280            command: "models batch submit",
281            schema_version: 1,
282            description:
283                "Batch submission receipt with provider job ids, dry-run operations, and lifecycle state.",
284            schema_json: None,
285        },
286        SchemaEntry {
287            command: "models batch status",
288            schema_version: 1,
289            description:
290                "Provider batch status receipt with cached/dry-run validation and lifecycle counts.",
291            schema_json: None,
292        },
293        SchemaEntry {
294            command: "models batch cancel",
295            schema_version: 1,
296            description:
297                "Batch cancellation receipt with redacted cancel operations, skipped-job reasons, and lifecycle counts.",
298            schema_json: None,
299        },
300        SchemaEntry {
301            command: "models batch download",
302            schema_version: 1,
303            description:
304                "Provider result-file download receipt with artifact paths, hashes, and lifecycle counts.",
305            schema_json: None,
306        },
307        SchemaEntry {
308            command: "models lora plan",
309            schema_version: 1,
310            description: "Portable LoRA/QLoRA route plan: base model, tool-call format, trainer, data, eval, and launch contract.",
311            schema_json: None,
312        },
313        SchemaEntry {
314            command: "models lora inspect",
315            schema_version: 1,
316            description:
317                "PEFT LoRA adapter compatibility report with base-model, provider, tool-call, and launch metadata.",
318            schema_json: None,
319        },
320        SchemaEntry {
321            command: "models lora export",
322            schema_version: 1,
323            description:
324                "Trainer-ready LoRA dataset export report, including contract id, manifest paths, stats, and validation results.",
325            schema_json: None,
326        },
327        SchemaEntry {
328            command: "models lora manifest",
329            schema_version: 1,
330            description:
331                "Canonical LoRA training-run manifest with route, data, artifact, serving, and promotion contracts.",
332            schema_json: None,
333        },
334        SchemaEntry {
335            command: "models lora preflight",
336            schema_version: 1,
337            description:
338                "LoRA corpus readiness report before GPU training, including sequence-fit, tool-call shape, and threshold failures.",
339            schema_json: None,
340        },
341        SchemaEntry {
342            command: "models lora promote",
343            schema_version: 1,
344            description:
345                "LoRA promotion probe matrix receipt collected from adapter-loaded behavioral probe outputs.",
346            schema_json: None,
347        },
348        SchemaEntry {
349            command: "models lora train",
350            schema_version: 1,
351            description:
352                "LoRA trainer backend receipt with route contract, dataset hashes, backend argv, and post-training manifest commands.",
353            schema_json: None,
354        },
355        SchemaEntry {
356            command: "check --connector-matrix",
357            schema_version: crate::commands::check::connector_matrix::CONNECTOR_MATRIX_SCHEMA_VERSION,
358            description: "Connector package capability matrix rows.",
359            schema_json: None,
360        },
361        SchemaEntry {
362            command: "test conformance",
363            schema_version: crate::commands::test::CONFORMANCE_TEST_SCHEMA_VERSION,
364            description:
365                "Conformance results with xfail accounting, fixture snapshot key, and duration distribution.",
366            schema_json: None,
367        },
368        SchemaEntry {
369            command: "test --json-out",
370            schema_version: crate::test_report::USER_TEST_REPORT_SCHEMA_VERSION,
371            description:
372                "User-test report with typed timeout, per-case and aggregate phases, module attribution, and duration distribution.",
373            schema_json: None,
374        },
375        SchemaEntry {
376            command: "time run",
377            schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
378            description:
379                "Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
380            schema_json: None,
381        },
382        SchemaEntry {
383            command: "fix --plan",
384            schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
385            description: "Plan repair-bearing diagnostics without editing files.",
386            schema_json: None,
387        },
388        SchemaEntry {
389            command: "fix --apply",
390            schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
391            description: "Apply clean repair edits at or below a declared safety ceiling.",
392            schema_json: None,
393        },
394        SchemaEntry {
395            command: "pack",
396            schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
397            description: "Signed-ready .harnpack run-bundle build summary.",
398            schema_json: Some(crate::commands::pack::json_schema()),
399        },
400        SchemaEntry {
401            command: "pack verify",
402            schema_version: crate::commands::pack::PACK_VERIFY_SCHEMA_VERSION,
403            description:
404                "Result of verifying a .harnpack: bundle hash, signature, per-module hashes.",
405            schema_json: Some(crate::commands::pack::verify_json_schema()),
406        },
407        SchemaEntry {
408            command: "dev",
409            schema_version: 1,
410            description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
411            schema_json: None,
412        },
413        SchemaEntry {
414            command: "routes",
415            schema_version: 1,
416            description: "Static trigger route, budget, capability, and vendor-lock inventory.",
417            schema_json: None,
418        },
419        SchemaEntry {
420            command: "usage",
421            schema_version: crate::commands::usage::USAGE_SCHEMA_VERSION,
422            description:
423                "LLM spend/usage rollup from the event log: per-group calls, cost_usd, tokens, cache telemetry, and time-series cumulatives.",
424            schema_json: None,
425        },
426        SchemaEntry {
427            command: "graph",
428            schema_version: crate::commands::graph::GRAPH_SCHEMA_VERSION,
429            description:
430                "Static module graph with public symbols, imports, capabilities, effects, and host-call surface.",
431            schema_json: None,
432        },
433        SchemaEntry {
434            command: "lint",
435            schema_version: crate::commands::check::LINT_SCHEMA_VERSION,
436            description:
437                "Per-file lint diagnostics with severity, fixable/fixed counts, and summary.",
438            schema_json: Some(crate::commands::check::lint_json_schema()),
439        },
440        SchemaEntry {
441            command: "replay",
442            schema_version: crate::commands::replay::REPLAY_SCHEMA_VERSION,
443            description:
444                "Replay summary: per-stage status/outcome/branch, embedded fixture verdicts, and multi-run determinism.",
445            schema_json: None,
446        },
447        SchemaEntry {
448            command: "version",
449            schema_version: crate::VERSION_SCHEMA_VERSION,
450            description: "CLI build metadata: name, version, description.",
451            schema_json: None,
452        },
453        SchemaEntry {
454            command: "upgrade",
455            schema_version: crate::commands::upgrade::UPGRADE_SCHEMA_VERSION,
456            description:
457                "Self-update probe (`--check`) or install summary: current, target, archive URL, install outcome.",
458            schema_json: None,
459        },
460        SchemaEntry {
461            command: "explain --catalog",
462            schema_version: crate::commands::diagnostics_catalog::SCHEMA_VERSION,
463            description:
464                "Diagnostic-code catalog: per-code summary, repair, safety, related codes.",
465            schema_json: None,
466        },
467        SchemaEntry {
468            command: "mcp presets",
469            schema_version: crate::commands::mcp::presets::MCP_PRESETS_SCHEMA_VERSION,
470            description:
471                "Canonical catalog of well-known MCP server presets (Notion, Linear, GitHub, filesystem): id, transport, command/url template, auth kind, and required placeholders.",
472            schema_json: None,
473        },
474    ]
475}
476
477/// Encode an envelope as JSON. Uses pretty form so humans tailing the
478/// terminal can still read it; agents `jq`-pipe either form.
479pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
480    serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use serde_json::json;
487
488    #[derive(Serialize)]
489    struct Payload {
490        value: u32,
491    }
492
493    /// Split a catalog row into subcommand paths plus any trailing flags.
494    ///
495    /// Rows are not all plain subcommand paths:
496    /// `"portable compile|start|resume"` covers three siblings, and
497    /// `"test --json-out"` names a command *and* one of its flags.
498    fn catalog_command_paths(command: &str) -> (Vec<Vec<String>>, Vec<String>) {
499        let tokens: Vec<&str> = command.split_whitespace().collect();
500        let split = tokens
501            .iter()
502            .position(|token| token.starts_with('-'))
503            .unwrap_or(tokens.len());
504        let (subcommands, flags) = tokens.split_at(split);
505        let flags = flags.iter().map(|f| f.to_string()).collect();
506        let Some((leaves, prefix)) = subcommands.split_last() else {
507            return (Vec::new(), flags);
508        };
509        let paths = leaves
510            .split('|')
511            .map(|leaf| {
512                let mut path: Vec<String> = prefix.iter().map(|s| (*s).to_string()).collect();
513                path.push(leaf.to_string());
514                path
515            })
516            .collect();
517        (paths, flags)
518    }
519
520    fn resolve_command<'a>(root: &'a clap::Command, path: &[String]) -> Option<&'a clap::Command> {
521        let mut current = root;
522        for segment in path {
523            current = current.get_subcommands().find(|sub| {
524                sub.get_name() == segment || sub.get_all_aliases().any(|alias| alias == segment)
525            })?;
526        }
527        Some(current)
528    }
529
530    /// Every catalog row must name a command the CLI actually exposes.
531    ///
532    /// `catalog()` is hand-maintained next to clap's command tree, so the two
533    /// are separate owners of one surface and drift silently. They did: the
534    /// catalog kept saying `skills list` after #3664 renamed the noun to
535    /// `skill`, and the only coverage that noticed lived in the nightly-only
536    /// e2e suite, where it sat red for three nights.
537    #[test]
538    fn catalog_commands_exist_in_the_cli() {
539        use clap::CommandFactory;
540        let root = crate::cli::Cli::command();
541        // Collect every mismatch rather than panicking on the first: drift
542        // arrives in batches after a rename, and fixing them one CI run at a
543        // time is how this diverged in the first place.
544        // Rows whose trailing token is a positional *value*, not a subcommand.
545        // `harn test [TARGET]` documents `conformance` as one of its accepted
546        // values. Kept as an explicit list rather than relaxing the rule to
547        // "parent accepts positionals" — that relaxation would also have waved
548        // through `check provider-matrix`, which was a real bug this test found.
549        const POSITIONAL_VALUE_ROWS: &[&str] = &["test conformance"];
550
551        let mut drift = Vec::new();
552        for entry in catalog() {
553            if POSITIONAL_VALUE_ROWS.contains(&entry.command) {
554                continue;
555            }
556            let (paths, flags) = catalog_command_paths(entry.command);
557            for path in paths {
558                let Some(command) = resolve_command(&root, &path) else {
559                    drift.push(format!(
560                        "`{}` names no such command (row: `{}`)",
561                        path.join(" "),
562                        entry.command
563                    ));
564                    continue;
565                };
566                for flag in &flags {
567                    let long = flag.trim_start_matches('-');
568                    if !command
569                        .get_arguments()
570                        .any(|arg| arg.get_long() == Some(long))
571                    {
572                        drift.push(format!(
573                            "`{}` takes no `{flag}` (row: `{}`)",
574                            path.join(" "),
575                            entry.command
576                        ));
577                    }
578                }
579            }
580        }
581        assert!(
582            drift.is_empty(),
583            "`--json-schemas` advertises {} command(s) the CLI does not expose:\n  {}",
584            drift.len(),
585            drift.join("\n  ")
586        );
587    }
588
589    #[test]
590    fn ok_envelope_round_trips() {
591        let env = JsonEnvelope::ok(7, Payload { value: 42 });
592        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
593        assert_eq!(v["schemaVersion"], 7);
594        assert_eq!(v["ok"], true);
595        assert_eq!(v["data"]["value"], 42);
596        // All envelope fields are always serialized; absent payloads
597        // surface as JSON `null` / `[]`.
598        assert!(v["error"].is_null());
599        assert_eq!(v["warnings"], json!([]));
600    }
601
602    #[test]
603    fn err_envelope_carries_details() {
604        let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
605            .with_details(json!({ "path": "/var/log/harn" }));
606        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
607        assert_eq!(v["schemaVersion"], 2);
608        assert_eq!(v["ok"], false);
609        assert_eq!(v["error"]["code"], "io");
610        assert_eq!(v["error"]["message"], "disk full");
611        assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
612        assert!(v["data"].is_null());
613    }
614
615    #[test]
616    fn warnings_serialize_when_present() {
617        let env = JsonEnvelope::ok(1, Payload { value: 1 })
618            .with_warning("deprecated.flag", "--format=json is deprecated");
619        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
620        assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
621        assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
622    }
623
624    #[test]
625    fn catalog_is_nonempty_and_unique() {
626        let entries = catalog();
627        assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
628        let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
629        commands.sort();
630        let unique_count = {
631            let mut deduped = commands.clone();
632            deduped.dedup();
633            deduped.len()
634        };
635        assert_eq!(commands.len(), unique_count, "command names must be unique");
636    }
637
638    #[test]
639    fn catalog_includes_fix_plan() {
640        let entries = catalog();
641        let entry = entries
642            .iter()
643            .find(|entry| entry.command == "fix --plan")
644            .expect("fix --plan schema should be registered");
645        assert_eq!(
646            entry.schema_version,
647            crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
648        );
649        let entry = entries
650            .iter()
651            .find(|entry| entry.command == "fix --apply")
652            .expect("fix apply schema should be registered");
653        assert_eq!(
654            entry.schema_version,
655            crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
656        );
657    }
658
659    #[test]
660    fn catalog_includes_models_lora_commands() {
661        let entries = catalog();
662        for command in [
663            "models lora plan",
664            "models lora inspect",
665            "models lora export",
666            "models lora manifest",
667            "models lora preflight",
668            "models lora promote",
669            "models lora train",
670        ] {
671            let entry = entries
672                .iter()
673                .find(|entry| entry.command == command)
674                .unwrap_or_else(|| panic!("{command} schema should be registered"));
675            assert_eq!(entry.schema_version, 1);
676        }
677    }
678
679    #[test]
680    fn catalog_includes_models_batch_commands() {
681        let entries = catalog();
682        for command in [
683            "models batch plan",
684            "models batch manifest",
685            "models batch prepare",
686            "models batch submit",
687            "models batch status",
688            "models batch cancel",
689            "models batch download",
690        ] {
691            let entry = entries
692                .iter()
693                .find(|entry| entry.command == command)
694                .unwrap_or_else(|| panic!("{command} schema should be registered"));
695            assert_eq!(entry.schema_version, 1);
696        }
697    }
698
699    #[test]
700    fn schema_versions_are_positive() {
701        for entry in catalog() {
702            assert!(
703                entry.schema_version >= 1,
704                "{} should have schemaVersion >= 1",
705                entry.command
706            );
707        }
708    }
709
710    #[test]
711    fn catalog_lint_publishes_schema_json() {
712        let entry = catalog()
713            .into_iter()
714            .find(|entry| entry.command == "lint")
715            .expect("lint schema should be registered");
716        assert_eq!(
717            entry.schema_version,
718            crate::commands::check::LINT_SCHEMA_VERSION
719        );
720        let schema = entry.schema_json.expect("lint schemaJson must be present");
721        assert_eq!(schema["title"], "harn lint --json");
722        assert_eq!(schema["properties"]["schemaVersion"]["const"], 1);
723        jsonschema::draft202012::meta::validate(&schema).expect("lint schema meta-valid");
724    }
725}