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: "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: "time run",
168 schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
169 description:
170 "Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
171 schema_json: None,
172 },
173 SchemaEntry {
174 command: "fix plan",
175 schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
176 description: "Plan repair-bearing diagnostics without editing files.",
177 schema_json: None,
178 },
179 SchemaEntry {
180 command: "fix apply",
181 schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
182 description: "Apply clean repair edits at or below a declared safety ceiling.",
183 schema_json: None,
184 },
185 SchemaEntry {
186 command: "skills list",
187 schema_version: 1,
188 description: "Embedded canonical Harn skill corpus, frontmatter only.",
189 schema_json: None,
190 },
191 SchemaEntry {
192 command: "skills get",
193 schema_version: 1,
194 description: "One embedded skill's frontmatter (and body with --full).",
195 schema_json: None,
196 },
197 SchemaEntry {
198 command: "pack",
199 schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
200 description: "Signed-ready .harnpack run-bundle build summary.",
201 schema_json: None,
202 },
203 SchemaEntry {
204 command: "dev",
205 schema_version: 1,
206 description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
207 schema_json: None,
208 },
209 SchemaEntry {
210 command: "routes",
211 schema_version: 1,
212 description: "Static trigger route, budget, capability, and vendor-lock inventory.",
213 schema_json: None,
214 },
215 ]
216}
217
218pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
221 serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use serde_json::json;
228
229 #[derive(Serialize)]
230 struct Payload {
231 value: u32,
232 }
233
234 #[test]
235 fn ok_envelope_round_trips() {
236 let env = JsonEnvelope::ok(7, Payload { value: 42 });
237 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
238 assert_eq!(v["schemaVersion"], 7);
239 assert_eq!(v["ok"], true);
240 assert_eq!(v["data"]["value"], 42);
241 assert!(v["error"].is_null());
244 assert_eq!(v["warnings"], json!([]));
245 }
246
247 #[test]
248 fn err_envelope_carries_details() {
249 let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
250 .with_details(json!({ "path": "/var/log/harn" }));
251 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
252 assert_eq!(v["schemaVersion"], 2);
253 assert_eq!(v["ok"], false);
254 assert_eq!(v["error"]["code"], "io");
255 assert_eq!(v["error"]["message"], "disk full");
256 assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
257 assert!(v["data"].is_null());
258 }
259
260 #[test]
261 fn warnings_serialize_when_present() {
262 let env = JsonEnvelope::ok(1, Payload { value: 1 })
263 .with_warning("deprecated.flag", "--format=json is deprecated");
264 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
265 assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
266 assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
267 }
268
269 #[test]
270 fn catalog_is_nonempty_and_unique() {
271 let entries = catalog();
272 assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
273 let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
274 commands.sort();
275 let unique_count = {
276 let mut deduped = commands.clone();
277 deduped.dedup();
278 deduped.len()
279 };
280 assert_eq!(commands.len(), unique_count, "command names must be unique");
281 }
282
283 #[test]
284 fn catalog_includes_fix_plan() {
285 let entries = catalog();
286 let entry = entries
287 .iter()
288 .find(|entry| entry.command == "fix plan")
289 .expect("fix plan schema should be registered");
290 assert_eq!(
291 entry.schema_version,
292 crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
293 );
294 let entry = entries
295 .iter()
296 .find(|entry| entry.command == "fix apply")
297 .expect("fix apply schema should be registered");
298 assert_eq!(
299 entry.schema_version,
300 crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
301 );
302 }
303
304 #[test]
305 fn schema_versions_are_positive() {
306 for entry in catalog() {
307 assert!(
308 entry.schema_version >= 1,
309 "{} should have schemaVersion >= 1",
310 entry.command
311 );
312 }
313 }
314}