From e4f501ca342e3595a6e429f415ebdc45a668fdc7 Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Fri, 17 Apr 2026 12:14:02 -0400 Subject: [PATCH] feat: implement JSONL streaming format for Path documents Implements docs/RFC-jsonl.md end-to-end so producers (notably `track`) can persist Path documents incrementally instead of rewriting a full JSON blob on every step. toolpath 0.2.0: - New `v1::jsonl` module with `JsonlLine` + body types, a strict line-by-line reader (`Path::from_jsonl_reader` / `from_jsonl_str`), and a deterministic normalized writer (`Path::to_jsonl_writer` / `to_jsonl_string`). - `JsonlError` enum with per-line-number diagnostics for every RFC-specified fatal condition (empty stream, non-PathOpen first line, duplicate PathOpen, malformed JSON, orphan step signature, ambiguous head, lines after close). - Unknown line variants are skipped with a stderr warning for forward compatibility. - Additive schema change: `PathIdentity.graph_ref: Option`, serialized only when Some, so existing documents and signatures remain byte-stable. toolpath-cli 0.4.0: - New `io::read_document_auto` helper routes `*.path.jsonl` through the JSONL reader and everything else through the canonical JSON reader. Wired into validate, render dot, render md, query *, and merge. - `track` sessions now persist as `.path.jsonl` streams with the tooling bookkeeping (buffer_cache, seq_to_step, step_counter) stored in `path.meta.extra["track"]`; export/close strip the extra from the sealed output. Strict append-only writes are deferred as a future optimization. - New integration tests cover `.path.jsonl` input across validate/render/ query/merge plus malformed-JSONL rejection. Examples adopt the RFC's two-part extension convention: - examples/path-*.json renamed to examples/path-*.path.json - new examples/path-*.path.jsonl siblings generated via a one-off `cargo run -p toolpath-cli --example convert-path-to-jsonl` helper - all references in site config, playground JS, integration tests, and insta snapshot headers updated to the new filenames. --- CHANGELOG.md | 13 + Cargo.lock | 4 +- Cargo.toml | 2 +- crates/toolpath-claude/src/derive.rs | 1 + crates/toolpath-cli/Cargo.toml | 2 +- .../examples/convert-path-to-jsonl.rs | 27 + crates/toolpath-cli/src/cmd_merge.rs | 11 +- crates/toolpath-cli/src/cmd_query.rs | 10 +- crates/toolpath-cli/src/cmd_render.rs | 17 +- crates/toolpath-cli/src/cmd_track.rs | 106 +- crates/toolpath-cli/src/cmd_validate.rs | 30 +- crates/toolpath-cli/src/io.rs | 37 + crates/toolpath-cli/src/main.rs | 1 + crates/toolpath-cli/tests/integration.rs | 77 +- .../toolpath-cli/tests/render_md_snapshots.rs | 8 +- ...er_md_snapshots__render_md_path_01_pr.snap | 2 +- ...hots__render_md_path_02_local_session.snap | 2 +- ...napshots__render_md_path_03_signed_pr.snap | 2 +- ...pshots__render_md_path_04_exploration.snap | 2 +- crates/toolpath-dot/src/lib.rs | 7 + crates/toolpath-git/src/lib.rs | 1 + crates/toolpath-github/src/lib.rs | 1 + crates/toolpath-md/src/lib.rs | 18 + crates/toolpath-md/src/source/github.rs | 1 + crates/toolpath-md/src/source/mod.rs | 1 + crates/toolpath/Cargo.toml | 2 +- crates/toolpath/src/jsonl.rs | 1195 +++++++++++++++++ crates/toolpath/src/lib.rs | 16 + crates/toolpath/src/types.rs | 7 + .../{path-01-pr.json => path-01-pr.path.json} | 0 examples/path-01-pr.path.jsonl | 8 + ...n.json => path-02-local-session.path.json} | 0 examples/path-02-local-session.path.jsonl | 6 + ...ed-pr.json => path-03-signed-pr.path.json} | 0 examples/path-03-signed-pr.path.jsonl | 13 + ...ion.json => path-04-exploration.path.json} | 0 examples/path-04-exploration.path.jsonl | 12 + schema/toolpath.schema.json | 8 + site/_data/crates.json | 4 +- site/eleventy.config.js | 10 +- site/js/playground.js | 2 +- 41 files changed, 1553 insertions(+), 113 deletions(-) create mode 100644 crates/toolpath-cli/examples/convert-path-to-jsonl.rs create mode 100644 crates/toolpath-cli/src/io.rs create mode 100644 crates/toolpath/src/jsonl.rs rename examples/{path-01-pr.json => path-01-pr.path.json} (100%) create mode 100644 examples/path-01-pr.path.jsonl rename examples/{path-02-local-session.json => path-02-local-session.path.json} (100%) create mode 100644 examples/path-02-local-session.path.jsonl rename examples/{path-03-signed-pr.json => path-03-signed-pr.path.json} (100%) create mode 100644 examples/path-03-signed-pr.path.jsonl rename examples/{path-04-exploration.json => path-04-exploration.path.json} (100%) create mode 100644 examples/path-04-exploration.path.jsonl diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1d7ab..088e7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to the Toolpath workspace are documented here. +## 0.2.0 — toolpath + 0.4.0 — toolpath-cli + +### toolpath 0.2.0 + +- Add JSONL streaming format for `Path` documents (new `v1::jsonl` module) per [docs/RFC-jsonl.md](docs/RFC-jsonl.md). Read with `Path::from_jsonl_reader` / `Path::from_jsonl_str`, write with `Path::to_jsonl_writer` / `Path::to_jsonl_string`. Line kinds: `PathOpen`, `Step`, `ActorDef`, `Signature`, `PathMeta`, `Head`, `PathClose`. +- Add `PathIdentity.graph_ref: Option` — an optional `$ref`-style URL naming the graph a path belongs to. Additive and backwards-compatible; serialization omits the field when `None`, so existing documents and signatures remain byte-stable. + +### toolpath-cli 0.4.0 + +- Accept `.path.jsonl` files wherever `--input` takes a canonical JSON path: `validate`, `render dot`, `render md`, `query *`, and `merge`. Extension-based routing via a new `io::read_document_auto` helper; stdin paths remain JSON-only in this release. +- Refactor `track` to persist sessions as `.path.jsonl` streams. The session file is still a single file per session, now in JSONL format, with tracking bookkeeping stored in `path.meta.extra["track"]` (stripped on export/close). Strict append-only writes are a future optimization. +- Rename example path documents to the two-part extension: `examples/path-*.json` → `examples/path-*.path.json`, with new `examples/path-*.path.jsonl` siblings. + ## 0.3.0 — toolpath-cli ### toolpath-cli 0.3.0 diff --git a/Cargo.lock b/Cargo.lock index e4c79e3..10b89a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "toolpath" -version = "0.1.5" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -1809,7 +1809,7 @@ dependencies = [ [[package]] name = "toolpath-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index f2b9691..64435e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ edition = "2024" license = "Apache-2.0" [workspace.dependencies] -toolpath = { version = "0.1.5", path = "crates/toolpath" } +toolpath = { version = "0.2.0", path = "crates/toolpath" } toolpath-convo = { version = "0.5.0", path = "crates/toolpath-convo" } toolpath-git = { version = "0.1.3", path = "crates/toolpath-git" } toolpath-claude = { version = "0.6.2", path = "crates/toolpath-claude", default-features = false } diff --git a/crates/toolpath-claude/src/derive.rs b/crates/toolpath-claude/src/derive.rs index c39b846..e76d515 100644 --- a/crates/toolpath-claude/src/derive.rs +++ b/crates/toolpath-claude/src/derive.rs @@ -204,6 +204,7 @@ pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { id: format!("path-claude-{}", session_short), base: base_uri.map(|uri| Base { uri, ref_str: None }), head, + graph_ref: None, }, steps, meta: Some(PathMeta { diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index d98aa37..03ec1eb 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.3.0" +version = "0.4.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-cli/examples/convert-path-to-jsonl.rs b/crates/toolpath-cli/examples/convert-path-to-jsonl.rs new file mode 100644 index 0000000..d2318c5 --- /dev/null +++ b/crates/toolpath-cli/examples/convert-path-to-jsonl.rs @@ -0,0 +1,27 @@ +//! One-off helper: read a `.path.json` file, write the JSONL form to a +//! `.path.jsonl` file at the same basename. Used to regenerate the JSONL +//! example files under `examples/` whenever their JSON counterparts change. +//! +//! Usage: `cargo run -p toolpath-cli --example convert-path-to-jsonl -- ` + +use std::fs; +use toolpath::v1::{Document, Path}; + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let input = args.next().expect("usage: "); + let output = args + .next() + .expect("usage: "); + + let json = fs::read_to_string(&input)?; + let doc = Document::from_json(&json)?; + let path: Path = match doc { + Document::Path(p) => p, + _ => anyhow::bail!("{input}: not a Path document"), + }; + let jsonl = path.to_jsonl_string()?; + fs::write(&output, jsonl)?; + println!("wrote {output}"); + Ok(()) +} diff --git a/crates/toolpath-cli/src/cmd_merge.rs b/crates/toolpath-cli/src/cmd_merge.rs index c5c283f..5e5df9a 100644 --- a/crates/toolpath-cli/src/cmd_merge.rs +++ b/crates/toolpath-cli/src/cmd_merge.rs @@ -9,20 +9,17 @@ pub fn run(inputs: Vec, title: Option, pretty: bool) -> Result<( let mut all_paths = Vec::new(); for input in &inputs { - let content = if input == "-" { + let doc = if input == "-" { use std::io::Read; let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) .context("Failed to read from stdin")?; - buf + Document::from_json(&buf).with_context(|| format!("Failed to parse {:?}", input))? } else { - std::fs::read_to_string(input).with_context(|| format!("Failed to read {:?}", input))? + crate::io::read_document_auto(std::path::Path::new(input))? }; - let doc = Document::from_json(&content) - .with_context(|| format!("Failed to parse {:?}", input))?; - extract_paths(doc, &mut all_paths); } @@ -55,6 +52,7 @@ fn extract_paths(doc: Document, paths: &mut Vec) { id: format!("path-{}", step_id), base: None, head: step_id, + graph_ref: None, }, steps: vec![s], meta: None, @@ -95,6 +93,7 @@ mod tests { id: id.to_string(), base: Some(Base::vcs("github:org/repo", "abc123")), head, + graph_ref: None, }, steps, meta: Some(PathMeta { diff --git a/crates/toolpath-cli/src/cmd_query.rs b/crates/toolpath-cli/src/cmd_query.rs index 679c5e6..86d46c5 100644 --- a/crates/toolpath-cli/src/cmd_query.rs +++ b/crates/toolpath-cli/src/cmd_query.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; use toolpath::v1::{Document, query}; @@ -59,10 +59,8 @@ pub fn run(op: QueryOp, pretty: bool) -> Result<()> { } } -fn read_doc(path: &PathBuf) -> Result { - let content = - std::fs::read_to_string(path).with_context(|| format!("Failed to read {:?}", path))?; - Document::from_json(&content).with_context(|| format!("Failed to parse {:?}", path)) +fn read_doc(path: &std::path::Path) -> Result { + crate::io::read_document_auto(path) } fn extract_steps(doc: &Document) -> (&[toolpath::v1::Step], Option<&str>) { @@ -175,6 +173,7 @@ mod tests { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc")), head: "s3".into(), + graph_ref: None, }, steps: vec![s1, s2, s2a, s3], meta: None, @@ -213,6 +212,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, diff --git a/crates/toolpath-cli/src/cmd_render.rs b/crates/toolpath-cli/src/cmd_render.rs index d56c07b..28995b6 100644 --- a/crates/toolpath-cli/src/cmd_render.rs +++ b/crates/toolpath-cli/src/cmd_render.rs @@ -78,19 +78,17 @@ fn run_dot( show_timestamps: bool, highlight_dead_ends: bool, ) -> Result<()> { - let content = if let Some(path) = &input { - std::fs::read_to_string(path).with_context(|| format!("Failed to read {:?}", path))? + let doc = if let Some(path) = &input { + crate::io::read_document_auto(path)? } else { use std::io::Read; let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) .context("Failed to read from stdin")?; - buf + Document::from_json(&buf).context("Failed to parse Toolpath document")? }; - let doc = Document::from_json(&content).context("Failed to parse Toolpath document")?; - let options = toolpath_dot::RenderOptions { show_files, show_timestamps, @@ -114,19 +112,17 @@ fn run_md( detail: &str, front_matter: bool, ) -> Result<()> { - let content = if let Some(path) = &input { - std::fs::read_to_string(path).with_context(|| format!("Failed to read {:?}", path))? + let doc = if let Some(path) = &input { + crate::io::read_document_auto(path)? } else { use std::io::Read; let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) .context("Failed to read from stdin")?; - buf + Document::from_json(&buf).context("Failed to parse Toolpath document")? }; - let doc = Document::from_json(&content).context("Failed to parse Toolpath document")?; - let detail = match detail { "full" => toolpath_md::Detail::Full, _ => toolpath_md::Detail::Summary, @@ -162,6 +158,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, diff --git a/crates/toolpath-cli/src/cmd_track.rs b/crates/toolpath-cli/src/cmd_track.rs index 8a4d702..e5cd516 100644 --- a/crates/toolpath-cli/src/cmd_track.rs +++ b/crates/toolpath-cli/src/cmd_track.rs @@ -190,17 +190,15 @@ fn session_dir(explicit: Option<&PathBuf>) -> PathBuf { explicit.cloned().unwrap_or_else(std::env::temp_dir) } -/// Load a session file. The file is a valid `{"Path": {...}}` document with -/// tracking bookkeeping in `meta.track`. Returns the Path (with track state -/// removed from meta) and the extracted TrackState. +/// Load a session file. The file is a `.path.jsonl` stream with the +/// tracking bookkeeping stored in `meta.extra["track"]`. Returns the Path +/// (with track state removed from meta) and the extracted TrackState. fn load_session(path: &std::path::Path) -> Result<(v1::Path, TrackState)> { - let data = std::fs::read_to_string(path) + let file = std::fs::File::open(path) .with_context(|| format!("failed to read session file: {}", path.display()))?; - let doc: v1::Document = serde_json::from_str(&data) + let reader = std::io::BufReader::new(file); + let mut path_doc = v1::Path::from_jsonl_reader(reader) .with_context(|| format!("failed to parse session file: {}", path.display()))?; - let v1::Document::Path(mut path_doc) = doc else { - anyhow::bail!("session file is not a Path document: {}", path.display()); - }; let track_value = path_doc .meta .as_mut() @@ -217,8 +215,15 @@ fn load_session(path: &std::path::Path) -> Result<(v1::Path, TrackState)> { Ok((path_doc, state)) } -/// Save a session file. Injects TrackState into `meta.track` and writes as a -/// valid `{"Path": {...}}` document. +/// Save a session file atomically. Injects TrackState into +/// `meta.extra["track"]` and writes the Path as a `.path.jsonl` stream. +/// +/// Writes are atomic via tempfile + persist. Any tool that reads the +/// session mid-operation will either see the pre-write version or the +/// post-write version, never a torn file. +/// +/// Note: this always does a full rewrite — strict append-only writes are +/// a future optimization. See `docs/RFC-jsonl.md` §13. fn save_session(path: &std::path::Path, doc: &v1::Path, state: &TrackState) -> Result<()> { let mut doc = doc.clone(); let meta = doc.meta.get_or_insert_with(v1::PathMeta::default); @@ -226,12 +231,15 @@ fn save_session(path: &std::path::Path, doc: &v1::Path, state: &TrackState) -> R "track".to_string(), serde_json::to_value(state).context("failed to serialize track state")?, ); - let wrapped = v1::Document::Path(doc); + let jsonl = doc + .to_jsonl_string() + .context("failed to serialize session as JSONL")?; let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); let tmp = tempfile::NamedTempFile::new_in(dir) .context("failed to create temp file for atomic write")?; - serde_json::to_writer_pretty(&tmp, &wrapped).context("failed to serialize session")?; + std::io::Write::write_all(&mut tmp.as_file(), jsonl.as_bytes()) + .context("failed to write JSONL to temp file")?; tmp.persist(path) .with_context(|| format!("failed to persist session file: {}", path.display()))?; Ok(()) @@ -322,7 +330,7 @@ fn init_session(config: InitConfig) -> Result { }; let dir = session_dir(config.session_dir.as_ref()); - let session_path = dir.join(format!("{session_id}.json")); + let session_path = dir.join(format!("{session_id}.path.jsonl")); save_session(&session_path, &path_doc, &state)?; Ok(session_path) } @@ -610,7 +618,7 @@ fn run_list(session_dir_opt: Option, json: bool) -> Result<()> { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("track-") - && name_str.ends_with(".json") + && name_str.ends_with(".path.jsonl") && let Ok((path_doc, state)) = load_session(&entry.path()) { sessions.push(SessionSummary { @@ -709,7 +717,7 @@ mod tests { step_counter: 0, created_at: "2026-01-01T00:00:00Z".to_string(), }; - let path = dir.join("track-test-1.json"); + let path = dir.join("track-test-1.path.jsonl"); save_session(&path, &path_doc, &state).unwrap(); path } @@ -845,7 +853,7 @@ mod tests { step_counter: 0, created_at: "2026-01-01T00:00:00Z".to_string(), }; - let path = dir.path().join("track-base-test.json"); + let path = dir.path().join("track-base-test.path.jsonl"); save_session(&path, &path_doc, &state).unwrap(); let (loaded_doc, _) = load_session(&path).unwrap(); let base = loaded_doc.path.base.unwrap(); @@ -866,7 +874,7 @@ mod tests { step_counter: 0, created_at: "2026-01-01T00:00:00Z".to_string(), }; - let path = dir.path().join("track-no-base.json"); + let path = dir.path().join("track-no-base.path.jsonl"); save_session(&path, &path_doc, &state).unwrap(); let (loaded_doc, _) = load_session(&path).unwrap(); assert!(loaded_doc.path.base.is_none()); @@ -874,20 +882,16 @@ mod tests { #[test] fn test_session_file_is_valid_toolpath_document() { - // The session file should be readable by any Toolpath tool + // The session file is a `.path.jsonl` stream — any Toolpath tool + // with a JSONL reader can consume it. let dir = TempDir::new().unwrap(); let path = make_session(dir.path(), "hello\n"); let data = std::fs::read_to_string(&path).unwrap(); - let doc = v1::Document::from_json(&data).unwrap(); - match doc { - v1::Document::Path(p) => { - assert_eq!(p.path.id, "track-test-1"); - // meta.track is present (tracking bookkeeping) - assert!(p.meta.as_ref().unwrap().extra.contains_key("track")); - } - _ => panic!("Expected Path"), - } + let p = v1::Path::from_jsonl_str(&data).unwrap(); + assert_eq!(p.path.id, "track-test-1"); + // meta.extra["track"] is present (tracking bookkeeping) + assert!(p.meta.as_ref().unwrap().extra.contains_key("track")); } // ── init_session ────────────────────────────────────────────────────── @@ -1748,7 +1752,7 @@ mod tests { created_at: "2026-01-01T00:00:00Z".to_string(), }; save_session( - &dir.path().join("track-20260101T000000-111.json"), + &dir.path().join("track-20260101T000000-111.path.jsonl"), &path_doc_1, &state_1, ) @@ -1770,7 +1774,7 @@ mod tests { created_at: "2026-01-01T00:00:01Z".to_string(), }; save_session( - &dir.path().join("track-20260101T000000-222.json"), + &dir.path().join("track-20260101T000000-222.path.jsonl"), &path_doc_2, &state_2, ) @@ -1800,7 +1804,7 @@ mod tests { #[test] fn test_list_ignores_corrupt_sessions() { let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("track-corrupt.json"), "not json").unwrap(); + std::fs::write(dir.path().join("track-corrupt.path.jsonl"), "not json").unwrap(); // Should not error — corrupt files are silently skipped run_list(Some(dir.path().to_path_buf()), false).unwrap(); } @@ -1828,21 +1832,16 @@ mod tests { step_counter: 1, created_at: "2026-01-01T00:00:00Z".to_string(), }; - let session_path = dir.path().join("track-test-doc.json"); + let session_path = dir.path().join("track-test-doc.path.jsonl"); save_session(&session_path, &path_doc, &state).unwrap(); - // Read raw file as Toolpath document — should work + // Read raw file as JSONL Path — should work let data = std::fs::read_to_string(&session_path).unwrap(); - let doc = v1::Document::from_json(&data).unwrap(); - match &doc { - v1::Document::Path(p) => { - assert_eq!(p.path.id, "track-test-doc"); - assert_eq!(p.path.head, "step-001"); - assert_eq!(p.path.base.as_ref().unwrap().uri, "github:org/repo"); - assert_eq!(p.steps.len(), 1); - } - _ => panic!("Expected Path"), - } + let p = v1::Path::from_jsonl_str(&data).unwrap(); + assert_eq!(p.path.id, "track-test-doc"); + assert_eq!(p.path.head, "step-001"); + assert_eq!(p.path.base.as_ref().unwrap().uri, "github:org/repo"); + assert_eq!(p.steps.len(), 1); // Load and export (track state stripped) let (exported, _) = load_session(&session_path).unwrap(); @@ -1873,7 +1872,7 @@ mod tests { step_counter: 0, created_at: "2026-01-01T00:00:00Z".to_string(), }; - let session_path = dir.path().join("track-no-base.json"); + let session_path = dir.path().join("track-no-base.path.jsonl"); save_session(&session_path, &path_doc, &state).unwrap(); let (exported, _) = load_session(&session_path).unwrap(); @@ -2006,20 +2005,15 @@ mod tests { .unwrap(); assert_eq!(r3, StepResult::Created("step-003".to_string())); - // Verify the session is a valid Toolpath document mid-session + // Verify the session is a valid JSONL Path mid-session let data = std::fs::read_to_string(&init_path).unwrap(); - let mid_doc = v1::Document::from_json(&data).unwrap(); - match &mid_doc { - v1::Document::Path(p) => { - assert_eq!(p.steps.len(), 3); - assert_eq!(p.path.head, "step-003"); - // Can run queries on the live session - let dead = v1::query::dead_ends(&p.steps, &p.path.head); - assert_eq!(dead.len(), 1); - assert_eq!(dead[0].step.id, "step-002"); - } - _ => panic!("Expected Path"), - } + let p = v1::Path::from_jsonl_str(&data).unwrap(); + assert_eq!(p.steps.len(), 3); + assert_eq!(p.path.head, "step-003"); + // Can run queries on the live session + let dead = v1::query::dead_ends(&p.steps, &p.path.head); + assert_eq!(dead.len(), 1); + assert_eq!(dead[0].step.id, "step-002"); // 6. Close with output file let output = dir.path().join("result.json"); diff --git a/crates/toolpath-cli/src/cmd_validate.rs b/crates/toolpath-cli/src/cmd_validate.rs index 5e500a2..5b1cb2f 100644 --- a/crates/toolpath-cli/src/cmd_validate.rs +++ b/crates/toolpath-cli/src/cmd_validate.rs @@ -1,22 +1,32 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use std::path::PathBuf; use toolpath::v1::Document; +use crate::io::read_document_auto; + pub fn run(input: PathBuf) -> Result<()> { - let content = - std::fs::read_to_string(&input).with_context(|| format!("Failed to read {:?}", input))?; - validate_content(&content) + match read_document_auto(&input) { + Ok(doc) => { + println!("Valid: {}", describe(&doc)); + Ok(()) + } + Err(e) => Err(anyhow::anyhow!("Invalid: {}", e)), + } } +fn describe(doc: &Document) -> String { + match doc { + Document::Graph(g) => format!("Graph (id: {})", g.graph.id), + Document::Path(p) => format!("Path (id: {}, {} steps)", p.path.id, p.steps.len()), + Document::Step(s) => format!("Step (id: {})", s.step.id), + } +} + +#[cfg(test)] fn validate_content(content: &str) -> Result<()> { match Document::from_json(content) { Ok(doc) => { - let kind = match &doc { - Document::Graph(g) => format!("Graph (id: {})", g.graph.id), - Document::Path(p) => format!("Path (id: {}, {} steps)", p.path.id, p.steps.len()), - Document::Step(s) => format!("Step (id: {})", s.step.id), - }; - println!("Valid: {}", kind); + println!("Valid: {}", describe(&doc)); Ok(()) } Err(e) => Err(anyhow::anyhow!("Invalid: {}", e)), diff --git a/crates/toolpath-cli/src/io.rs b/crates/toolpath-cli/src/io.rs new file mode 100644 index 0000000..a37dea9 --- /dev/null +++ b/crates/toolpath-cli/src/io.rs @@ -0,0 +1,37 @@ +//! Input/output helpers shared across CLI subcommands. +//! +//! Extension-based format routing: `*.path.jsonl` files are parsed through +//! the JSONL streaming reader; everything else is parsed as canonical JSON. + +use anyhow::{Context, Result}; +use std::io::BufReader; +use std::path::Path as FsPath; +use toolpath::v1::{Document, Path}; + +/// Read a Toolpath document from a file, auto-detecting the format. +/// +/// - Files whose name ends with `.path.jsonl` are parsed as JSONL `Path` +/// streams and returned as `Document::Path`. +/// - All other files are parsed as canonical `{"Step"|"Path"|"Graph": ...}` +/// JSON. +pub fn read_document_auto(path: &FsPath) -> Result { + if is_path_jsonl(path) { + let file = std::fs::File::open(path) + .with_context(|| format!("failed to open {}", path.display()))?; + let reader = BufReader::new(file); + let p = Path::from_jsonl_reader(reader) + .with_context(|| format!("failed to parse JSONL {}", path.display()))?; + Ok(Document::Path(p)) + } else { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + Document::from_json(&content).with_context(|| format!("failed to parse {}", path.display())) + } +} + +/// Whether `path`'s filename ends with `.path.jsonl`. +pub fn is_path_jsonl(path: &FsPath) -> bool { + path.file_name() + .and_then(|s| s.to_str()) + .is_some_and(|n| n.ends_with(".path.jsonl")) +} diff --git a/crates/toolpath-cli/src/main.rs b/crates/toolpath-cli/src/main.rs index fb6ef38..e0edc2a 100644 --- a/crates/toolpath-cli/src/main.rs +++ b/crates/toolpath-cli/src/main.rs @@ -6,6 +6,7 @@ mod cmd_query; mod cmd_render; mod cmd_track; mod cmd_validate; +mod io; use anyhow::Result; use clap::{Parser, Subcommand}; diff --git a/crates/toolpath-cli/tests/integration.rs b/crates/toolpath-cli/tests/integration.rs index d2cfae2..42a1d9d 100644 --- a/crates/toolpath-cli/tests/integration.rs +++ b/crates/toolpath-cli/tests/integration.rs @@ -261,7 +261,7 @@ fn derive_git_validate_roundtrip() { #[test] fn render_dot_from_stdin() { - let input = std::fs::read_to_string(examples_dir().join("path-01-pr.json")).unwrap(); + let input = std::fs::read_to_string(examples_dir().join("path-01-pr.path.json")).unwrap(); cmd() .arg("render") @@ -280,7 +280,7 @@ fn query_dead_ends() { .arg("query") .arg("dead-ends") .arg("--input") - .arg(examples_dir().join("path-01-pr.json")) + .arg(examples_dir().join("path-01-pr.path.json")) .assert() .success() .stdout(predicate::str::contains("step-002a")); @@ -292,7 +292,7 @@ fn query_ancestors() { .arg("query") .arg("ancestors") .arg("--input") - .arg(examples_dir().join("path-01-pr.json")) + .arg(examples_dir().join("path-01-pr.path.json")) .arg("--step-id") .arg("step-004") .assert() @@ -307,8 +307,75 @@ fn query_ancestors() { fn merge_produces_graph() { cmd() .arg("merge") - .arg(examples_dir().join("path-01-pr.json")) - .arg(examples_dir().join("path-02-local-session.json")) + .arg(examples_dir().join("path-01-pr.path.json")) + .arg(examples_dir().join("path-02-local-session.path.json")) + .assert() + .success() + .stdout(predicate::str::contains("\"Graph\"")); +} + +// ── .path.jsonl input ──────────────────────────────────────────────── + +#[test] +fn validate_accepts_path_jsonl() { + cmd() + .arg("validate") + .arg("--input") + .arg(examples_dir().join("path-02-local-session.path.jsonl")) + .assert() + .success() + .stdout(predicate::str::contains("Valid: Path")); +} + +#[test] +fn validate_rejects_truncated_jsonl() { + let mut f = tempfile::Builder::new() + .suffix(".path.jsonl") + .tempfile() + .unwrap(); + // No PathOpen, just garbage. + use std::io::Write; + writeln!(f, r#"{{"Step":"garbage"}}"#).unwrap(); + f.flush().unwrap(); + + cmd() + .arg("validate") + .arg("--input") + .arg(f.path()) + .assert() + .failure() + .stderr(predicate::str::contains("Invalid")); +} + +#[test] +fn render_md_accepts_path_jsonl() { + cmd() + .arg("render") + .arg("md") + .arg("--input") + .arg(examples_dir().join("path-03-signed-pr.path.jsonl")) + .assert() + .success() + .stdout(predicate::str::is_empty().not()); +} + +#[test] +fn query_dead_ends_accepts_path_jsonl() { + cmd() + .arg("query") + .arg("dead-ends") + .arg("--input") + .arg(examples_dir().join("path-04-exploration.path.jsonl")) + .assert() + .success(); +} + +#[test] +fn merge_accepts_path_jsonl() { + cmd() + .arg("merge") + .arg(examples_dir().join("path-01-pr.path.jsonl")) + .arg(examples_dir().join("path-02-local-session.path.jsonl")) .assert() .success() .stdout(predicate::str::contains("\"Graph\"")); diff --git a/crates/toolpath-cli/tests/render_md_snapshots.rs b/crates/toolpath-cli/tests/render_md_snapshots.rs index 22fa6aa..9f801b5 100644 --- a/crates/toolpath-cli/tests/render_md_snapshots.rs +++ b/crates/toolpath-cli/tests/render_md_snapshots.rs @@ -42,13 +42,13 @@ snapshot_test!(render_md_step_06_signed, "step-06-signed.json"); snapshot_test!(render_md_step_07_merge, "step-07-merge.json"); // Paths (4) -snapshot_test!(render_md_path_01_pr, "path-01-pr.json"); +snapshot_test!(render_md_path_01_pr, "path-01-pr.path.json"); snapshot_test!( render_md_path_02_local_session, - "path-02-local-session.json" + "path-02-local-session.path.json" ); -snapshot_test!(render_md_path_03_signed_pr, "path-03-signed-pr.json"); -snapshot_test!(render_md_path_04_exploration, "path-04-exploration.json"); +snapshot_test!(render_md_path_03_signed_pr, "path-03-signed-pr.path.json"); +snapshot_test!(render_md_path_04_exploration, "path-04-exploration.path.json"); // Graphs (1) snapshot_test!(render_md_graph_01_release, "graph-01-release.json"); diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_01_pr.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_01_pr.snap index ff4fc83..580a8b0 100644 --- a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_01_pr.snap +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_01_pr.snap @@ -1,6 +1,6 @@ --- source: crates/toolpath-cli/tests/render_md_snapshots.rs -expression: "render_md(\"path-01-pr.json\")" +expression: "render_md(\"path-01-pr.path.json\")" --- # Add email validation diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_02_local_session.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_02_local_session.snap index bfbd678..d554225 100644 --- a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_02_local_session.snap +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_02_local_session.snap @@ -1,6 +1,6 @@ --- source: crates/toolpath-cli/tests/render_md_snapshots.rs -expression: "render_md(\"path-02-local-session.json\")" +expression: "render_md(\"path-02-local-session.path.json\")" --- # Claude Code session: Add service config diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_03_signed_pr.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_03_signed_pr.snap index 7429a74..3a854b0 100644 --- a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_03_signed_pr.snap +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_03_signed_pr.snap @@ -1,6 +1,6 @@ --- source: crates/toolpath-cli/tests/render_md_snapshots.rs -expression: "render_md(\"path-03-signed-pr.json\")" +expression: "render_md(\"path-03-signed-pr.path.json\")" --- # Add email validation diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_04_exploration.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_04_exploration.snap index ff35ae0..e9ba957 100644 --- a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_04_exploration.snap +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_04_exploration.snap @@ -1,6 +1,6 @@ --- source: crates/toolpath-cli/tests/render_md_snapshots.rs -expression: "render_md(\"path-04-exploration.json\")" +expression: "render_md(\"path-04-exploration.path.json\")" --- # Explore CLI argument parsing approaches diff --git a/crates/toolpath-dot/src/lib.rs b/crates/toolpath-dot/src/lib.rs index beffea2..5710b6c 100644 --- a/crates/toolpath-dot/src/lib.rs +++ b/crates/toolpath-dot/src/lib.rs @@ -658,6 +658,7 @@ mod tests { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc123")), head: "s2".into(), + graph_ref: None, }, steps: vec![s1, s2], meta: Some(PathMeta { @@ -690,6 +691,7 @@ mod tests { id: "p1".into(), base: None, head: "s3".into(), + graph_ref: None, }, steps: vec![s1, s2, s2a, s3], meta: None, @@ -712,6 +714,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -733,6 +736,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -757,6 +761,7 @@ mod tests { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc123")), head: "s2".into(), + graph_ref: None, }, steps: vec![s1, s2], meta: Some(PathMeta { @@ -771,6 +776,7 @@ mod tests { id: "p2".into(), base: Some(Base::vcs("github:org/repo", "abc123")), head: "s3".into(), + graph_ref: None, }, steps: vec![s3], meta: Some(PathMeta { @@ -839,6 +845,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![make_step("s1", "human:alex", &[])], meta: None, diff --git a/crates/toolpath-git/src/lib.rs b/crates/toolpath-git/src/lib.rs index 4277331..68b8cf3 100644 --- a/crates/toolpath-git/src/lib.rs +++ b/crates/toolpath-git/src/lib.rs @@ -248,6 +248,7 @@ mod native { ref_str: Some(base_commit.id().to_string()), }), head: head_step_id, + graph_ref: None, }, steps, meta: Some(PathMeta { diff --git a/crates/toolpath-github/src/lib.rs b/crates/toolpath-github/src/lib.rs index eb01922..5b30c6d 100644 --- a/crates/toolpath-github/src/lib.rs +++ b/crates/toolpath-github/src/lib.rs @@ -530,6 +530,7 @@ mod native { ref_str: Some(pr["base"]["ref"].as_str().unwrap_or("main").to_string()), }), head, + graph_ref: None, }, steps, meta: Some(meta), diff --git a/crates/toolpath-md/src/lib.rs b/crates/toolpath-md/src/lib.rs index eac23d0..1915b50 100644 --- a/crates/toolpath-md/src/lib.rs +++ b/crates/toolpath-md/src/lib.rs @@ -1274,6 +1274,7 @@ mod tests { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc123")), head: "s2".into(), + graph_ref: None, }, steps: vec![s1, s2], meta: Some(PathMeta { @@ -1303,6 +1304,7 @@ mod tests { id: "p1".into(), base: None, head: "s3".into(), + graph_ref: None, }, steps: vec![s1, s2, s2a, s3], meta: None, @@ -1323,6 +1325,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -1349,6 +1352,7 @@ mod tests { id: "p1".into(), base: None, head: "s2".into(), + graph_ref: None, }, steps: vec![s1, s2], meta: None, @@ -1368,6 +1372,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: Some(PathMeta { @@ -1395,6 +1400,7 @@ mod tests { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc")), head: "s2".into(), + graph_ref: None, }, steps: vec![s1, s2], meta: Some(PathMeta { @@ -1409,6 +1415,7 @@ mod tests { id: "p2".into(), base: None, head: "s3".into(), + graph_ref: None, }, steps: vec![s3], meta: Some(PathMeta { @@ -1461,6 +1468,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -1524,6 +1532,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -1685,6 +1694,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: Some(PathMeta { @@ -1710,6 +1720,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -1735,6 +1746,7 @@ mod tests { id: "path-42".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -1929,6 +1941,7 @@ mod tests { id: "p1".into(), base: None, head: "s3".into(), + graph_ref: None, }, steps: vec![s1, s2, s3], meta: None, @@ -1951,6 +1964,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -1982,6 +1996,7 @@ mod tests { id: "pr-42".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: Some(PathMeta { @@ -2010,6 +2025,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, @@ -2130,6 +2146,7 @@ mod tests { id: "p1".into(), base: None, head: "s3".into(), + graph_ref: None, }, steps: vec![s1, s2, s3], meta: None, @@ -2163,6 +2180,7 @@ mod tests { id: "pr-7".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: Some(PathMeta { diff --git a/crates/toolpath-md/src/source/github.rs b/crates/toolpath-md/src/source/github.rs index 9147cfd..4f94379 100644 --- a/crates/toolpath-md/src/source/github.rs +++ b/crates/toolpath-md/src/source/github.rs @@ -87,6 +87,7 @@ mod tests { id: "pr-42".into(), base: None, head: "s2".into(), + graph_ref: None, }, steps: vec![s1, s2], meta: Some(PathMeta { diff --git a/crates/toolpath-md/src/source/mod.rs b/crates/toolpath-md/src/source/mod.rs index 8479075..cfb38a2 100644 --- a/crates/toolpath-md/src/source/mod.rs +++ b/crates/toolpath-md/src/source/mod.rs @@ -38,6 +38,7 @@ mod tests { id: "p1".into(), base: None, head: "s1".into(), + graph_ref: None, }, steps: vec![s1], meta: None, diff --git a/crates/toolpath/Cargo.toml b/crates/toolpath/Cargo.toml index 29acbc5..a641caa 100644 --- a/crates/toolpath/Cargo.toml +++ b/crates/toolpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath" -version = "0.1.5" +version = "0.2.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath/src/jsonl.rs b/crates/toolpath/src/jsonl.rs new file mode 100644 index 0000000..34cc75b --- /dev/null +++ b/crates/toolpath/src/jsonl.rs @@ -0,0 +1,1195 @@ +//! JSONL streaming format for `Path` documents. +//! +//! A `.path.jsonl` file is a line-oriented sequence of self-describing JSON +//! objects that seals to a canonical [`Path`]. Each line is an externally +//! tagged JSON object: `{"": }`. Writers append one line per +//! logical event (path open, new step, new signature, etc.); readers +//! accumulate these into a single `Path` document. +//! +//! See `docs/RFC-jsonl.md` for the full specification. Round-trip guarantee: +//! reading a JSONL file and writing it back produces a JSON document +//! equivalent to what [`Path::to_json`] would produce for the same logical +//! path — signatures computed over canonical JSON remain valid across +//! conversions. +//! +//! # Reading +//! +//! ``` +//! use toolpath::v1::Path; +//! +//! let jsonl = concat!( +//! r#"{"PathOpen":{"version":"1","id":"pr-42","base":{"uri":"github:org/repo","ref":"abc"}}}"#, "\n", +//! r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"2026-01-01T00:00:00Z"},"change":{"f.rs":{"raw":"@@"}}}}"#, "\n", +//! r#"{"Head":{"step_id":"s1"}}"#, "\n", +//! r#"{"PathClose":{}}"#, "\n", +//! ); +//! let path = Path::from_jsonl_str(jsonl).unwrap(); +//! assert_eq!(path.path.id, "pr-42"); +//! assert_eq!(path.path.head, "s1"); +//! assert_eq!(path.steps.len(), 1); +//! ``` +//! +//! # Writing +//! +//! ``` +//! use toolpath::v1::{Base, Path, PathIdentity, Step}; +//! +//! let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z") +//! .with_raw_change("f.rs", "@@ -0,0 +1 @@\n+hi"); +//! let path = Path { +//! path: PathIdentity { +//! id: "p1".into(), +//! base: Some(Base::vcs("github:org/repo", "abc")), +//! head: "s1".into(), +//! graph_ref: None, +//! }, +//! steps: vec![step], +//! meta: None, +//! }; +//! let jsonl = path.to_jsonl_string().unwrap(); +//! let first_line = jsonl.lines().next().unwrap(); +//! assert!(first_line.starts_with(r#"{"PathOpen":"#)); +//! ``` + +use crate::types::{ + ActorDefinition, Base, Path, PathIdentity, PathMeta, Ref, Signature, Step, StepMeta, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt; +use std::io::{BufRead, Write}; + +// ============================================================================ +// Line kind types +// ============================================================================ + +/// One line of a `.path.jsonl` file. Externally tagged to match the RFC +/// wire format: `{"": }` per line, compact JSON, +/// LF-terminated. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum JsonlLine { + PathOpen(PathOpenBody), + Step(StepBody), + ActorDef(ActorDefBody), + Signature(SignatureBody), + PathMeta(PathMetaBody), + Head(HeadBody), + PathClose(PathCloseBody), +} + +/// Body of a `PathOpen` line — the first line of every file. +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// `PathMeta` fields allowed inside `PathOpen.meta`. Excludes `actors` and +/// `signatures` (those have dedicated line kinds per the RFC). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PathOpenMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub refs: Vec, + #[serde(flatten, default)] + pub extra: HashMap, +} + +/// Body of a `Step` line — the existing `Step` JSON shape verbatim. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StepBody(pub Step); + +/// Body of an `ActorDef` line. Inserts or overwrites an entry in +/// `path.meta.actors`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActorDefBody { + pub actor: String, + pub definition: ActorDefinition, +} + +/// Body of a `Signature` line. `target` is either `"path"` or +/// `"step:"`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureBody { + pub target: String, + pub signature: Signature, +} + +/// Body of a `PathMeta` line. Fields in `patch` field-wise overwrite +/// `path.meta`. Arrays (via `Option>`) replace; absent fields are +/// unchanged. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathMetaBody { + pub patch: PathMetaPatch, +} + +/// Patch body for `PathMeta` lines. Scalar fields overwrite when `Some`; +/// `refs` replaces when `Some` (empty `Vec` means "replace with empty"). +/// `actors` and `signatures` are not representable — use dedicated +/// [`ActorDefBody`] / [`SignatureBody`] lines. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PathMetaPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refs: Option>, + #[serde(flatten, default)] + pub extra: HashMap, +} + +/// Body of a `Head` line. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeadBody { + pub step_id: String, +} + +/// Body of a `PathClose` line (optional terminator; currently empty). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PathCloseBody {} + +// ============================================================================ +// Errors +// ============================================================================ + +/// Errors produced while reading or writing a `.path.jsonl` stream. +#[derive(Debug)] +pub enum JsonlError { + /// Underlying I/O failure. + Io(std::io::Error), + /// The stream was empty; a `PathOpen` line is required. + Empty, + /// The first line was not a `PathOpen` line. + FirstLineNotPathOpen { line_num: usize }, + /// A second `PathOpen` appeared mid-stream. + DuplicatePathOpen { line_num: usize }, + /// A line was not parseable JSON. + MalformedJson { + line_num: usize, + source: serde_json::Error, + }, + /// A line was valid JSON but not a JSON object. + NotAnObject { line_num: usize }, + /// A line was an object with zero keys or more than one key. + NotSingleKey { line_num: usize }, + /// A line had a known tag but the body failed to deserialize. + BadBody { + line_num: usize, + tag: String, + source: serde_json::Error, + }, + /// A `Signature` line targeted a step that had not appeared yet. + OrphanStepSignature { line_num: usize, step_id: String }, + /// EOF reached with no `Head` line and head cannot be unambiguously + /// inferred. + AmbiguousHead { candidates: Vec }, + /// EOF reached with no steps and no `Head`. + NoSteps, + /// A line appeared after `PathClose`. + AfterClose { line_num: 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 {{\"\": ...}}" + ), + 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") + } + } + } +} + +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 for JsonlError { + fn from(e: std::io::Error) -> Self { + JsonlError::Io(e) + } +} + +// ============================================================================ +// Reader +// ============================================================================ + +/// Internal per-line parse result. Each value is consumed immediately by the +/// reader's match block — never stored — so the size difference between +/// variants is irrelevant here. +#[allow(clippy::large_enum_variant)] +enum ParsedLine { + Known(JsonlLine), + Unknown { tag: String }, +} + +fn parse_line(line: &str, line_num: usize) -> Result { + 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::(value).map_err(|source| { + JsonlError::BadBody { + line_num, + tag: tag.clone(), + source, + } + })?; + Ok(ParsedLine::Known(line_obj)) + } + _ => Ok(ParsedLine::Unknown { tag }), + } +} + +impl Path { + /// Read a JSONL `Path` document from any buffered reader. + /// + /// Each line is parsed as a [`JsonlLine`]; the reader accumulates them + /// per the RFC algorithm and returns the sealed `Path` at EOF. + /// + /// Unknown line variants are skipped with a warning to stderr. Malformed + /// JSON, a first-line that is not `PathOpen`, a duplicate `PathOpen`, a + /// `Signature` targeting an unknown step, or an EOF with an ambiguous + /// head are all fatal. + pub fn from_jsonl_reader(reader: R) -> Result { + let mut lines_iter = reader.lines().enumerate(); + + // Line 1 MUST be PathOpen. + 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() { + // Tolerate an initial empty line (e.g. blank first + // line from a file with a BOM-stripped start or a + // concatenated stream) to stay forgiving. The RFC + // does not require first-line-PathOpen to be + // byte-offset 0, only the first non-empty line. + 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 = Vec::new(); + let mut step_idx: HashMap = HashMap::new(); + let mut head: Option = 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, + }) + } + + /// Read a JSONL `Path` document from a string. + pub fn from_jsonl_str(s: &str) -> Result { + Self::from_jsonl_reader(std::io::Cursor::new(s)) + } +} + +fn apply_signature( + path_meta: &mut PathMeta, + steps: &mut [Step], + step_idx: &HashMap, + 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(()); + } + // Unknown target form — treat as orphan for strict parsing. + 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, steps: &[Step]) -> Result { + 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 = 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() +} + +// ============================================================================ +// Writer +// ============================================================================ + +impl Path { + /// Write the normalized JSONL form of this path to any writer. + /// + /// Emission order matches the RFC: `PathOpen`, then `ActorDef` entries + /// sorted by actor key for determinism, then each `Step` followed by its + /// step-level `Signature` lines in original order, then path-level + /// `Signature` lines in original order, then `Head`, then `PathClose`. + /// Each line is compact JSON followed by a single `\n`. + pub fn to_jsonl_writer(&self, w: &mut W) -> Result<(), JsonlError> { + // PathOpen + 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)?; + + // ActorDef lines, sorted by actor key for determinism + 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)?; + } + } + + // Steps + per-step signatures + for step in &self.steps { + let mut trimmed = step.clone(); + let step_sigs: Vec = 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)?; + } + } + + // Path-level signatures + 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)?; + } + } + + // Head + write_line( + w, + &JsonlLine::Head(HeadBody { + step_id: self.path.head.clone(), + }), + )?; + // PathClose + write_line(w, &JsonlLine::PathClose(PathCloseBody {}))?; + Ok(()) + } + + /// Write the normalized JSONL form of this path to a string. + pub fn to_jsonl_string(&self) -> Result { + let mut buf: Vec = Vec::new(); + self.to_jsonl_writer(&mut buf)?; + Ok(String::from_utf8(buf).expect("jsonl writer emits utf-8")) + } +} + +fn write_line(w: &mut W, line: &JsonlLine) -> Result<(), JsonlError> { + let s = serde_json::to_string(line).map_err(|e| JsonlError::BadBody { + line_num: 0, + tag: "".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() +} + +/// Project the parts of `PathMeta` that may be carried in `PathOpen.meta`. +/// Returns `None` if the projection would be empty. +fn path_meta_for_open(m: &PathMeta) -> Option { + 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) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ArtifactChange, Document, 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(Document::Path(path.clone())).unwrap() + } + + // ── Line kind serde ──────────────────────────────────────────────────── + + #[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(); + // Field ordering may differ; compare JSON values. + 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(_))); + } + + // ── Reader: error paths ──────────────────────────────────────────────── + + #[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() { + // First line has a valid body but wrong variant — FirstLineNotPathOpen. + 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() { + // `{"Step":{}}` has a known tag but a body that can't deserialize, + // so the error is `BadBody`, not `FirstLineNotPathOpen`. + 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() { + // Two steps, neither is a parent of the other → two candidates. + 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); + } + + // ── Reader: happy paths ──────────────────────────────────────────────── + + #[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"); + } + + // ── Writer ───────────────────────────────────────────────────────────── + + #[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(); + // The Step line must not contain any signature payload, only the + // following Signature line should carry it. + 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""#)); + } + + // ── Round-trip ───────────────────────────────────────────────────────── + + 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(); + // Writer always emits Head so reader doesn't have to disambiguate. + 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() { + // Sanity check that we don't mangle ArtifactChange fields through + // the transparent StepBody. + let art = ArtifactChange { + raw: Some("@@".into()), + structural: None, + }; + let v = serde_json::to_value(&art).unwrap(); + assert_eq!(v["raw"], "@@"); + } +} diff --git a/crates/toolpath/src/lib.rs b/crates/toolpath/src/lib.rs index 8480aa5..0a42688 100644 --- a/crates/toolpath/src/lib.rs +++ b/crates/toolpath/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +mod jsonl; mod query; mod types; @@ -67,6 +68,7 @@ pub mod v1 { //! id: "path-1".into(), //! base: Some(Base::vcs("github:org/repo", "abc123")), //! head: "s2".into(), + //! graph_ref: None, //! }, //! steps: vec![s1, s2], //! meta: None, @@ -117,6 +119,20 @@ pub mod v1 { filter_by_time_range, step_index, }; } + + /// JSONL streaming format for `Path` documents. + /// + /// See the module-level docs in [`crate::jsonl`] for the on-wire shape. + /// Read a file with [`Path::from_jsonl_reader`] / + /// [`Path::from_jsonl_str`], write with [`Path::to_jsonl_writer`] / + /// [`Path::to_jsonl_string`]. + pub mod jsonl { + pub use crate::jsonl::{ + ActorDefBody, HeadBody, JsonlError, JsonlLine, PathCloseBody, PathMetaBody, + PathMetaPatch, PathOpenBody, PathOpenMeta, SignatureBody, StepBody, + }; + } + pub use crate::types::{ ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, Key, Path, PathIdentity, PathMeta, PathOrRef, PathRef, Ref, Signature, Step, StepIdentity, diff --git a/crates/toolpath/src/types.rs b/crates/toolpath/src/types.rs index 384a7bc..b5e7bf7 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -157,6 +157,11 @@ pub struct PathIdentity { #[serde(default, skip_serializing_if = "Option::is_none")] pub base: Option, pub head: String, + /// Optional `$ref`-style URL naming the graph this path belongs to. + /// Uses the same URL conventions as `Graph.paths[*].$ref` + /// (e.g. `toolpath://archive/release-v2`, `https://...`, `file:///...`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph_ref: Option, } /// Root context for a path @@ -409,6 +414,7 @@ impl Path { id: id.into(), base, head: head.into(), + graph_ref: None, }, steps: Vec::new(), meta: None, @@ -566,6 +572,7 @@ mod tests { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc")), head: "s1".into(), + graph_ref: None, }, steps: vec![step], meta: None, diff --git a/examples/path-01-pr.json b/examples/path-01-pr.path.json similarity index 100% rename from examples/path-01-pr.json rename to examples/path-01-pr.path.json diff --git a/examples/path-01-pr.path.jsonl b/examples/path-01-pr.path.jsonl new file mode 100644 index 0000000..9665b6e --- /dev/null +++ b/examples/path-01-pr.path.jsonl @@ -0,0 +1,8 @@ +{"PathOpen":{"version":"1","id":"path-pr-42","base":{"uri":"github:myorg/myrepo","ref":"main"},"meta":{"title":"Add email validation","source":"github:myorg/myrepo/pull/42","refs":[{"rel":"fixes","href":"issue://github/myorg/myrepo/issues/42"}]}}} +{"Step":{"step":{"id":"step-001","actor":"human:alex","timestamp":"2026-01-29T10:00:00Z"},"change":{"src/main.rs":{"raw":"@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");"}}}} +{"Step":{"step":{"id":"step-002a","parents":["step-001"],"actor":"agent:claude-code/session-abc123","timestamp":"2026-01-29T10:03:00Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -1,5 +1,15 @@\n+use regex::Regex;..."}},"meta":{"intent":"Regex-based validation (abandoned)"}}} +{"Step":{"step":{"id":"step-002","parents":["step-001"],"actor":"agent:claude-code/session-abc123","timestamp":"2026-01-29T10:05:00Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -1,5 +1,25 @@\n+pub struct ValidationError..."}},"meta":{"intent":"Add email validation with custom error type"}}} +{"Step":{"step":{"id":"step-003","parents":["step-002"],"actor":"tool:rustfmt/1.7.0","timestamp":"2026-01-29T10:05:30Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -15,4 +15,8 @@\n-pub fn validate_email..."}},"meta":{"intent":"Auto-format"}}} +{"Step":{"step":{"id":"step-004","parents":["step-003"],"actor":"human:alex","timestamp":"2026-01-29T10:15:00Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -20,2 +20,2 @@\n-message: \"must contain @\"..."}},"meta":{"intent":"Refine error messages"}}} +{"Head":{"step_id":"step-004"}} +{"PathClose":{}} diff --git a/examples/path-02-local-session.json b/examples/path-02-local-session.path.json similarity index 100% rename from examples/path-02-local-session.json rename to examples/path-02-local-session.path.json diff --git a/examples/path-02-local-session.path.jsonl b/examples/path-02-local-session.path.jsonl new file mode 100644 index 0000000..c0173b0 --- /dev/null +++ b/examples/path-02-local-session.path.jsonl @@ -0,0 +1,6 @@ +{"PathOpen":{"version":"1","id":"path-session-xyz","base":{"uri":"file:///home/alex/projects/myrepo"},"meta":{"title":"Claude Code session: Add service config","source":"agent://claude-code/session-xyz"}}} +{"Step":{"step":{"id":"step-001","actor":"agent:claude-code/session-xyz","timestamp":"2026-01-29T14:00:00Z"},"change":{"src/lib.rs":{"raw":"@@ -1,3 +1,10 @@\n+/// Configuration for the service\n+pub struct Config {\n+ pub port: u16,\n+ pub host: String,\n+}\n","structural":{"type":"rust.add_items","items":[{"kind":"struct","name":"Config"}]}}},"meta":{"intent":"Add Config struct for service configuration","refs":[{"rel":"requested_by","href":"agent://claude-code/session-xyz/turn/1"}]}}} +{"Step":{"step":{"id":"step-002","parents":["step-001"],"actor":"agent:claude-code/session-xyz","timestamp":"2026-01-29T14:02:00Z"},"change":{"src/lib.rs":{"raw":"@@ -8,0 +9,15 @@\n+impl Config {\n+ pub fn from_env() -> Self {\n+ Self {\n+ port: std::env::var(\"PORT\")\n+ .unwrap_or_else(|_| \"8080\".to_string())\n+ .parse()\n+ .expect(\"PORT must be a number\"),\n+ host: std::env::var(\"HOST\")\n+ .unwrap_or_else(|_| \"localhost\".to_string()),\n+ }\n+ }\n+}\n","structural":{"type":"rust.add_items","items":[{"for":"Config","kind":"impl","methods":["from_env"]}]}}},"meta":{"intent":"Add from_env constructor for Config"}}} +{"Step":{"step":{"id":"step-003","parents":["step-002"],"actor":"tool:rustfmt/1.7.0","timestamp":"2026-01-29T14:02:05Z"},"change":{"src/lib.rs":{"raw":"@@ -12,4 +12,5 @@\n- port: std::env::var(\"PORT\").unwrap_or_else(|_| \"8080\".to_string()).parse().expect(\"PORT must be a number\"),\n+ port: std::env::var(\"PORT\")\n+ .unwrap_or_else(|_| \"8080\".to_string())\n+ .parse()\n+ .expect(\"PORT must be a number\"),"}},"meta":{"intent":"Auto-format"}}} +{"Head":{"step_id":"step-003"}} +{"PathClose":{}} diff --git a/examples/path-03-signed-pr.json b/examples/path-03-signed-pr.path.json similarity index 100% rename from examples/path-03-signed-pr.json rename to examples/path-03-signed-pr.path.json diff --git a/examples/path-03-signed-pr.path.jsonl b/examples/path-03-signed-pr.path.jsonl new file mode 100644 index 0000000..ebb5b36 --- /dev/null +++ b/examples/path-03-signed-pr.path.jsonl @@ -0,0 +1,13 @@ +{"PathOpen":{"version":"1","id":"path-pr-42","base":{"uri":"github:myorg/myrepo","ref":"main"},"meta":{"title":"Add email validation","source":"github:myorg/myrepo/pull/42","refs":[{"rel":"fixes","href":"issue://github/myorg/myrepo/issues/42"}]}}} +{"ActorDef":{"actor":"agent:claude-code","definition":{"name":"Claude Code","provider":"anthropic","model":"claude-sonnet-4-20250514","identities":[{"system":"anthropic","id":"claude-code/1.0.0"}]}}} +{"ActorDef":{"actor":"human:alex","definition":{"name":"Alex Kesling","identities":[{"system":"github","id":"akesling"},{"system":"email","id":"alex@empathic.dev"},{"system":"orcid","id":"0000-0001-2345-6789"}],"keys":[{"type":"gpg","fingerprint":"ABCD 1234 5678 90EF GHIJ KLMN OPQR STUV WXYZ","href":"https://keys.openpgp.org/vks/v1/by-fingerprint/..."}]}}} +{"ActorDef":{"actor":"human:bob","definition":{"name":"Bob Reviewer","identities":[{"system":"github","id":"bobreviewer"},{"system":"email","id":"bob@example.com"}],"keys":[{"type":"gpg","fingerprint":"WXYZ 9876 5432 10FE DCBA","href":"https://keys.openpgp.org/vks/v1/by-fingerprint/..."}]}}} +{"ActorDef":{"actor":"tool:rustfmt","definition":{"name":"rustfmt","identities":[{"system":"crates.io","id":"rustfmt-nightly/1.7.0"},{"system":"github","id":"rust-lang/rustfmt"}]}}} +{"Step":{"step":{"id":"step-001","actor":"agent:claude-code","timestamp":"2026-01-29T10:00:00Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -1,5 +1,25 @@\n+pub struct ValidationError {...}"}},"meta":{"intent":"Add email validation with custom error type"}}} +{"Step":{"step":{"id":"step-002","parents":["step-001"],"actor":"tool:rustfmt","timestamp":"2026-01-29T10:00:30Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -15,2 +15,5 @@\n-pub fn validate_email..."}},"meta":{"intent":"Auto-format"}}} +{"Step":{"step":{"id":"step-003","parents":["step-002"],"actor":"human:alex","timestamp":"2026-01-29T10:15:00Z"},"change":{"src/auth/validator.rs":{"raw":"@@ -20,2 +20,2 @@\n-message: \"must contain @\"..."}},"meta":{"intent":"Improve error messages for better UX"}}} +{"Signature":{"target":"path","signature":{"signer":"human:alex","key":"gpg:ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ","scope":"author","sig":"-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(author signature)...\n-----END PGP SIGNATURE-----","timestamp":"2026-01-29T10:20:00Z"}}} +{"Signature":{"target":"path","signature":{"signer":"human:bob","key":"gpg:WXYZ987654321FEDCBA","scope":"reviewer","sig":"-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(reviewer signature)...\n-----END PGP SIGNATURE-----","timestamp":"2026-01-29T11:00:00Z"}}} +{"Signature":{"target":"path","signature":{"signer":"ci:github-actions","key":"sigstore:github-actions/myorg/myrepo","scope":"ci","sig":"eyJhbGciOiJFUzI1NiIs...(sigstore attestation)...","timestamp":"2026-01-29T10:25:00Z"}}} +{"Head":{"step_id":"step-003"}} +{"PathClose":{}} diff --git a/examples/path-04-exploration.json b/examples/path-04-exploration.path.json similarity index 100% rename from examples/path-04-exploration.json rename to examples/path-04-exploration.path.json diff --git a/examples/path-04-exploration.path.jsonl b/examples/path-04-exploration.path.jsonl new file mode 100644 index 0000000..8e81169 --- /dev/null +++ b/examples/path-04-exploration.path.jsonl @@ -0,0 +1,12 @@ +{"PathOpen":{"version":"1","id":"path-explore-cli-args","base":{"uri":"github:myorg/myrepo","ref":"main"},"meta":{"title":"Explore CLI argument parsing approaches","source":"agent://claude-code/session-exp1"}}} +{"ActorDef":{"actor":"agent:claude-code/session-exp1","definition":{"name":"Claude Code","provider":"Anthropic","model":"claude-sonnet-4-20250514"}}} +{"ActorDef":{"actor":"human:alex","definition":{"name":"Alex","identities":[{"system":"github","id":"alexk"}]}}} +{"Step":{"step":{"id":"step-001","actor":"human:alex","timestamp":"2026-02-10T09:00:00Z"},"change":{"src/main.rs":{"raw":"@@ -1,3 +1,8 @@\n+use std::process;\n+\n fn main() {\n- println!(\"Hello, world!\");\n+ let args: Vec = std::env::args().collect();\n+ if args.len() < 2 {\n+ eprintln!(\"usage: mytool \");\n+ process::exit(1);\n+ }\n }"}},"meta":{"intent":"Scaffold CLI entry point with basic arg check"}}} +{"Step":{"step":{"id":"step-002a","parents":["step-001"],"actor":"agent:claude-code/session-exp1","timestamp":"2026-02-10T09:05:00Z"},"change":{"Cargo.toml":{"raw":"@@ -6,0 +7,2 @@\n+[dependencies]\n+clap = { version = \"4\", features = [\"derive\"] }"},"src/main.rs":{"raw":"@@ -1,8 +1,15 @@\n+use clap::Parser;\n+\n+#[derive(Parser)]\n+struct Cli {\n+ command: String,\n+}\n+\n fn main() {\n- let args: Vec = std::env::args().collect();\n- if args.len() < 2 {\n- eprintln!(\"usage: mytool \");\n- process::exit(1);\n- }\n+ let cli = Cli::parse();\n+ println!(\"Running: {}\", cli.command);\n }"}},"meta":{"intent":"Try clap derive macros (abandoned: too much codegen)"}}} +{"Step":{"step":{"id":"step-002b","parents":["step-001"],"actor":"agent:claude-code/session-exp1","timestamp":"2026-02-10T09:08:00Z"},"change":{"src/main.rs":{"raw":"@@ -1,8 +1,20 @@\n-use std::process;\n+use std::process;\n+use std::env;\n \n fn main() {\n- let args: Vec = std::env::args().collect();\n- if args.len() < 2 {\n- eprintln!(\"usage: mytool \");\n- process::exit(1);\n- }\n+ let args: Vec = env::args().collect();\n+ let cmd = args.get(1).map(|s| s.as_str());\n+ match cmd {\n+ Some(\"run\") => run(&args[2..]),\n+ Some(\"help\") | None => print_usage(),\n+ Some(other) => {\n+ eprintln!(\"unknown command: {other}\");\n+ process::exit(1);\n+ }\n+ }\n+}\n+\n+fn run(_args: &[String]) { todo!() }\n+fn print_usage() { println!(\"usage: mytool \"); }"}},"meta":{"intent":"Try manual arg parsing with match"}}} +{"Step":{"step":{"id":"step-003b","parents":["step-002b"],"actor":"tool:clippy/0.1.84","timestamp":"2026-02-10T09:09:00Z"},"change":{"src/main.rs":{"structural":{"type":"rust.rename","from":"_args","to":"args"}}},"meta":{"intent":"Fix clippy: unused variable prefix"}}} +{"Step":{"step":{"id":"step-002c","parents":["step-001"],"actor":"agent:claude-code/session-exp1","timestamp":"2026-02-10T09:12:00Z"},"change":{"src/main.rs":{"raw":"@@ -1,8 +1,18 @@\n-use std::process;\n+use clap::{Command, Arg};\n \n fn main() {\n- let args: Vec = std::env::args().collect();\n- if args.len() < 2 {\n- eprintln!(\"usage: mytool \");\n- process::exit(1);\n- }\n+ let matches = Command::new(\"mytool\")\n+ .subcommand(Command::new(\"run\")\n+ .about(\"Run the tool\")\n+ .arg(Arg::new(\"verbose\").short('v')))\n+ .subcommand(Command::new(\"help\")\n+ .about(\"Print help\"))\n+ .get_matches();\n+\n+ match matches.subcommand() {\n+ Some((\"run\", sub)) => run(sub),\n+ _ => println!(\"usage: mytool \"),\n+ }\n+}\n+\n+fn run(_sub: &clap::ArgMatches) { todo!() }"},"Cargo.toml":{"raw":"@@ -6,0 +7,2 @@\n+[dependencies]\n+clap = \"4\""}},"meta":{"intent":"Try clap builder API (no derive macros)"}}} +{"Step":{"step":{"id":"step-003c","parents":["step-002c"],"actor":"tool:rustfmt/1.7.0","timestamp":"2026-02-10T09:13:00Z"},"change":{"src/main.rs":{"raw":"@@ -4,6 +4,10 @@\n- let matches = Command::new(\"mytool\")\n- .subcommand(Command::new(\"run\")\n- .about(\"Run the tool\")\n- .arg(Arg::new(\"verbose\").short('v')))\n- .subcommand(Command::new(\"help\")\n- .about(\"Print help\"))\n+ let matches = Command::new(\"mytool\")\n+ .subcommand(\n+ Command::new(\"run\")\n+ .about(\"Run the tool\")\n+ .arg(Arg::new(\"verbose\").short('v')),\n+ )\n+ .subcommand(Command::new(\"help\").about(\"Print help\"))"}},"meta":{"intent":"Auto-format"}}} +{"Step":{"step":{"id":"step-004","parents":["step-003b","step-003c"],"actor":"human:alex","timestamp":"2026-02-10T09:20:00Z"},"change":{"src/main.rs":{"raw":"@@ -1,18 +1,25 @@\n-use clap::{Command, Arg};\n+use clap::{Arg, Command};\n+use std::env;\n \n fn main() {\n let matches = Command::new(\"mytool\")\n .subcommand(\n Command::new(\"run\")\n .about(\"Run the tool\")\n .arg(Arg::new(\"verbose\").short('v')),\n )\n .subcommand(Command::new(\"help\").about(\"Print help\"))\n .get_matches();\n \n match matches.subcommand() {\n- Some((\"run\", sub)) => run(sub),\n+ Some((\"run\", sub)) => {\n+ let verbose = sub.get_flag(\"verbose\");\n+ run(verbose);\n+ }\n _ => println!(\"usage: mytool \"),\n }\n }\n \n-fn run(_sub: &clap::ArgMatches) { todo!() }\n+fn run(verbose: bool) {\n+ if verbose { println!(\"verbose mode\"); }\n+ println!(\"running\");\n+}"}},"meta":{"intent":"Merge builder API with manual match dispatch, wire up verbose flag"}}} +{"Head":{"step_id":"step-004"}} +{"PathClose":{}} diff --git a/schema/toolpath.schema.json b/schema/toolpath.schema.json index 2985760..59da596 100644 --- a/schema/toolpath.schema.json +++ b/schema/toolpath.schema.json @@ -309,6 +309,14 @@ "head": { "type": "string", "description": "Current tip step ID" + }, + "graph_ref": { + "type": "string", + "description": "Optional $ref-style URL naming the graph this path belongs to", + "examples": [ + "toolpath://archive/release-v2", + "https://archive.example.com/graphs/release-v2.json" + ] } }, "required": ["id", "base", "head"], diff --git a/site/_data/crates.json b/site/_data/crates.json index bf0331e..8158aaa 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -1,7 +1,7 @@ [ { "name": "toolpath", - "version": "0.1.5", + "version": "0.2.0", "description": "Core types, builders, and query API", "docs": "https://docs.rs/toolpath", "crate": "https://crates.io/crates/toolpath", @@ -57,7 +57,7 @@ }, { "name": "toolpath-cli", - "version": "0.3.0", + "version": "0.4.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", diff --git a/site/eleventy.config.js b/site/eleventy.config.js index 7a223b0..add9a11 100644 --- a/site/eleventy.config.js +++ b/site/eleventy.config.js @@ -68,7 +68,7 @@ export default function (eleventyConfig) { eleventyConfig.addGlobalData("playgroundFiles", () => { const files = [ "step-01-minimal.json", - "path-01-pr.json", + "path-01-pr.path.json", "graph-01-release.json", ]; const result = {}; @@ -81,10 +81,10 @@ export default function (eleventyConfig) { eleventyConfig.addGlobalData("vizExamples", () => { const examples = [ { file: "step-01-minimal.json", name: "Step: minimal" }, - { file: "path-01-pr.json", name: "Path: PR with dead end" }, - { file: "path-02-local-session.json", name: "Path: local session" }, - { file: "path-03-signed-pr.json", name: "Path: signed PR" }, - { file: "path-04-exploration.json", name: "Path: exploration & merge" }, + { file: "path-01-pr.path.json", name: "Path: PR with dead end" }, + { file: "path-02-local-session.path.json", name: "Path: local session" }, + { file: "path-03-signed-pr.path.json", name: "Path: signed PR" }, + { file: "path-04-exploration.path.json", name: "Path: exploration & merge" }, { file: "graph-01-release.json", name: "Graph: release bundle" }, ]; return examples.map((e) => ({ diff --git a/site/js/playground.js b/site/js/playground.js index b4e85c1..bc0a5cd 100644 --- a/site/js/playground.js +++ b/site/js/playground.js @@ -1366,7 +1366,7 @@ .then(function () { shell.term.write(copperBold("path") + " " + pencil("$") + " "); shell.autoType( - "path query dead-ends --input path-01-pr.json --pretty", + "path query dead-ends --input path-01-pr.path.json --pretty", function () { shell.prompt(); },