use serde::{Deserialize, Serialize};
pub const CATALOG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonEnvelope<T: Serialize> {
#[serde(rename = "schemaVersion")]
pub schema_version: u32,
pub ok: bool,
pub data: Option<T>,
pub error: Option<JsonError>,
#[serde(default)]
pub warnings: Vec<JsonWarning>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonError {
pub code: String,
pub message: String,
#[serde(default)]
pub details: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonWarning {
pub code: String,
pub message: String,
}
pub trait JsonOutput {
const SCHEMA_VERSION: u32;
type Data: Serialize;
fn into_envelope(self) -> JsonEnvelope<Self::Data>;
}
impl<T: Serialize> JsonEnvelope<T> {
pub fn ok(schema_version: u32, data: T) -> Self {
Self {
schema_version,
ok: true,
data: Some(data),
error: None,
warnings: Vec::new(),
}
}
pub fn err(
schema_version: u32,
code: impl Into<String>,
message: impl Into<String>,
) -> JsonEnvelope<T> {
Self {
schema_version,
ok: false,
data: None,
error: Some(JsonError {
code: code.into(),
message: message.into(),
details: serde_json::Value::Null,
}),
warnings: Vec::new(),
}
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
if let Some(err) = self.error.as_mut() {
err.details = details;
}
self
}
pub fn with_warning(mut self, code: impl Into<String>, message: impl Into<String>) -> Self {
self.warnings.push(JsonWarning {
code: code.into(),
message: message.into(),
});
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SchemaEntry {
pub command: &'static str,
#[serde(rename = "schemaVersion")]
pub schema_version: u32,
pub description: &'static str,
#[serde(skip_serializing_if = "Option::is_none", rename = "schemaJson")]
pub schema_json: Option<serde_json::Value>,
}
pub fn catalog() -> Vec<SchemaEntry> {
vec![
SchemaEntry {
command: "doctor",
schema_version: crate::commands::doctor::DOCTOR_SCHEMA_VERSION,
description: "Capability matrix: host, per-target buildability, per-provider reachability, per-stdlib-effect availability.",
schema_json: None,
},
SchemaEntry {
command: "session export",
schema_version: 1,
description: "Portable Harn session bundle export.",
schema_json: None,
},
SchemaEntry {
command: "provider-catalog",
schema_version: 1,
description: "Resolved provider/model catalog snapshot.",
schema_json: None,
},
SchemaEntry {
command: "connect status",
schema_version: 1,
description: "Outbound-connector readiness report.",
schema_json: None,
},
SchemaEntry {
command: "connect setup-plan",
schema_version: 1,
description: "Step-by-step plan to bring a connector online.",
schema_json: None,
},
SchemaEntry {
command: "run",
schema_version: crate::commands::run::json_events::RUN_JSON_SCHEMA_VERSION,
description: "Pipeline-run NDJSON event stream (stdout, stderr, transcript, tool, hook, persona, result, error).",
schema_json: None,
},
SchemaEntry {
command: "time run",
schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
description:
"Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
schema_json: None,
},
SchemaEntry {
command: "fix plan",
schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
description: "Plan repair-bearing diagnostics without editing files.",
schema_json: None,
},
SchemaEntry {
command: "fix apply",
schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
description: "Apply clean repair edits at or below a declared safety ceiling.",
schema_json: None,
},
SchemaEntry {
command: "skills list",
schema_version: 1,
description: "Embedded canonical Harn skill corpus, frontmatter only.",
schema_json: None,
},
SchemaEntry {
command: "skills get",
schema_version: 1,
description: "One embedded skill's frontmatter (and body with --full).",
schema_json: None,
},
SchemaEntry {
command: "pack",
schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
description: "Signed-ready .harnpack run-bundle build summary.",
schema_json: None,
},
SchemaEntry {
command: "dev",
schema_version: 1,
description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
schema_json: None,
},
SchemaEntry {
command: "routes",
schema_version: 1,
description: "Static trigger route, budget, capability, and vendor-lock inventory.",
schema_json: None,
},
]
}
pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[derive(Serialize)]
struct Payload {
value: u32,
}
#[test]
fn ok_envelope_round_trips() {
let env = JsonEnvelope::ok(7, Payload { value: 42 });
let v: serde_json::Value = serde_json::to_value(&env).unwrap();
assert_eq!(v["schemaVersion"], 7);
assert_eq!(v["ok"], true);
assert_eq!(v["data"]["value"], 42);
assert!(v["error"].is_null());
assert_eq!(v["warnings"], json!([]));
}
#[test]
fn err_envelope_carries_details() {
let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
.with_details(json!({ "path": "/var/log/harn" }));
let v: serde_json::Value = serde_json::to_value(&env).unwrap();
assert_eq!(v["schemaVersion"], 2);
assert_eq!(v["ok"], false);
assert_eq!(v["error"]["code"], "io");
assert_eq!(v["error"]["message"], "disk full");
assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
assert!(v["data"].is_null());
}
#[test]
fn warnings_serialize_when_present() {
let env = JsonEnvelope::ok(1, Payload { value: 1 })
.with_warning("deprecated.flag", "--format=json is deprecated");
let v: serde_json::Value = serde_json::to_value(&env).unwrap();
assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
}
#[test]
fn catalog_is_nonempty_and_unique() {
let entries = catalog();
assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
commands.sort();
let unique_count = {
let mut deduped = commands.clone();
deduped.dedup();
deduped.len()
};
assert_eq!(commands.len(), unique_count, "command names must be unique");
}
#[test]
fn catalog_includes_fix_plan() {
let entries = catalog();
let entry = entries
.iter()
.find(|entry| entry.command == "fix plan")
.expect("fix plan schema should be registered");
assert_eq!(
entry.schema_version,
crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
);
let entry = entries
.iter()
.find(|entry| entry.command == "fix apply")
.expect("fix apply schema should be registered");
assert_eq!(
entry.schema_version,
crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
);
}
#[test]
fn schema_versions_are_positive() {
for entry in catalog() {
assert!(
entry.schema_version >= 1,
"{} should have schemaVersion >= 1",
entry.command
);
}
}
}