use crate::types::{
ActorDefinition, Base, Graph, Path, PathIdentity, PathMeta, PathOrRef, Ref, Signature, Step,
StepMeta,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::io::{BufRead, Write};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum JsonlLine {
PathOpen(PathOpenBody),
Step(StepBody),
ActorDef(ActorDefBody),
Signature(SignatureBody),
PathMeta(PathMetaBody),
Head(HeadBody),
PathClose(PathCloseBody),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathOpenBody {
pub version: String,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<Base>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<PathOpenMeta>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PathOpenMeta {
#[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(flatten, default)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StepBody(pub Step);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorDefBody {
pub actor: String,
pub definition: ActorDefinition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureBody {
pub target: String,
pub signature: Signature,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathMetaBody {
pub patch: PathMetaPatch,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PathMetaPatch {
#[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 = "Option::is_none")]
pub refs: Option<Vec<Ref>>,
#[serde(flatten, default)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadBody {
pub step_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PathCloseBody {}
#[derive(Debug)]
pub enum JsonlError {
Io(std::io::Error),
Empty,
FirstLineNotPathOpen { line_num: usize },
DuplicatePathOpen { line_num: usize },
MalformedJson {
line_num: usize,
source: serde_json::Error,
},
NotAnObject { line_num: usize },
NotSingleKey { line_num: usize },
BadBody {
line_num: usize,
tag: String,
source: serde_json::Error,
},
OrphanStepSignature { line_num: usize, step_id: String },
AmbiguousHead { candidates: Vec<String> },
NoSteps,
AfterClose { line_num: usize },
NotSinglePathGraph { path_count: usize },
}
impl fmt::Display for JsonlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
JsonlError::Io(e) => write!(f, "I/O error: {e}"),
JsonlError::Empty => write!(f, "empty JSONL stream (expected a PathOpen line)"),
JsonlError::FirstLineNotPathOpen { line_num } => {
write!(f, "line {line_num}: first line must be a PathOpen")
}
JsonlError::DuplicatePathOpen { line_num } => {
write!(f, "line {line_num}: duplicate PathOpen mid-stream")
}
JsonlError::MalformedJson { line_num, source } => {
write!(f, "line {line_num}: malformed JSON: {source}")
}
JsonlError::NotAnObject { line_num } => {
write!(f, "line {line_num}: expected a JSON object")
}
JsonlError::NotSingleKey { line_num } => write!(
f,
"line {line_num}: expected a single-key object {{\"<Variant>\": ...}}"
),
JsonlError::BadBody {
line_num,
tag,
source,
} => write!(f, "line {line_num}: invalid body for {tag}: {source}"),
JsonlError::OrphanStepSignature { line_num, step_id } => write!(
f,
"line {line_num}: Signature targets step {step_id:?} which has not appeared"
),
JsonlError::AmbiguousHead { candidates } => write!(
f,
"no Head line and head cannot be inferred (candidates: {candidates:?})"
),
JsonlError::NoSteps => write!(f, "no Head line and no steps in file"),
JsonlError::AfterClose { line_num } => {
write!(f, "line {line_num}: unexpected line after PathClose")
}
JsonlError::NotSinglePathGraph { path_count } => write!(
f,
"JSONL only encodes single-path graphs (got {path_count} paths)"
),
}
}
}
impl std::error::Error for JsonlError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
JsonlError::Io(e) => Some(e),
JsonlError::MalformedJson { source, .. } | JsonlError::BadBody { source, .. } => {
Some(source)
}
_ => None,
}
}
}
impl From<std::io::Error> for JsonlError {
fn from(e: std::io::Error) -> Self {
JsonlError::Io(e)
}
}
#[allow(clippy::large_enum_variant)]
enum ParsedLine {
Known(JsonlLine),
Unknown { tag: String },
}
fn parse_line(line: &str, line_num: usize) -> Result<ParsedLine, JsonlError> {
let value: serde_json::Value = serde_json::from_str(line)
.map_err(|source| JsonlError::MalformedJson { line_num, source })?;
let obj = value
.as_object()
.ok_or(JsonlError::NotAnObject { line_num })?;
if obj.len() != 1 {
return Err(JsonlError::NotSingleKey { line_num });
}
let tag = obj.keys().next().cloned().unwrap();
match tag.as_str() {
"PathOpen" | "Step" | "ActorDef" | "Signature" | "PathMeta" | "Head" | "PathClose" => {
let line_obj = serde_json::from_value::<JsonlLine>(value).map_err(|source| {
JsonlError::BadBody {
line_num,
tag: tag.clone(),
source,
}
})?;
Ok(ParsedLine::Known(line_obj))
}
_ => Ok(ParsedLine::Unknown { tag }),
}
}
impl Path {
pub fn from_jsonl_reader<R: BufRead>(reader: R) -> Result<Self, JsonlError> {
let mut lines_iter = reader.lines().enumerate();
let (path_id, base, graph_ref, mut path_meta) = loop {
match lines_iter.next() {
None => return Err(JsonlError::Empty),
Some((idx, io_res)) => {
let line = io_res?;
if line.is_empty() {
continue;
}
let line_num = idx + 1;
match parse_line(&line, line_num)? {
ParsedLine::Known(JsonlLine::PathOpen(po)) => {
let mut meta = PathMeta::default();
if let Some(m) = po.meta {
meta.title = m.title;
meta.source = m.source;
meta.intent = m.intent;
meta.refs = m.refs;
meta.extra = m.extra;
}
break (po.id, po.base, po.graph_ref, meta);
}
ParsedLine::Known(_) | ParsedLine::Unknown { .. } => {
return Err(JsonlError::FirstLineNotPathOpen { line_num });
}
}
}
}
};
let mut steps: Vec<Step> = Vec::new();
let mut step_idx: HashMap<String, usize> = HashMap::new();
let mut head: Option<String> = None;
let mut closed = false;
for (idx, io_res) in lines_iter {
let line = io_res?;
if line.is_empty() {
continue;
}
let line_num = idx + 1;
if closed {
return Err(JsonlError::AfterClose { line_num });
}
match parse_line(&line, line_num)? {
ParsedLine::Known(JsonlLine::PathOpen(_)) => {
return Err(JsonlError::DuplicatePathOpen { line_num });
}
ParsedLine::Known(JsonlLine::Step(StepBody(step))) => {
let id = step.step.id.clone();
step_idx.insert(id, steps.len());
steps.push(step);
}
ParsedLine::Known(JsonlLine::ActorDef(body)) => {
let actors = path_meta.actors.get_or_insert_with(HashMap::new);
actors.insert(body.actor, body.definition);
}
ParsedLine::Known(JsonlLine::Signature(body)) => {
apply_signature(&mut path_meta, &mut steps, &step_idx, body, line_num)?;
}
ParsedLine::Known(JsonlLine::PathMeta(body)) => {
apply_meta_patch(&mut path_meta, body.patch);
}
ParsedLine::Known(JsonlLine::Head(body)) => {
head = Some(body.step_id);
}
ParsedLine::Known(JsonlLine::PathClose(_)) => {
closed = true;
}
ParsedLine::Unknown { tag } => {
eprintln!(
"toolpath::jsonl: line {line_num}: unknown variant {tag:?}, skipping"
);
}
}
}
let head = resolve_head(head, &steps)?;
let meta = if path_meta_is_empty(&path_meta) {
None
} else {
Some(path_meta)
};
Ok(Path {
path: PathIdentity {
id: path_id,
base,
head,
graph_ref,
},
steps,
meta,
})
}
pub fn from_jsonl_str(s: &str) -> Result<Self, JsonlError> {
Self::from_jsonl_reader(std::io::Cursor::new(s))
}
}
impl Graph {
pub fn from_jsonl_reader<R: BufRead>(reader: R) -> Result<Self, JsonlError> {
let path = Path::from_jsonl_reader(reader)?;
Ok(Graph::from_path(path))
}
pub fn from_jsonl_str(s: &str) -> Result<Self, JsonlError> {
Self::from_jsonl_reader(std::io::Cursor::new(s))
}
pub fn to_jsonl_writer<W: Write>(&self, w: &mut W) -> Result<(), JsonlError> {
match self.paths.as_slice() {
[PathOrRef::Path(p)] => p.to_jsonl_writer(w),
other => Err(JsonlError::NotSinglePathGraph {
path_count: other.len(),
}),
}
}
pub fn to_jsonl_string(&self) -> Result<String, JsonlError> {
let mut buf: Vec<u8> = Vec::new();
self.to_jsonl_writer(&mut buf)?;
Ok(String::from_utf8(buf).expect("jsonl writer emits utf-8"))
}
}
fn apply_signature(
path_meta: &mut PathMeta,
steps: &mut [Step],
step_idx: &HashMap<String, usize>,
body: SignatureBody,
line_num: usize,
) -> Result<(), JsonlError> {
if body.target == "path" {
path_meta.signatures.push(body.signature);
return Ok(());
}
if let Some(step_id) = body.target.strip_prefix("step:") {
let idx =
step_idx
.get(step_id)
.copied()
.ok_or_else(|| JsonlError::OrphanStepSignature {
line_num,
step_id: step_id.to_string(),
})?;
let step = &mut steps[idx];
let meta = step.meta.get_or_insert_with(StepMeta::default);
meta.signatures.push(body.signature);
return Ok(());
}
Err(JsonlError::OrphanStepSignature {
line_num,
step_id: body.target,
})
}
fn apply_meta_patch(path_meta: &mut PathMeta, patch: PathMetaPatch) {
if let Some(v) = patch.title {
path_meta.title = Some(v);
}
if let Some(v) = patch.source {
path_meta.source = Some(v);
}
if let Some(v) = patch.intent {
path_meta.intent = Some(v);
}
if let Some(v) = patch.refs {
path_meta.refs = v;
}
for (k, v) in patch.extra {
path_meta.extra.insert(k, v);
}
}
fn resolve_head(explicit: Option<String>, steps: &[Step]) -> Result<String, JsonlError> {
if let Some(h) = explicit {
return Ok(h);
}
if steps.is_empty() {
return Err(JsonlError::NoSteps);
}
let mut referenced: HashSet<&str> = HashSet::new();
for s in steps {
for p in &s.step.parents {
referenced.insert(p.as_str());
}
}
let candidates: Vec<String> = steps
.iter()
.map(|s| s.step.id.as_str())
.filter(|id| !referenced.contains(*id))
.map(str::to_string)
.collect();
if candidates.len() == 1 {
Ok(candidates.into_iter().next().unwrap())
} else {
Err(JsonlError::AmbiguousHead { candidates })
}
}
fn path_meta_is_empty(m: &PathMeta) -> bool {
m.title.is_none()
&& m.source.is_none()
&& m.intent.is_none()
&& m.refs.is_empty()
&& m.actors.as_ref().is_none_or(|a| a.is_empty())
&& m.signatures.is_empty()
&& m.extra.is_empty()
}
impl Path {
pub fn to_jsonl_writer<W: Write>(&self, w: &mut W) -> Result<(), JsonlError> {
let open_meta = self.meta.as_ref().and_then(path_meta_for_open);
let open = JsonlLine::PathOpen(PathOpenBody {
version: "1".to_string(),
id: self.path.id.clone(),
base: self.path.base.clone(),
graph_ref: self.path.graph_ref.clone(),
meta: open_meta,
});
write_line(w, &open)?;
if let Some(actors) = self.meta.as_ref().and_then(|m| m.actors.as_ref()) {
let sorted: BTreeMap<&String, &ActorDefinition> = actors.iter().collect();
for (actor, def) in sorted {
let line = JsonlLine::ActorDef(ActorDefBody {
actor: actor.clone(),
definition: def.clone(),
});
write_line(w, &line)?;
}
}
for step in &self.steps {
let mut trimmed = step.clone();
let step_sigs: Vec<Signature> = trimmed
.meta
.as_mut()
.map(|m| std::mem::take(&mut m.signatures))
.unwrap_or_default();
if let Some(m) = trimmed.meta.as_ref()
&& step_meta_is_empty(m)
{
trimmed.meta = None;
}
let line = JsonlLine::Step(StepBody(trimmed));
write_line(w, &line)?;
for sig in step_sigs {
let sig_line = JsonlLine::Signature(SignatureBody {
target: format!("step:{}", step.step.id),
signature: sig,
});
write_line(w, &sig_line)?;
}
}
if let Some(meta) = &self.meta {
for sig in &meta.signatures {
let line = JsonlLine::Signature(SignatureBody {
target: "path".to_string(),
signature: sig.clone(),
});
write_line(w, &line)?;
}
}
write_line(
w,
&JsonlLine::Head(HeadBody {
step_id: self.path.head.clone(),
}),
)?;
write_line(w, &JsonlLine::PathClose(PathCloseBody {}))?;
Ok(())
}
pub fn to_jsonl_string(&self) -> Result<String, JsonlError> {
let mut buf: Vec<u8> = Vec::new();
self.to_jsonl_writer(&mut buf)?;
Ok(String::from_utf8(buf).expect("jsonl writer emits utf-8"))
}
}
fn write_line<W: Write>(w: &mut W, line: &JsonlLine) -> Result<(), JsonlError> {
let s = serde_json::to_string(line).map_err(|e| JsonlError::BadBody {
line_num: 0,
tag: "<writer>".into(),
source: e,
})?;
w.write_all(s.as_bytes())?;
w.write_all(b"\n")?;
Ok(())
}
fn step_meta_is_empty(m: &StepMeta) -> bool {
m.intent.is_none()
&& m.source.is_none()
&& m.refs.is_empty()
&& m.actors.as_ref().is_none_or(|a| a.is_empty())
&& m.signatures.is_empty()
&& m.extra.is_empty()
}
fn path_meta_for_open(m: &PathMeta) -> Option<PathOpenMeta> {
let open = PathOpenMeta {
title: m.title.clone(),
source: m.source.clone(),
intent: m.intent.clone(),
refs: m.refs.clone(),
extra: m.extra.clone(),
};
if open.title.is_none()
&& open.source.is_none()
&& open.intent.is_none()
&& open.refs.is_empty()
&& open.extra.is_empty()
{
None
} else {
Some(open)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ArtifactChange, Ref};
use serde_json::json;
use std::collections::HashMap;
fn make_step(id: &str, parent: Option<&str>) -> Step {
let mut s = Step::new(id, "human:alex", "2026-01-01T00:00:00Z")
.with_raw_change("src/main.rs", "@@ -1 +1 @@\n-a\n+b");
if let Some(p) = parent {
s = s.with_parent(p);
}
s
}
fn canonical_json(path: &Path) -> serde_json::Value {
serde_json::to_value(path).unwrap()
}
#[test]
fn path_open_serde_wire_shape() {
let wire = r#"{"PathOpen":{"version":"1","id":"pr-42","base":{"uri":"github:org/repo","ref":"abc"},"meta":{"title":"T"}}}"#;
let line: JsonlLine = serde_json::from_str(wire).unwrap();
let back = serde_json::to_string(&line).unwrap();
let a: serde_json::Value = serde_json::from_str(wire).unwrap();
let b: serde_json::Value = serde_json::from_str(&back).unwrap();
assert_eq!(a, b);
}
#[test]
fn step_body_is_transparent() {
let step = make_step("s1", None);
let body = StepBody(step.clone());
let wire = serde_json::to_value(&body).unwrap();
let direct = serde_json::to_value(&step).unwrap();
assert_eq!(wire, direct);
}
#[test]
fn head_body_serde() {
let wire = r#"{"Head":{"step_id":"s5"}}"#;
let line: JsonlLine = serde_json::from_str(wire).unwrap();
assert!(matches!(line, JsonlLine::Head(HeadBody { ref step_id }) if step_id == "s5"));
}
#[test]
fn path_close_empty_object() {
let wire = r#"{"PathClose":{}}"#;
let line: JsonlLine = serde_json::from_str(wire).unwrap();
assert!(matches!(line, JsonlLine::PathClose(_)));
}
#[test]
fn reader_empty_stream_fatal() {
let err = Path::from_jsonl_str("").unwrap_err();
assert!(matches!(err, JsonlError::Empty));
}
#[test]
fn reader_first_line_not_path_open_fatal() {
let err = Path::from_jsonl_str(r#"{"Head":{"step_id":"s1"}}"#).unwrap_err();
assert!(matches!(err, JsonlError::FirstLineNotPathOpen { .. }));
}
#[test]
fn reader_first_line_malformed_step_body_is_bad_body() {
let err = Path::from_jsonl_str(r#"{"Step":{}}"#).unwrap_err();
assert!(matches!(err, JsonlError::BadBody { .. }));
}
#[test]
fn reader_malformed_json_fatal() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
"not json at all\n",
);
let err = Path::from_jsonl_str(input).unwrap_err();
assert!(matches!(err, JsonlError::MalformedJson { line_num: 2, .. }));
}
#[test]
fn reader_duplicate_path_open_fatal() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"PathOpen":{"version":"1","id":"p2"}}"#,
"\n",
);
let err = Path::from_jsonl_str(input).unwrap_err();
assert!(matches!(err, JsonlError::DuplicatePathOpen { line_num: 3 }));
}
#[test]
fn reader_orphan_step_signature_fatal() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"Signature":{"target":"step:nope","signature":{"signer":"s","key":"k","scope":"author","sig":"x"}}}"#,
"\n",
);
let err = Path::from_jsonl_str(input).unwrap_err();
assert!(matches!(err, JsonlError::OrphanStepSignature { .. }));
}
#[test]
fn reader_ambiguous_head_fatal() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"Step":{"step":{"id":"s2","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
);
let err = Path::from_jsonl_str(input).unwrap_err();
assert!(matches!(err, JsonlError::AmbiguousHead { .. }));
}
#[test]
fn reader_no_steps_fatal() {
let input = concat!(r#"{"PathOpen":{"version":"1","id":"p"}}"#, "\n");
let err = Path::from_jsonl_str(input).unwrap_err();
assert!(matches!(err, JsonlError::NoSteps));
}
#[test]
fn reader_after_close_fatal() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"Head":{"step_id":"s1"}}"#,
"\n",
r#"{"PathClose":{}}"#,
"\n",
r#"{"Step":{"step":{"id":"s2","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
);
let err = Path::from_jsonl_str(input).unwrap_err();
assert!(matches!(err, JsonlError::AfterClose { line_num: 5 }));
}
#[test]
fn reader_unknown_variant_skipped() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"FutureKind":{"whatever":true}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"Head":{"step_id":"s1"}}"#,
"\n",
);
let path = Path::from_jsonl_str(input).expect("unknown variant must be skipped, not fatal");
assert_eq!(path.steps.len(), 1);
}
#[test]
fn reader_linear_path_with_inferred_head() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"Step":{"step":{"id":"s2","parents":["s1"],"actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
);
let path = Path::from_jsonl_str(input).unwrap();
assert_eq!(path.path.head, "s2");
}
#[test]
fn reader_actor_def_last_write_wins() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"ActorDef":{"actor":"human:alex","definition":{"name":"Alex v1"}}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"ActorDef":{"actor":"human:alex","definition":{"name":"Alex v2"}}}"#,
"\n",
r#"{"Head":{"step_id":"s1"}}"#,
"\n",
);
let path = Path::from_jsonl_str(input).unwrap();
let actors = path.meta.unwrap().actors.unwrap();
assert_eq!(actors["human:alex"].name.as_deref(), Some("Alex v2"));
}
#[test]
fn reader_path_meta_patch_refs_replace() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p","meta":{"refs":[{"rel":"fixes","href":"issue:1"}]}}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"PathMeta":{"patch":{"refs":[{"rel":"tracks","href":"issue:2"}]}}}"#,
"\n",
r#"{"Head":{"step_id":"s1"}}"#,
"\n",
);
let path = Path::from_jsonl_str(input).unwrap();
let refs = path.meta.unwrap().refs;
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].rel, "tracks");
}
#[test]
fn reader_path_meta_extra_merges() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p","meta":{"custom_a":1}}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"PathMeta":{"patch":{"custom_b":2}}}"#,
"\n",
r#"{"Head":{"step_id":"s1"}}"#,
"\n",
);
let path = Path::from_jsonl_str(input).unwrap();
let extra = path.meta.unwrap().extra;
assert_eq!(extra.get("custom_a"), Some(&json!(1)));
assert_eq!(extra.get("custom_b"), Some(&json!(2)));
}
#[test]
fn reader_step_signature_attached() {
let input = concat!(
r#"{"PathOpen":{"version":"1","id":"p"}}"#,
"\n",
r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#,
"\n",
r#"{"Signature":{"target":"step:s1","signature":{"signer":"human:alex","key":"gpg:A","scope":"author","sig":"X"}}}"#,
"\n",
r#"{"Head":{"step_id":"s1"}}"#,
"\n",
);
let path = Path::from_jsonl_str(input).unwrap();
let sigs = &path.steps[0].meta.as_ref().unwrap().signatures;
assert_eq!(sigs.len(), 1);
assert_eq!(sigs[0].scope, "author");
}
#[test]
fn writer_emits_path_open_first() {
let path = Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s1".into(),
graph_ref: None,
},
steps: vec![make_step("s1", None)],
meta: None,
};
let jsonl = path.to_jsonl_string().unwrap();
let first = jsonl.lines().next().unwrap();
assert!(first.starts_with(r#"{"PathOpen":"#));
}
#[test]
fn writer_sorts_actor_defs() {
let mut actors = HashMap::new();
actors.insert(
"human:zoe".to_string(),
ActorDefinition {
name: Some("Zoe".into()),
..Default::default()
},
);
actors.insert(
"human:alex".to_string(),
ActorDefinition {
name: Some("Alex".into()),
..Default::default()
},
);
let meta = PathMeta {
actors: Some(actors),
..Default::default()
};
let path = Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s1".into(),
graph_ref: None,
},
steps: vec![make_step("s1", None)],
meta: Some(meta),
};
let jsonl = path.to_jsonl_string().unwrap();
let actor_lines: Vec<&str> = jsonl
.lines()
.filter(|l| l.starts_with(r#"{"ActorDef":"#))
.collect();
assert_eq!(actor_lines.len(), 2);
let alex_idx = actor_lines.iter().position(|l| l.contains("alex")).unwrap();
let zoe_idx = actor_lines.iter().position(|l| l.contains("zoe")).unwrap();
assert!(alex_idx < zoe_idx, "alex should come before zoe");
}
#[test]
fn writer_strips_step_signatures_from_step_body() {
let mut step = make_step("s1", None);
step.meta = Some(StepMeta {
signatures: vec![Signature {
signer: "human:alex".into(),
key: "gpg:A".into(),
scope: "author".into(),
sig: "X".into(),
timestamp: None,
}],
..Default::default()
});
let path = Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s1".into(),
graph_ref: None,
},
steps: vec![step],
meta: None,
};
let jsonl = path.to_jsonl_string().unwrap();
let step_line = jsonl
.lines()
.find(|l| l.starts_with(r#"{"Step":"#))
.unwrap();
assert!(
!step_line.contains("\"signatures\""),
"step body should not carry its own signatures: {step_line}"
);
let sig_line = jsonl
.lines()
.find(|l| l.starts_with(r#"{"Signature":"#))
.unwrap();
assert!(sig_line.contains(r#""target":"step:s1""#));
}
fn linear_path() -> Path {
Path {
path: PathIdentity {
id: "p".into(),
base: Some(Base::vcs("github:org/repo", "abc123")),
head: "s2".into(),
graph_ref: None,
},
steps: vec![make_step("s1", None), make_step("s2", Some("s1"))],
meta: None,
}
}
fn signed_path_with_actors() -> Path {
let mut actors = HashMap::new();
actors.insert(
"human:alex".to_string(),
ActorDefinition {
name: Some("Alex Kesling".into()),
..Default::default()
},
);
let sig = Signature {
signer: "human:alex".into(),
key: "gpg:A".into(),
scope: "author".into(),
sig: "SIG".into(),
timestamp: None,
};
Path {
path: PathIdentity {
id: "p".into(),
base: Some(Base::vcs("github:org/repo", "abc123")),
head: "s1".into(),
graph_ref: None,
},
steps: vec![make_step("s1", None)],
meta: Some(PathMeta {
title: Some("Test".into()),
actors: Some(actors),
signatures: vec![sig],
..Default::default()
}),
}
}
fn path_with_step_signature() -> Path {
let mut step = make_step("s1", None);
step.meta = Some(StepMeta {
intent: Some("fix bug".into()),
signatures: vec![Signature {
signer: "human:alex".into(),
key: "gpg:A".into(),
scope: "author".into(),
sig: "SIG".into(),
timestamp: None,
}],
..Default::default()
});
Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s1".into(),
graph_ref: None,
},
steps: vec![step],
meta: None,
}
}
fn path_with_graph_ref() -> Path {
Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s1".into(),
graph_ref: Some("toolpath://archive/release-v2".into()),
},
steps: vec![make_step("s1", None)],
meta: None,
}
}
fn path_with_dead_end() -> Path {
Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s2".into(),
graph_ref: None,
},
steps: vec![
make_step("s1", None),
make_step("s2", Some("s1")),
make_step("s_dead", Some("s1")),
],
meta: None,
}
}
#[test]
fn roundtrip_linear_path() {
let p = linear_path();
let jsonl = p.to_jsonl_string().unwrap();
let back = Path::from_jsonl_str(&jsonl).unwrap();
assert_eq!(canonical_json(&p), canonical_json(&back));
}
#[test]
fn roundtrip_signed_with_actors() {
let p = signed_path_with_actors();
let jsonl = p.to_jsonl_string().unwrap();
let back = Path::from_jsonl_str(&jsonl).unwrap();
assert_eq!(canonical_json(&p), canonical_json(&back));
}
#[test]
fn roundtrip_step_signature() {
let p = path_with_step_signature();
let jsonl = p.to_jsonl_string().unwrap();
let back = Path::from_jsonl_str(&jsonl).unwrap();
assert_eq!(canonical_json(&p), canonical_json(&back));
}
#[test]
fn roundtrip_graph_ref() {
let p = path_with_graph_ref();
let jsonl = p.to_jsonl_string().unwrap();
let back = Path::from_jsonl_str(&jsonl).unwrap();
assert_eq!(canonical_json(&p), canonical_json(&back));
}
#[test]
fn roundtrip_dead_end_uses_explicit_head() {
let p = path_with_dead_end();
let jsonl = p.to_jsonl_string().unwrap();
assert!(jsonl.contains(r#"{"Head":{"step_id":"s2"}}"#));
let back = Path::from_jsonl_str(&jsonl).unwrap();
assert_eq!(canonical_json(&p), canonical_json(&back));
}
#[test]
fn roundtrip_refs_in_path_meta() {
let p = Path {
path: PathIdentity {
id: "p".into(),
base: None,
head: "s1".into(),
graph_ref: None,
},
steps: vec![make_step("s1", None)],
meta: Some(PathMeta {
refs: vec![Ref {
rel: "fixes".into(),
href: "issue://1".into(),
}],
..Default::default()
}),
};
let jsonl = p.to_jsonl_string().unwrap();
let back = Path::from_jsonl_str(&jsonl).unwrap();
assert_eq!(canonical_json(&p), canonical_json(&back));
}
#[test]
fn change_artifact_roundtrip_preserved() {
let art = ArtifactChange {
raw: Some("@@".into()),
structural: None,
};
let v = serde_json::to_value(&art).unwrap();
assert_eq!(v["raw"], "@@");
}
}