1use serde::{Deserialize, Serialize};
17
18pub const CATALOG_SCHEMA_VERSION: u32 = 1;
21
22#[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 #[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
54pub 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#[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
122pub 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: "mcp status",
162 schema_version: crate::commands::mcp::MCP_STATUS_SCHEMA_VERSION,
163 description: "Per-server MCP readiness: transport, connection state, tool/resource/prompt counts, last error.",
164 schema_json: None,
165 },
166 SchemaEntry {
167 command: "run",
168 schema_version: crate::commands::run::json_events::RUN_JSON_SCHEMA_VERSION,
169 description: "Pipeline-run NDJSON event stream (stdout, stderr, transcript, tool, hook, persona, result, error).",
170 schema_json: None,
171 },
172 SchemaEntry {
173 command: "parse",
174 schema_version: crate::commands::parse_tokens::PARSE_JSON_SCHEMA_VERSION,
175 description: "Tagged Harn AST tree with byte spans for parser tooling.",
176 schema_json: None,
177 },
178 SchemaEntry {
179 command: "tokens",
180 schema_version: crate::commands::parse_tokens::TOKENS_JSON_SCHEMA_VERSION,
181 description: "Lexer token stream with source lexemes and byte spans.",
182 schema_json: None,
183 },
184 SchemaEntry {
185 command: "check",
186 schema_version: crate::commands::check::CHECK_SCHEMA_VERSION,
187 description: "Per-file static check results with diagnostics and summary counts.",
188 schema_json: None,
189 },
190 SchemaEntry {
191 command: "fmt",
192 schema_version: crate::commands::check::FMT_SCHEMA_VERSION,
193 description: "Per-file formatting result report for write and check modes.",
194 schema_json: None,
195 },
196 SchemaEntry {
197 command: "check provider-matrix",
198 schema_version: crate::commands::check::provider_matrix::PROVIDER_MATRIX_SCHEMA_VERSION,
199 description: "Provider/model capability matrix rows.",
200 schema_json: None,
201 },
202 SchemaEntry {
203 command: "providers support",
204 schema_version: crate::commands::provider_support::PROVIDER_SUPPORT_SCHEMA_VERSION,
205 description: "Generated provider recommendation and support matrix.",
206 schema_json: None,
207 },
208 SchemaEntry {
209 command: "check connector-matrix",
210 schema_version: crate::commands::check::connector_matrix::CONNECTOR_MATRIX_SCHEMA_VERSION,
211 description: "Connector package capability matrix rows.",
212 schema_json: None,
213 },
214 SchemaEntry {
215 command: "test conformance",
216 schema_version: crate::commands::test::CONFORMANCE_TEST_SCHEMA_VERSION,
217 description:
218 "Conformance test results with xfail accounting and a stable fixture snapshot key.",
219 schema_json: None,
220 },
221 SchemaEntry {
222 command: "test --json-out",
223 schema_version: crate::test_report::USER_TEST_REPORT_SCHEMA_VERSION,
224 description:
225 "User-test report (`--json-out`): per-case name/file/classname/outcome/duration plus suite-level summary.",
226 schema_json: None,
227 },
228 SchemaEntry {
229 command: "time run",
230 schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
231 description:
232 "Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
233 schema_json: None,
234 },
235 SchemaEntry {
236 command: "fix plan",
237 schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
238 description: "Plan repair-bearing diagnostics without editing files.",
239 schema_json: None,
240 },
241 SchemaEntry {
242 command: "fix apply",
243 schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
244 description: "Apply clean repair edits at or below a declared safety ceiling.",
245 schema_json: None,
246 },
247 SchemaEntry {
248 command: "skills list",
249 schema_version: 1,
250 description: "Canonical Harn skill corpus, frontmatter only.",
251 schema_json: None,
252 },
253 SchemaEntry {
254 command: "skills get",
255 schema_version: 1,
256 description: "One canonical skill's frontmatter (and body with --full).",
257 schema_json: None,
258 },
259 SchemaEntry {
260 command: "pack",
261 schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
262 description: "Signed-ready .harnpack run-bundle build summary.",
263 schema_json: Some(crate::commands::pack::json_schema()),
264 },
265 SchemaEntry {
266 command: "pack verify",
267 schema_version: crate::commands::pack::PACK_VERIFY_SCHEMA_VERSION,
268 description:
269 "Result of verifying a .harnpack: bundle hash, signature, per-module hashes.",
270 schema_json: Some(crate::commands::pack::verify_json_schema()),
271 },
272 SchemaEntry {
273 command: "dev",
274 schema_version: 1,
275 description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
276 schema_json: None,
277 },
278 SchemaEntry {
279 command: "routes",
280 schema_version: 1,
281 description: "Static trigger route, budget, capability, and vendor-lock inventory.",
282 schema_json: None,
283 },
284 SchemaEntry {
285 command: "graph",
286 schema_version: crate::commands::graph::GRAPH_SCHEMA_VERSION,
287 description:
288 "Static module graph with public symbols, imports, capabilities, effects, and host-call surface.",
289 schema_json: None,
290 },
291 SchemaEntry {
292 command: "lint",
293 schema_version: crate::commands::check::LINT_SCHEMA_VERSION,
294 description:
295 "Per-file lint diagnostics with severity, fixable/fixed counts, and summary.",
296 schema_json: None,
297 },
298 SchemaEntry {
299 command: "replay",
300 schema_version: crate::commands::replay::REPLAY_SCHEMA_VERSION,
301 description:
302 "Replay summary: per-stage status/outcome/branch, embedded fixture verdicts, and multi-run determinism.",
303 schema_json: None,
304 },
305 SchemaEntry {
306 command: "version",
307 schema_version: crate::VERSION_SCHEMA_VERSION,
308 description: "CLI build metadata: name, version, description.",
309 schema_json: None,
310 },
311 SchemaEntry {
312 command: "upgrade",
313 schema_version: crate::commands::upgrade::UPGRADE_SCHEMA_VERSION,
314 description:
315 "Self-update probe (`--check`) or install summary: current, target, archive URL, install outcome.",
316 schema_json: None,
317 },
318 SchemaEntry {
319 command: "explain --catalog",
320 schema_version: crate::commands::diagnostics_catalog::SCHEMA_VERSION,
321 description:
322 "Diagnostic-code catalog: per-code summary, repair, safety, related codes.",
323 schema_json: None,
324 },
325 SchemaEntry {
326 command: "mcp presets",
327 schema_version: crate::commands::mcp::presets::MCP_PRESETS_SCHEMA_VERSION,
328 description:
329 "Canonical catalog of well-known MCP server presets (Notion, Linear, GitHub, filesystem): id, transport, command/url template, auth kind, and required placeholders.",
330 schema_json: None,
331 },
332 ]
333}
334
335pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
338 serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use serde_json::json;
345
346 #[derive(Serialize)]
347 struct Payload {
348 value: u32,
349 }
350
351 #[test]
352 fn ok_envelope_round_trips() {
353 let env = JsonEnvelope::ok(7, Payload { value: 42 });
354 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
355 assert_eq!(v["schemaVersion"], 7);
356 assert_eq!(v["ok"], true);
357 assert_eq!(v["data"]["value"], 42);
358 assert!(v["error"].is_null());
361 assert_eq!(v["warnings"], json!([]));
362 }
363
364 #[test]
365 fn err_envelope_carries_details() {
366 let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
367 .with_details(json!({ "path": "/var/log/harn" }));
368 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
369 assert_eq!(v["schemaVersion"], 2);
370 assert_eq!(v["ok"], false);
371 assert_eq!(v["error"]["code"], "io");
372 assert_eq!(v["error"]["message"], "disk full");
373 assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
374 assert!(v["data"].is_null());
375 }
376
377 #[test]
378 fn warnings_serialize_when_present() {
379 let env = JsonEnvelope::ok(1, Payload { value: 1 })
380 .with_warning("deprecated.flag", "--format=json is deprecated");
381 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
382 assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
383 assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
384 }
385
386 #[test]
387 fn catalog_is_nonempty_and_unique() {
388 let entries = catalog();
389 assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
390 let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
391 commands.sort();
392 let unique_count = {
393 let mut deduped = commands.clone();
394 deduped.dedup();
395 deduped.len()
396 };
397 assert_eq!(commands.len(), unique_count, "command names must be unique");
398 }
399
400 #[test]
401 fn catalog_includes_fix_plan() {
402 let entries = catalog();
403 let entry = entries
404 .iter()
405 .find(|entry| entry.command == "fix plan")
406 .expect("fix plan schema should be registered");
407 assert_eq!(
408 entry.schema_version,
409 crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
410 );
411 let entry = entries
412 .iter()
413 .find(|entry| entry.command == "fix apply")
414 .expect("fix apply schema should be registered");
415 assert_eq!(
416 entry.schema_version,
417 crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
418 );
419 }
420
421 #[test]
422 fn schema_versions_are_positive() {
423 for entry in catalog() {
424 assert!(
425 entry.schema_version >= 1,
426 "{} should have schemaVersion >= 1",
427 entry.command
428 );
429 }
430 }
431}