use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Document {
Graph(Graph),
Path(Path),
Step(Step),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Graph {
pub graph: GraphIdentity,
pub paths: Vec<PathOrRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<GraphMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphIdentity {
pub id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GraphMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<Ref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actors: Option<HashMap<String, ActorDefinition>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signatures: Vec<Signature>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PathOrRef {
Path(Box<Path>),
Ref(PathRef),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathRef {
#[serde(rename = "$ref")]
pub ref_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Path {
pub path: PathIdentity,
pub steps: Vec<Step>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<PathMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathIdentity {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<Base>,
pub head: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Base {
pub uri: String,
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub ref_str: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PathMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<Ref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actors: Option<HashMap<String, ActorDefinition>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signatures: Vec<Signature>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub step: StepIdentity,
pub change: HashMap<String, ArtifactChange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<StepMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepIdentity {
pub id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parents: Vec<String>,
pub actor: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactChange {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structural: Option<StructuralChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuralChange {
#[serde(rename = "type")]
pub change_type: String,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StepMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<VcsSource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<Ref>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actors: Option<HashMap<String, ActorDefinition>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signatures: Vec<Signature>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VcsSource {
#[serde(rename = "type")]
pub vcs_type: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub change_id: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ref {
pub rel: String,
pub href: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActorDefinition {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub identities: Vec<Identity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<Key>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
pub system: String,
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Key {
#[serde(rename = "type")]
pub key_type: String,
pub fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
pub signer: String,
pub key: String,
pub scope: String,
pub sig: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
impl Document {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
impl Graph {
pub fn new(id: impl Into<String>) -> Self {
Self {
graph: GraphIdentity { id: id.into() },
paths: Vec::new(),
meta: None,
}
}
}
impl Path {
pub fn new(id: impl Into<String>, base: Option<Base>, head: impl Into<String>) -> Self {
Self {
path: PathIdentity {
id: id.into(),
base,
head: head.into(),
},
steps: Vec::new(),
meta: None,
}
}
}
impl Base {
pub fn vcs(uri: impl Into<String>, ref_str: impl Into<String>) -> Self {
Self {
uri: uri.into(),
ref_str: Some(ref_str.into()),
}
}
pub fn toolpath(path_id: impl Into<String>, step_id: impl Into<String>) -> Self {
Self {
uri: format!("toolpath:{}/{}", path_id.into(), step_id.into()),
ref_str: None,
}
}
}
impl Step {
pub fn new(
id: impl Into<String>,
actor: impl Into<String>,
timestamp: impl Into<String>,
) -> Self {
Self {
step: StepIdentity {
id: id.into(),
parents: Vec::new(),
actor: actor.into(),
timestamp: timestamp.into(),
},
change: HashMap::new(),
meta: None,
}
}
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
self.step.parents.push(parent.into());
self
}
pub fn with_raw_change(mut self, artifact: impl Into<String>, raw: impl Into<String>) -> Self {
self.change.insert(
artifact.into(),
ArtifactChange {
raw: Some(raw.into()),
structural: None,
},
);
self
}
pub fn with_intent(mut self, intent: impl Into<String>) -> Self {
self.meta.get_or_insert_with(StepMeta::default).intent = Some(intent.into());
self
}
pub fn with_vcs_source(
mut self,
vcs_type: impl Into<String>,
revision: impl Into<String>,
) -> Self {
self.meta.get_or_insert_with(StepMeta::default).source = Some(VcsSource {
vcs_type: vcs_type.into(),
revision: revision.into(),
change_id: None,
extra: HashMap::new(),
});
self
}
}
impl ArtifactChange {
pub fn raw(diff: impl Into<String>) -> Self {
Self {
raw: Some(diff.into()),
structural: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_step_builder() {
let step = Step::new("step-001", "human:alex", "2026-01-29T10:00:00Z")
.with_raw_change("src/main.rs", "@@ -1,1 +1,1 @@\n-hello\n+world")
.with_intent("Fix greeting");
let json = serde_json::to_string_pretty(&step).unwrap();
assert!(json.contains("step-001"));
assert!(json.contains("human:alex"));
}
#[test]
fn test_roundtrip() {
let step = Step::new("step-001", "human:alex", "2026-01-29T10:00:00Z")
.with_raw_change("src/main.rs", "@@ -1,1 +1,1 @@\n-hello\n+world");
let json = serde_json::to_string(&step).unwrap();
let parsed: Step = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.step.id, "step-001");
assert_eq!(parsed.step.actor, "human:alex");
}
#[test]
fn test_base_constructors() {
let vcs_base = Base::vcs("github:org/repo", "abc123");
assert_eq!(vcs_base.uri, "github:org/repo");
assert_eq!(vcs_base.ref_str, Some("abc123".to_string()));
let toolpath_base = Base::toolpath("path-main", "step-005");
assert_eq!(toolpath_base.uri, "toolpath:path-main/step-005");
assert_eq!(toolpath_base.ref_str, None);
}
#[test]
fn test_document_step_roundtrip() {
let step =
Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_raw_change("f.rs", "@@");
let doc = Document::Step(step);
let json = doc.to_json().unwrap();
assert!(json.contains("\"Step\""));
let parsed = Document::from_json(&json).unwrap();
match parsed {
Document::Step(s) => assert_eq!(s.step.id, "s1"),
_ => panic!("Expected Step"),
}
}
#[test]
fn test_document_path_roundtrip() {
let step =
Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_raw_change("f.rs", "@@");
let path = Path {
path: PathIdentity {
id: "p1".into(),
base: Some(Base::vcs("github:org/repo", "abc")),
head: "s1".into(),
},
steps: vec![step],
meta: None,
};
let doc = Document::Path(path);
let json = doc.to_json().unwrap();
assert!(json.contains("\"Path\""));
let parsed = Document::from_json(&json).unwrap();
match parsed {
Document::Path(p) => {
assert_eq!(p.path.id, "p1");
assert_eq!(p.steps.len(), 1);
}
_ => panic!("Expected Path"),
}
}
#[test]
fn test_document_graph_roundtrip() {
let graph = Graph::new("g1");
let doc = Document::Graph(graph);
let json = doc.to_json().unwrap();
assert!(json.contains("\"Graph\""));
let parsed = Document::from_json(&json).unwrap();
match parsed {
Document::Graph(g) => assert_eq!(g.graph.id, "g1"),
_ => panic!("Expected Graph"),
}
}
#[test]
fn test_document_to_json_pretty() {
let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z");
let doc = Document::Step(step);
let json = doc.to_json_pretty().unwrap();
assert!(json.contains('\n')); assert!(json.contains("\"Step\""));
}
#[test]
fn test_document_from_json_invalid() {
let result = Document::from_json("not json");
assert!(result.is_err());
}
#[test]
fn test_graph_new() {
let g = Graph::new("my-graph");
assert_eq!(g.graph.id, "my-graph");
assert!(g.paths.is_empty());
assert!(g.meta.is_none());
}
#[test]
fn test_path_new() {
let p = Path::new("p1", Some(Base::vcs("repo", "abc")), "head-step");
assert_eq!(p.path.id, "p1");
assert_eq!(p.path.head, "head-step");
assert!(p.path.base.is_some());
assert!(p.steps.is_empty());
assert!(p.meta.is_none());
}
#[test]
fn test_path_new_no_base() {
let p = Path::new("p1", None, "s1");
assert!(p.path.base.is_none());
}
#[test]
fn test_step_with_parent() {
let step = Step::new("s2", "human:alex", "2026-01-01T00:00:00Z").with_parent("s1");
assert_eq!(step.step.parents, vec!["s1".to_string()]);
}
#[test]
fn test_step_with_multiple_parents() {
let step = Step::new("s3", "human:alex", "2026-01-01T00:00:00Z")
.with_parent("s1")
.with_parent("s2");
assert_eq!(step.step.parents.len(), 2);
}
#[test]
fn test_step_with_intent() {
let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_intent("Fix bug");
assert_eq!(step.meta.unwrap().intent.unwrap(), "Fix bug");
}
#[test]
fn test_step_with_vcs_source() {
let step =
Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_vcs_source("git", "abc123");
let meta = step.meta.unwrap();
let source = meta.source.unwrap();
assert_eq!(source.vcs_type, "git");
assert_eq!(source.revision, "abc123");
assert!(source.change_id.is_none());
}
#[test]
fn test_step_with_raw_change() {
let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z")
.with_raw_change("f.rs", "@@ -1 +1 @@");
assert!(step.change.contains_key("f.rs"));
let change = &step.change["f.rs"];
assert_eq!(change.raw.as_deref(), Some("@@ -1 +1 @@"));
assert!(change.structural.is_none());
}
#[test]
fn test_artifact_change_raw() {
let change = ArtifactChange::raw("diff content");
assert_eq!(change.raw.as_deref(), Some("diff content"));
assert!(change.structural.is_none());
}
#[test]
fn test_path_or_ref_inline_path_roundtrip() {
let path = Path::new("p1", None, "s1");
let por = PathOrRef::Path(Box::new(path));
let json = serde_json::to_string(&por).unwrap();
assert!(json.contains("\"p1\""));
let parsed: PathOrRef = serde_json::from_str(&json).unwrap();
match parsed {
PathOrRef::Path(p) => assert_eq!(p.path.id, "p1"),
_ => panic!("Expected Path"),
}
}
#[test]
fn test_path_or_ref_ref_roundtrip() {
let por = PathOrRef::Ref(PathRef {
ref_url: "https://example.com/path.json".to_string(),
});
let json = serde_json::to_string(&por).unwrap();
assert!(json.contains("$ref"));
let parsed: PathOrRef = serde_json::from_str(&json).unwrap();
match parsed {
PathOrRef::Ref(r) => assert_eq!(r.ref_url, "https://example.com/path.json"),
_ => panic!("Expected Ref"),
}
}
#[test]
fn test_graph_meta_default_skips_empty() {
let g = Graph {
graph: GraphIdentity { id: "g1".into() },
paths: vec![],
meta: Some(GraphMeta::default()),
};
let json = serde_json::to_string(&g).unwrap();
assert!(!json.contains("\"title\""));
assert!(!json.contains("\"refs\""));
}
#[test]
fn test_step_meta_with_refs() {
let step = Step {
step: StepIdentity {
id: "s1".into(),
parents: vec![],
actor: "human:alex".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
},
change: HashMap::new(),
meta: Some(StepMeta {
refs: vec![super::Ref {
rel: "issue".into(),
href: "https://github.com/org/repo/issues/1".into(),
}],
..Default::default()
}),
};
let json = serde_json::to_string(&step).unwrap();
assert!(json.contains("\"issue\""));
assert!(json.contains("issues/1"));
}
#[test]
fn test_identity_serialization() {
let id = super::Identity {
system: "email".into(),
id: "user@example.com".into(),
};
let json = serde_json::to_string(&id).unwrap();
assert!(json.contains("email"));
assert!(json.contains("user@example.com"));
}
#[test]
fn test_structural_change_serialization() {
let mut extra = HashMap::new();
extra.insert("from".to_string(), serde_json::json!("foo"));
extra.insert("to".to_string(), serde_json::json!("bar"));
let sc = StructuralChange {
change_type: "rename_function".into(),
extra,
};
let json = serde_json::to_string(&sc).unwrap();
assert!(json.contains("rename_function"));
assert!(json.contains("\"from\""));
assert!(json.contains("\"bar\""));
}
}