use std::collections::HashMap;
use std::sync::OnceLock;
use jsonschema::Validator;
const SCHEMA_SOURCE: &str = toolpath::SCHEMA_JSON;
const KIND_SCHEMAS: &[(&str, &str)] = &[(
"https://toolpath.net/kinds/agent-coding-session/v1.0.0",
include_str!("../kinds/agent-coding-session/v1.0.0/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")
})
}
fn kind_validators() -> &'static HashMap<&'static str, Validator> {
static VALIDATORS: OnceLock<HashMap<&'static str, Validator>> = OnceLock::new();
VALIDATORS.get_or_init(|| {
KIND_SCHEMAS
.iter()
.map(|(uri, source)| {
let schema: serde_json::Value = serde_json::from_str(source)
.unwrap_or_else(|e| panic!("bundled kind schema {uri} is not valid JSON: {e}"));
let v = jsonschema::validator_for(&schema).unwrap_or_else(|e| {
panic!("bundled kind schema {uri} is not a valid JSON Schema: {e}")
});
(*uri, v)
})
.collect()
})
}
pub fn validate(instance: &serde_json::Value) -> anyhow::Result<()> {
let mut 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 let Some(paths) = instance.get("paths").and_then(|p| p.as_array()) {
let kinds = kind_validators();
for (i, path) in paths.iter().enumerate() {
let Some(kind) = path.pointer("/meta/kind").and_then(|k| k.as_str()) else {
continue;
};
let Some(kv) = kinds.get(kind) else {
continue;
};
for err in kv.iter_errors(path) {
errors.push(format!(
" at /paths/{i}{}: {err} (kind {kind})",
err.instance_path()
));
}
}
}
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");
}
const ACS_KIND: &str = "https://toolpath.net/kinds/agent-coding-session/v1.0.0";
fn acs_graph(append: serde_json::Value) -> serde_json::Value {
json!({
"graph": {"id": "g1"},
"paths": [{
"path": {"id": "p1", "head": "s1"},
"meta": {"kind": ACS_KIND},
"steps": [{
"step": {
"id": "s1",
"actor": "human:user",
"timestamp": "2026-01-29T10:00:00Z"
},
"change": {"agent://claude-code/s1": {"structural": append}}
}]
}]
})
}
#[test]
fn agent_coding_session_kind_validates_when_well_formed() {
let doc = acs_graph(json!({
"type": "conversation.append",
"role": "user",
"text": "hi"
}));
validate(&doc).expect("a well-formed agent-coding-session path should pass base + kind");
}
#[test]
fn agent_coding_session_kind_constraints_are_enforced() {
let doc = acs_graph(json!({
"type": "conversation.append",
"role": "user"
}));
let err = validate(&doc).expect_err("missing `text` violates the kind");
let msg = err.to_string();
assert!(
msg.contains("text"),
"error should name the missing field: {msg}"
);
assert!(
msg.contains(ACS_KIND),
"error should attribute the kind: {msg}"
);
}
#[test]
fn unknown_kind_is_treated_as_generic() {
let mut doc = acs_graph(json!({
"type": "conversation.append",
"role": "user"
}));
doc["paths"][0]["meta"]["kind"] = json!("https://toolpath.net/kinds/made-up/v9.9.9");
validate(&doc).expect("an unknown kind imposes no extra constraints");
}
#[test]
fn derived_claude_path_conforms_to_its_own_kind() {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-fixtures/claude/convo.jsonl");
let convo = toolpath_claude::ConversationReader::read_conversation(&fixture)
.expect("read claude fixture");
let path = toolpath_claude::derive::derive_path(&convo, &Default::default());
assert_eq!(
path.meta.as_ref().and_then(|m| m.kind.as_deref()),
Some(ACS_KIND),
"derive_path must stamp the agent-coding-session kind"
);
let doc = json!({
"graph": {"id": "g1"},
"paths": [serde_json::to_value(&path).unwrap()],
});
validate(&doc).expect("derived claude path should satisfy base + its own kind");
}
}