use std::sync::OnceLock;
use jsonschema::Validator;
const SCHEMA_SOURCE: &str = toolpath::SCHEMA_JSON;
fn validator() -> &'static Validator {
static VALIDATOR: OnceLock<Validator> = OnceLock::new();
VALIDATOR.get_or_init(|| {
let schema: serde_json::Value = serde_json::from_str(SCHEMA_SOURCE)
.expect("toolpath.schema.json embedded in binary parses as JSON");
jsonschema::validator_for(&schema)
.expect("toolpath.schema.json embedded in binary is itself a valid JSON Schema")
})
}
pub fn validate(instance: &serde_json::Value) -> anyhow::Result<()> {
let errors: Vec<String> = validator()
.iter_errors(instance)
.map(|err| {
let pointer = err.instance_path().as_str();
let location = if pointer.is_empty() { "/" } else { pointer };
format!(" at {location}: {err}")
})
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!(
"schema validation failed:\n{}",
errors.join("\n")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn embedded_schema_compiles() {
let _ = validator();
}
#[test]
fn empty_graph_is_valid() {
validate(&json!({"graph": {"id": "g1"}, "paths": []}))
.expect("an empty graph is the simplest valid document");
}
#[test]
fn single_path_graph_is_valid() {
let doc = json!({
"graph": {"id": "g1"},
"paths": [{
"path": {"id": "p1", "head": "s1"},
"steps": [{
"step": {
"id": "s1",
"actor": "human:alex",
"timestamp": "2026-01-29T10:00:00Z"
},
"change": {"src/main.rs": {"raw": "@@ -1 +1 @@\n-old\n+new"}}
}]
}]
});
validate(&doc).expect("single-path single-step graph should validate");
}
#[test]
fn path_base_rejects_unknown_field() {
let doc = json!({
"graph": {"id": "g1"},
"paths": [{
"path": {
"id": "p1",
"base": {
"uri": "github:org/repo",
"ref": "abc123",
"commit": "abc123"
},
"head": "s1"
},
"steps": [{
"step": {
"id": "s1",
"actor": "human:alex",
"timestamp": "2026-01-29T10:00:00Z"
},
"change": {"src/main.rs": {"raw": "@@ -1 +1 @@\n-a\n+b"}}
}]
}]
});
let err = validate(&doc).expect_err("commit is not a permitted base property");
let msg = err.to_string();
assert!(
msg.contains("commit"),
"error should mention the offending field, got: {msg}"
);
}
#[test]
fn path_base_accepts_branch_alongside_ref() {
let doc = json!({
"graph": {"id": "g1"},
"paths": [{
"path": {
"id": "p1",
"base": {
"uri": "github:org/repo",
"ref": "abc123def456",
"branch": "main"
},
"head": "s1"
},
"steps": [{
"step": {
"id": "s1",
"actor": "human:alex",
"timestamp": "2026-01-29T10:00:00Z"
},
"change": {"src/main.rs": {"raw": "@@ -1 +1 @@\n-a\n+b"}}
}]
}]
});
validate(&doc).expect("base may carry both ref and branch");
}
#[test]
fn path_without_base_is_valid() {
let doc = json!({
"graph": {"id": "g1"},
"paths": [{
"path": {"id": "p1", "head": "s1"},
"steps": [{
"step": {
"id": "s1",
"actor": "human:alex",
"timestamp": "2026-01-29T10:00:00Z"
},
"change": {"src/main.rs": {"raw": "@@ -1 +1 @@\n-a\n+b"}}
}]
}]
});
validate(&doc).expect("base is optional on path identity");
}
}