diff --git a/CHANGELOG.md b/CHANGELOG.md index db43c41..f14b70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to the Toolpath workspace are documented here. +## 0.2.0 — toolpath-github + toolpath-md + +### toolpath-github 0.2.0 + +- Capture `diff_hunk` on review comments as `raw` for code context display +- Capture PR summary stats in path meta: state, merged, additions, deletions, changed_files, draft, number, author +- Capture `author_association` (MEMBER, COLLABORATOR, etc.) stored in `extra["github"]["actor_associations"]` +- Capture `html_url` on CI check runs in structural extra +- Set review body as `meta.intent` on review decision steps for renderer visibility +- Thread review comment replies via `in_reply_to_id` — replies branch off the step they reply to instead of trunk-chaining + +### toolpath-md 0.2.0 + +- Render review comment bodies inline in both summary and full modes (no more opaque `review://` URIs) +- Render CI conclusions with emoji indicators (pass/fail/skip) in summary mode +- Show diff_hunk code context alongside review comments in full mode +- Add PR-level diffstat to context block (`**Changes:** +N −M across K files`) +- Add Review summary section collecting all decisions and inline comments +- Friendly date range display in context block (e.g. `Feb 26–27, 2026`) +- PR identity line when GitHub metadata present (`**PR #6** by author · status · dates`) +- Hide opaque head ID when PR identity is shown +- Strip `review://` and `ci://checks/` prefixes for natural display names + +## 0.1.0 — toolpath-md + +### toolpath-md 0.1.0 + +- New crate: render Toolpath documents as Markdown for LLM consumption +- Handles all three document variants: Step, Path, and Graph +- Two detail levels: `Summary` (file-level diffstats) and `Full` (inline diffs as fenced code blocks) +- Optional YAML front matter with machine-readable metadata (step count, actors, artifacts, dead end count) +- Dead ends are marked inline and summarized in a dedicated section with intent and parent references +- Topological sort ensures steps appear in causal order regardless of input ordering +- Actor definitions rendered when present in path/graph metadata +- CLI: `path render md [--input FILE] [--detail summary|full] [--front-matter]` + ## 0.1.0 — toolpath-github ### toolpath-github 0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 38b4f1d..25ede34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ crates/ toolpath-github/ # derive from GitHub pull requests (REST API) toolpath-claude/ # derive from Claude conversation logs toolpath-dot/ # Graphviz DOT rendering + toolpath-md/ # Markdown rendering for LLM consumption toolpath-cli/ # unified CLI (binary: path) schema/toolpath.schema.json # JSON Schema for the format examples/*.json # 12 example documents (step, path, graph) @@ -33,7 +34,8 @@ toolpath-cli (binary: path) ├── toolpath-git → toolpath ├── toolpath-github → toolpath ├── toolpath-claude → toolpath, toolpath-convo - └── toolpath-dot → toolpath + ├── toolpath-dot → toolpath + └── toolpath-md → toolpath ``` No cross-dependencies between satellite crates except `toolpath-claude → toolpath-convo`. @@ -57,6 +59,7 @@ cargo run -p toolpath-cli -- derive git --repo . --branch main --pretty cargo run -p toolpath-cli -- derive github --repo owner/repo --pr 42 --pretty cargo run -p toolpath-cli -- derive claude --project /path/to/project cargo run -p toolpath-cli -- render dot --input doc.json +cargo run -p toolpath-cli -- render md --input doc.json --detail full cargo run -p toolpath-cli -- query dead-ends --input doc.json cargo run -p toolpath-cli -- query ancestors --input doc.json --step-id step-003 cargo run -p toolpath-cli -- query filter --input doc.json --actor "agent:" @@ -85,7 +88,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `toolpath-cli` ha - `toolpath-github`: 28 unit + 2 doc tests (mapping, DAG construction, fixtures) - `toolpath-claude`: 216 unit + 5 doc tests (path resolution, conversation reading, query, chaining, watcher, derive) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `toolpath-cli`: 120 unit + 8 integration tests (all commands, track sessions, merge, validate, roundtrip) +- `toolpath-cli`: 126 unit + 24 integration tests (all commands, track sessions, merge, validate, roundtrip, render-md snapshots) Validate example documents: `for f in examples/*.json; do cargo run -p toolpath-cli -- validate --input "$f"; done` diff --git a/Cargo.lock b/Cargo.lock index d41d141..43bf9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -265,6 +277,12 @@ dependencies = [ "syn", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -789,6 +807,18 @@ dependencies = [ "libc", ] +[[package]] +name = "insta" +version = "1.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instant" version = "0.1.13" @@ -1786,6 +1816,7 @@ dependencies = [ "chrono", "clap", "git2", + "insta", "predicates", "rand", "serde", @@ -1797,6 +1828,7 @@ dependencies = [ "toolpath-dot", "toolpath-git", "toolpath-github", + "toolpath-md", ] [[package]] @@ -1829,7 +1861,7 @@ dependencies = [ [[package]] name = "toolpath-github" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", @@ -1839,6 +1871,14 @@ dependencies = [ "toolpath", ] +[[package]] +name = "toolpath-md" +version = "0.2.0" +dependencies = [ + "serde_json", + "toolpath", +] + [[package]] name = "tower" version = "0.5.3" @@ -2200,6 +2240,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 5760431..f2b9691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/toolpath-github", "crates/toolpath-claude", "crates/toolpath-dot", + "crates/toolpath-md", "crates/toolpath-cli", ] resolver = "2" @@ -19,8 +20,9 @@ toolpath = { version = "0.1.5", 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 } -toolpath-github = { version = "0.1.0", path = "crates/toolpath-github" } +toolpath-github = { version = "0.2.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.1.2", path = "crates/toolpath-dot" } +toolpath-md = { version = "0.2.0", path = "crates/toolpath-md" } reqwest = { version = "0.12", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } @@ -34,6 +36,7 @@ tokio = { version = "1.40", features = ["full"] } notify = { version = "7", features = ["macos_kqueue"] } similar = "2" tempfile = "3.15" +insta = "1" [profile.wasm] inherits = "release" diff --git a/README.md b/README.md index 3db7cb2..221e7cc 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ crates/ toolpath-github/ Derive from GitHub pull requests toolpath-claude/ Derive from Claude conversation logs toolpath-dot/ Graphviz DOT visualization + toolpath-md/ Markdown rendering for LLM consumption toolpath-cli/ Unified CLI (binary: path) ``` @@ -63,6 +64,9 @@ path derive git --repo . --branch main --pretty # Visualize it path derive git --repo . --branch main | path render dot | dot -Tpng -o graph.png +# Render as Markdown for an LLM +path derive git --repo . --branch main | path render md + # Derive from a GitHub pull request path derive github --repo owner/repo --pr 42 --pretty @@ -103,6 +107,7 @@ path filter --input FILE [--actor PREFIX] [--artifact PATH] [--after TIME] [--before TIME] render dot [--input FILE] [--output FILE] [--show-files] [--show-timestamps] + md [--input FILE] [--output FILE] [--detail summary|full] [--front-matter] merge FILE... [--title TEXT] track init --file PATH --actor ACTOR [--title TEXT] [--base-uri URI] [--base-ref REF] @@ -166,6 +171,14 @@ use toolpath_dot::{render, RenderOptions}; let dot_string = render(&doc, &RenderOptions::default()); ``` +### Markdown rendering + +```rust +use toolpath_md::{render, RenderOptions}; + +let md_string = render(&doc, &RenderOptions::default()); +``` + ## Documentation - [RFC.md](RFC.md) -- Full format specification diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 5433207..aa7a287 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" toolpath = { workspace = true } toolpath-git = { workspace = true } toolpath-dot = { workspace = true } +toolpath-md = { workspace = true } clap = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } @@ -36,3 +37,4 @@ toolpath-claude = { workspace = true } [dev-dependencies] assert_cmd = "2" predicates = "3" +insta = { workspace = true } diff --git a/crates/toolpath-cli/src/cmd_render.rs b/crates/toolpath-cli/src/cmd_render.rs index 91545e6..d56c07b 100644 --- a/crates/toolpath-cli/src/cmd_render.rs +++ b/crates/toolpath-cli/src/cmd_render.rs @@ -27,6 +27,24 @@ pub enum RenderFormat { #[arg(long, default_value = "true")] highlight_dead_ends: bool, }, + /// Render as Markdown (for LLM consumption) + Md { + /// Input file (reads from stdin if not provided) + #[arg(short, long)] + input: Option, + + /// Output file (writes to stdout if not provided) + #[arg(short, long)] + output: Option, + + /// Detail level: summary (file-level diffstats) or full (inline diffs) + #[arg(long, default_value = "summary")] + detail: String, + + /// Include YAML front matter with machine-readable metadata + #[arg(long)] + front_matter: bool, + }, } pub fn run(format: RenderFormat) -> Result<()> { @@ -44,6 +62,12 @@ pub fn run(format: RenderFormat) -> Result<()> { show_timestamps, highlight_dead_ends, ), + RenderFormat::Md { + input, + output, + detail, + front_matter, + } => run_md(input, output, &detail, front_matter), } } @@ -84,6 +108,46 @@ fn run_dot( Ok(()) } +fn run_md( + input: Option, + output: Option, + 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))? + } 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 + }; + + 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, + }; + + let options = toolpath_md::RenderOptions { + detail, + front_matter, + }; + + let md = toolpath_md::render(&doc, &options); + + if let Some(path) = &output { + std::fs::write(path, &md).with_context(|| format!("Failed to write {:?}", path))?; + } else { + print!("{}", md); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +243,95 @@ mod tests { let result = run_dot(Some(f.path().to_path_buf()), None, false, false, false); assert!(result.is_ok()); } + + // ── run_md ─────────────────────────────────────────────────────────── + + #[test] + fn test_run_md_with_input_file() { + let doc = make_doc(); + let mut f = tempfile::NamedTempFile::new().unwrap(); + write!(f, "{}", doc.to_json().unwrap()).unwrap(); + f.flush().unwrap(); + + let result = run_md(Some(f.path().to_path_buf()), None, "summary", false); + assert!(result.is_ok()); + } + + #[test] + fn test_run_md_with_output_file() { + let doc = make_doc(); + let mut f = tempfile::NamedTempFile::new().unwrap(); + write!(f, "{}", doc.to_json().unwrap()).unwrap(); + f.flush().unwrap(); + + let out = tempfile::NamedTempFile::new().unwrap(); + let result = run_md( + Some(f.path().to_path_buf()), + Some(out.path().to_path_buf()), + "summary", + false, + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(out.path()).unwrap(); + assert!(content.contains("# p1")); + assert!(content.contains("## Timeline")); + } + + #[test] + fn test_run_md_full_detail() { + let doc = make_doc(); + let mut f = tempfile::NamedTempFile::new().unwrap(); + write!(f, "{}", doc.to_json().unwrap()).unwrap(); + f.flush().unwrap(); + + let out = tempfile::NamedTempFile::new().unwrap(); + let result = run_md( + Some(f.path().to_path_buf()), + Some(out.path().to_path_buf()), + "full", + false, + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(out.path()).unwrap(); + assert!(content.contains("```diff")); + } + + #[test] + fn test_run_md_with_front_matter() { + let doc = make_doc(); + let mut f = tempfile::NamedTempFile::new().unwrap(); + write!(f, "{}", doc.to_json().unwrap()).unwrap(); + f.flush().unwrap(); + + let out = tempfile::NamedTempFile::new().unwrap(); + let result = run_md( + Some(f.path().to_path_buf()), + Some(out.path().to_path_buf()), + "summary", + true, + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(out.path()).unwrap(); + assert!(content.starts_with("---\n")); + assert!(content.contains("type: path")); + } + + #[test] + fn test_run_md_invalid_input() { + let result = run_md(Some(PathBuf::from("/nonexistent")), None, "summary", false); + assert!(result.is_err()); + } + + #[test] + fn test_run_md_invalid_json() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + write!(f, "not valid json").unwrap(); + f.flush().unwrap(); + + let result = run_md(Some(f.path().to_path_buf()), None, "summary", false); + assert!(result.is_err()); + } } diff --git a/crates/toolpath-cli/tests/render_md_snapshots.rs b/crates/toolpath-cli/tests/render_md_snapshots.rs new file mode 100644 index 0000000..22fa6aa --- /dev/null +++ b/crates/toolpath-cli/tests/render_md_snapshots.rs @@ -0,0 +1,54 @@ +use assert_cmd::Command; +use std::path::PathBuf; + +fn examples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +fn render_md(example: &str) -> String { + let output = Command::cargo_bin("path") + .unwrap() + .args(["render", "md", "--input"]) + .arg(examples_dir().join(example)) + .output() + .unwrap(); + assert!( + output.status.success(), + "failed for {example}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +macro_rules! snapshot_test { + ($name:ident, $file:expr) => { + #[test] + fn $name() { + insta::assert_snapshot!(render_md($file)); + } + }; +} + +// Steps (7) +snapshot_test!(render_md_step_01_minimal, "step-01-minimal.json"); +snapshot_test!(render_md_step_02_agent, "step-02-agent.json"); +snapshot_test!(render_md_step_03_formatter, "step-03-formatter.json"); +snapshot_test!( + render_md_step_04_human_refinement, + "step-04-human-refinement.json" +); +snapshot_test!(render_md_step_05_dead_end, "step-05-dead-end.json"); +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_02_local_session, + "path-02-local-session.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"); + +// 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_graph_01_release.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_graph_01_release.snap new file mode 100644 index 0000000..cf079e6 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_graph_01_release.snap @@ -0,0 +1,90 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"graph-01-release.json\")" +--- +# Release v2.0 + +- **milestone:** `issue://github/myorg/myrepo/milestone/5` +- **changelog:** `doc://CHANGELOG.md#v2.0` + +| Path | Steps | Actors | Head | +|------|-------|--------|------| +| Add email validation | 3 | `agent:claude-code`, `human:alex`, `tool:rustfmt` | `step-003` | +| Fix session timeout bug | 2 | `agent:claude-code`, `human:bob` | `step-002` | + +**External references:** +- `https://archive.example.com/toolpath/path-pr-44.json` +- `toolpath://internal/path-pr-45` + +--- + +## Add email validation + +**Base:** `github:myorg/myrepo` @ `main` +**Head:** `step-003` +**Source:** `github:myorg/myrepo/pull/42` +**Changes:** +1 −2 across 1 files +**Steps:** 3 | **Artifacts:** 1 | **Dead ends:** 0 + +### step-001 — agent:claude-code + +**Timestamp:** 2026-01-29T10:00:00Z + +> Add email validation with custom error type + +- `src/auth/validator.rs` (+1 -0) + +### step-002 — tool:rustfmt + +**Timestamp:** 2026-01-29T10:00:30Z +**Parents:** `step-001` + +> Auto-format + +- `src/auth/validator.rs` (+0 -1) + +### step-003 — human:alex [head] + +**Timestamp:** 2026-01-29T10:15:00Z +**Parents:** `step-002` + +> Improve error messages for better UX + +- `src/auth/validator.rs` (+0 -1) + +--- + +## Fix session timeout bug + +**Base:** `github:myorg/myrepo` @ `main` +**Head:** `step-002` +**Source:** `github:myorg/myrepo/pull/43` +**Changes:** +2 −0 across 1 files +**Steps:** 2 | **Artifacts:** 1 | **Dead ends:** 0 + +### step-001 — human:bob + +**Timestamp:** 2026-01-28T14:00:00Z + +> Add session refresh to prevent timeouts + +- `src/auth/session.rs` (+1 -0) + +### step-002 — agent:claude-code [head] + +**Timestamp:** 2026-01-28T14:30:00Z +**Parents:** `step-001` + +> Add edge case handling for expired refresh tokens + +- `src/auth/session.rs` (+1 -0) + +--- + +## Actors + +- **`agent:claude-code`** — Claude Code (anthropic, claude-sonnet-4-20250514) +- **`human:alex`** — Alex Kesling +- **`human:bob`** — Bob Developer +- **`human:release-manager`** — Release Manager +- **`tool:rustfmt`** — rustfmt 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 new file mode 100644 index 0000000..ff4fc83 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_01_pr.snap @@ -0,0 +1,62 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"path-01-pr.json\")" +--- +# Add email validation + +**Base:** `github:myorg/myrepo` @ `main` +**Head:** `step-004` +**Source:** `github:myorg/myrepo/pull/42` +- **fixes:** `issue://github/myorg/myrepo/issues/42` +**Changes:** +3 −3 across 2 files +**Steps:** 5 | **Artifacts:** 2 | **Dead ends:** 1 + +## Timeline + +### step-001 — human:alex + +**Timestamp:** 2026-01-29T10:00:00Z + +- `src/main.rs` (+1 -1) + +### step-002a — agent:claude-code/session-abc123 [dead end] + +**Timestamp:** 2026-01-29T10:03:00Z +**Parents:** `step-001` + +> Regex-based validation (abandoned) + +- `src/auth/validator.rs` (+1 -0) + +### step-002 — agent:claude-code/session-abc123 + +**Timestamp:** 2026-01-29T10:05:00Z +**Parents:** `step-001` + +> Add email validation with custom error type + +- `src/auth/validator.rs` (+1 -0) + +### step-003 — tool:rustfmt/1.7.0 + +**Timestamp:** 2026-01-29T10:05:30Z +**Parents:** `step-002` + +> Auto-format + +- `src/auth/validator.rs` (+0 -1) + +### step-004 — human:alex [head] + +**Timestamp:** 2026-01-29T10:15:00Z +**Parents:** `step-003` + +> Refine error messages + +- `src/auth/validator.rs` (+0 -1) + +## Dead Ends + +These steps were attempted but did not contribute to the final result. + +- **step-002a** (agent:claude-code/session-abc123) — Regex-based validation (abandoned) | Parent: `step-001` 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 new file mode 100644 index 0000000..bfbd678 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_02_local_session.snap @@ -0,0 +1,41 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"path-02-local-session.json\")" +--- +# Claude Code session: Add service config + +**Base:** `file:///home/alex/projects/myrepo` +**Head:** `step-003` +**Source:** `agent://claude-code/session-xyz` +**Changes:** +21 −1 across 1 files +**Steps:** 3 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-001 — agent:claude-code/session-xyz + +**Timestamp:** 2026-01-29T14:00:00Z + +> Add Config struct for service configuration + +- **requested_by:** `agent://claude-code/session-xyz/turn/1` + +- `src/lib.rs` (+5 -0, rust.add_items) + +### step-002 — agent:claude-code/session-xyz + +**Timestamp:** 2026-01-29T14:02:00Z +**Parents:** `step-001` + +> Add from_env constructor for Config + +- `src/lib.rs` (+12 -0, rust.add_items) + +### step-003 — tool:rustfmt/1.7.0 [head] + +**Timestamp:** 2026-01-29T14:02:05Z +**Parents:** `step-002` + +> Auto-format + +- `src/lib.rs` (+4 -1) 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 new file mode 100644 index 0000000..7429a74 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_03_signed_pr.snap @@ -0,0 +1,47 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"path-03-signed-pr.json\")" +--- +# Add email validation + +**Base:** `github:myorg/myrepo` @ `main` +**Head:** `step-003` +**Source:** `github:myorg/myrepo/pull/42` +- **fixes:** `issue://github/myorg/myrepo/issues/42` +**Changes:** +1 −2 across 1 files +**Steps:** 3 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-001 — agent:claude-code + +**Timestamp:** 2026-01-29T10:00:00Z + +> Add email validation with custom error type + +- `src/auth/validator.rs` (+1 -0) + +### step-002 — tool:rustfmt + +**Timestamp:** 2026-01-29T10:00:30Z +**Parents:** `step-001` + +> Auto-format + +- `src/auth/validator.rs` (+0 -1) + +### step-003 — human:alex [head] + +**Timestamp:** 2026-01-29T10:15:00Z +**Parents:** `step-002` + +> Improve error messages for better UX + +- `src/auth/validator.rs` (+0 -1) + +## Actors + +- **`agent:claude-code`** — Claude Code (anthropic, claude-sonnet-4-20250514) +- **`human:alex`** — Alex Kesling +- **`human:bob`** — Bob Reviewer +- **`tool:rustfmt`** — rustfmt 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 new file mode 100644 index 0000000..ff35ae0 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_path_04_exploration.snap @@ -0,0 +1,88 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"path-04-exploration.json\")" +--- +# Explore CLI argument parsing approaches + +**Base:** `github:myorg/myrepo` @ `main` +**Head:** `step-004` +**Source:** `agent://claude-code/session-exp1` +**Changes:** +69 −27 across 2 files +**Steps:** 7 | **Artifacts:** 2 | **Dead ends:** 1 + +## Timeline + +### step-001 — human:alex + +**Timestamp:** 2026-02-10T09:00:00Z + +> Scaffold CLI entry point with basic arg check + +- `src/main.rs` (+7 -1) + +### step-002a — agent:claude-code/session-exp1 [dead end] + +**Timestamp:** 2026-02-10T09:05:00Z +**Parents:** `step-001` + +> Try clap derive macros (abandoned: too much codegen) + +- `Cargo.toml` (+2 -0) +- `src/main.rs` (+9 -5) + +### step-002b — agent:claude-code/session-exp1 + +**Timestamp:** 2026-02-10T09:08:00Z +**Parents:** `step-001` + +> Try manual arg parsing with match + +- `src/main.rs` (+16 -6) + +### step-002c — agent:claude-code/session-exp1 + +**Timestamp:** 2026-02-10T09:12:00Z +**Parents:** `step-001` + +> Try clap builder API (no derive macros) + +- `Cargo.toml` (+2 -0) +- `src/main.rs` (+16 -6) + +### step-003b — tool:clippy/0.1.84 + +**Timestamp:** 2026-02-10T09:09:00Z +**Parents:** `step-002b` + +> Fix clippy: unused variable prefix + +- `src/main.rs` (rust.rename) + +### step-003c — tool:rustfmt/1.7.0 + +**Timestamp:** 2026-02-10T09:13:00Z +**Parents:** `step-002c` + +> Auto-format + +- `src/main.rs` (+7 -6) + +### step-004 — human:alex [head] + +**Timestamp:** 2026-02-10T09:20:00Z +**Parents:** `step-003b`, `step-003c` + +> Merge builder API with manual match dispatch, wire up verbose flag + +- `src/main.rs` (+10 -3) + +## Dead Ends + +These steps were attempted but did not contribute to the final result. + +- **step-002a** (agent:claude-code/session-exp1) — Try clap derive macros (abandoned: too much codegen) | Parent: `step-001` + +## Actors + +- **`agent:claude-code/session-exp1`** — Claude Code (Anthropic, claude-sonnet-4-20250514) +- **`human:alex`** — Alex diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap new file mode 100644 index 0000000..d394590 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap @@ -0,0 +1,12 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-01-minimal.json\")" +--- +# step-001 + +**Actor:** `human:alex` +**Timestamp:** 2026-01-29T10:00:00Z + +## Changes + +- `src/main.rs` (+1 -1) diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap new file mode 100644 index 0000000..d2ea685 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap @@ -0,0 +1,20 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-02-agent.json\")" +--- +# step-002 + +**Actor:** `agent:claude-code/session-abc123` +**Timestamp:** 2026-01-29T10:05:00Z +**Parents:** `step-001` + +> Add email validation to prevent malformed input from reaching the database + +- **fixes:** `issue://github/myrepo/issues/42` +- **implements:** `doc://design/input-validation-2026q1.md` +- **reasoning:** `agent://claude-code/session-abc123/turn/3` + +## Changes + +- `src/auth/mod.rs` (+1 -0) +- `src/auth/validator.rs` (+23 -0, rust.add_items) diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap new file mode 100644 index 0000000..4c40eee --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap @@ -0,0 +1,15 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-03-formatter.json\")" +--- +# step-003 + +**Actor:** `tool:rustfmt/1.7.0` +**Timestamp:** 2026-01-29T10:05:30Z +**Parents:** `step-002` + +> Automatic code formatting + +## Changes + +- `src/auth/validator.rs` (+8 -3) diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap new file mode 100644 index 0000000..40bebd8 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap @@ -0,0 +1,17 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-04-human-refinement.json\")" +--- +# step-004 + +**Actor:** `human:alex` +**Timestamp:** 2026-01-29T10:15:00Z +**Parents:** `step-003` + +> Improve error message clarity and use idiomatic .into() + +- **refines:** `toolpath://step-002` + +## Changes + +- `src/auth/validator.rs` (+2 -2, rust.modify_expressions) diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap new file mode 100644 index 0000000..733afca --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap @@ -0,0 +1,15 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-05-dead-end.json\")" +--- +# step-002a + +**Actor:** `agent:claude-code/session-abc123` +**Timestamp:** 2026-01-29T10:03:00Z +**Parents:** `step-001` + +> Validate email addresses using regex pattern matching + +## Changes + +- `src/auth/validator.rs` (+12 -0, rust.add_items) diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap new file mode 100644 index 0000000..a4ba0c4 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap @@ -0,0 +1,14 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-06-signed.json\")" +--- +# step-001 + +**Actor:** `human:alex` +**Timestamp:** 2026-01-29T10:00:00Z + +> Fix greeting punctuation + +## Changes + +- `src/main.rs` (+1 -1) diff --git a/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap new file mode 100644 index 0000000..14ee218 --- /dev/null +++ b/crates/toolpath-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap @@ -0,0 +1,15 @@ +--- +source: crates/toolpath-cli/tests/render_md_snapshots.rs +expression: "render_md(\"step-07-merge.json\")" +--- +# step-004 + +**Actor:** `human:alex` +**Timestamp:** 2026-01-29T12:00:00Z +**Parents:** `step-002a`, `step-003b` + +> Merge validation improvements from feature-A with logging from feature-B + +## Changes + +- `src/auth/validator.rs` diff --git a/crates/toolpath-github/Cargo.toml b/crates/toolpath-github/Cargo.toml index 1281633..47c0da9 100644 --- a/crates/toolpath-github/Cargo.toml +++ b/crates/toolpath-github/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-github" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-github/src/lib.rs b/crates/toolpath-github/src/lib.rs index 1b04d38..eb01922 100644 --- a/crates/toolpath-github/src/lib.rs +++ b/crates/toolpath-github/src/lib.rs @@ -428,21 +428,22 @@ mod native { // ── Commit steps ───────────────────────────────────────────── let mut steps: Vec = Vec::new(); let mut actors: HashMap = HashMap::new(); + let mut actor_associations: HashMap = HashMap::new(); for detail in commit_details { - let step = commit_to_step(detail, &mut actors)?; + let step = commit_to_step(detail, &mut actors, &mut actor_associations)?; steps.push(step); } // ── Review comment steps ───────────────────────────────────── if config.include_comments { for rc in review_comments { - let step = review_comment_to_step(rc, &mut actors)?; + let step = review_comment_to_step(rc, &mut actors, &mut actor_associations)?; steps.push(step); } for pc in pr_comments { - let step = pr_comment_to_step(pc, &mut actors)?; + let step = pr_comment_to_step(pc, &mut actors, &mut actor_associations)?; steps.push(step); } @@ -451,7 +452,7 @@ mod native { if state.is_empty() || state == "PENDING" { continue; } - let step = review_to_step(review, &mut actors)?; + let step = review_to_step(review, &mut actors, &mut actor_associations)?; steps.push(step); } } @@ -466,31 +467,60 @@ mod native { } } - // ── Sort by timestamp, then chain into a single trunk ──────── - // Everything in a PR is part of one timeline. Commits, comments, - // reviews, and CI checks all chain linearly — none are dead ends - // or alternate explorations. Sort by time, then re-parent each - // step to point at the previous one. + // ── Sort by timestamp, then chain into trunk + reply threads ─ + // Most steps chain linearly by time (the trunk). Review comment + // replies (in_reply_to_id) branch off the step they reply to. steps.sort_by(|a, b| a.step.timestamp.cmp(&b.step.timestamp)); + // Build a map from GitHub comment id -> step id for reply resolution + let reply_target: HashMap = steps + .iter() + .filter_map(|s| { + let id_str = s.step.id.strip_prefix("step-rc-")?; + let github_id: u64 = id_str.parse().ok()?; + Some((github_id, s.step.id.clone())) + }) + .collect(); + + // Identify which steps are replies (and to whom) + let reply_parents: HashMap = steps + .iter() + .filter_map(|s| { + let reply_to = s + .meta + .as_ref()? + .extra + .get("github")? + .get("in_reply_to_id")? + .as_u64()?; + let parent_step = reply_target.get(&reply_to)?; + Some((s.step.id.clone(), parent_step.clone())) + }) + .collect(); + + // Chain trunk steps (non-reply steps) linearly, branch replies let mut prev_id: Option = None; for step in &mut steps { - if let Some(ref prev) = prev_id { + if let Some(parent) = reply_parents.get(&step.step.id) { + // This step is a reply — parent off the step it replies to + step.step.parents = vec![parent.clone()]; + } else if let Some(ref prev) = prev_id { step.step.parents = vec![prev.clone()]; } else { step.step.parents = vec![]; } - prev_id = Some(step.step.id.clone()); + // Only advance trunk pointer for non-reply steps + if !reply_parents.contains_key(&step.step.id) { + prev_id = Some(step.step.id.clone()); + } } // ── Build path head ────────────────────────────────────────── - let head = steps - .last() - .map(|s| s.step.id.clone()) - .unwrap_or_else(|| format!("pr-{}", pr_number)); + // Head is the last trunk step (not a reply branch) + let head = prev_id.unwrap_or_else(|| format!("pr-{}", pr_number)); // ── Build path metadata ────────────────────────────────────── - let meta = build_path_meta(pr, &actors)?; + let meta = build_path_meta(pr, &actors, &actor_associations)?; Ok(Path { path: PathIdentity { @@ -513,6 +543,7 @@ mod native { fn commit_to_step( detail: &serde_json::Value, actors: &mut HashMap, + actor_associations: &mut HashMap, ) -> Result { let sha = detail["sha"].as_str().unwrap_or_default(); let short_sha = &sha[..sha.len().min(8)]; @@ -521,7 +552,8 @@ mod native { // Actor let login = detail["author"]["login"].as_str().unwrap_or("unknown"); let actor = format!("human:{}", login); - register_actor(actors, &actor, login, None); + let association = detail["author_association"].as_str(); + register_actor(actors, actor_associations, &actor, login, association); // Timestamp let timestamp = detail["commit"]["committer"]["date"] @@ -574,13 +606,15 @@ mod native { fn review_comment_to_step( rc: &serde_json::Value, actors: &mut HashMap, + actor_associations: &mut HashMap, ) -> Result { let id = rc["id"].as_u64().unwrap_or(0); let step_id = format!("step-rc-{}", id); let login = rc["user"]["login"].as_str().unwrap_or("unknown"); let actor = format!("human:{}", login); - register_actor(actors, &actor, login, None); + let association = rc["author_association"].as_str(); + register_actor(actors, actor_associations, &actor, login, association); let timestamp = rc["created_at"] .as_str() @@ -595,6 +629,7 @@ mod native { let artifact_uri = format!("review://{}#L{}", path, line); let body = rc["body"].as_str().unwrap_or("").to_string(); + let diff_hunk = rc["diff_hunk"].as_str().map(|s| s.to_string()); let mut extra = HashMap::new(); extra.insert("body".to_string(), serde_json::Value::String(body)); @@ -602,7 +637,7 @@ mod native { let change = HashMap::from([( artifact_uri, ArtifactChange { - raw: None, + raw: diff_hunk, structural: Some(StructuralChange { change_type: "review.comment".to_string(), extra, @@ -610,6 +645,20 @@ mod native { }, )]); + // Capture in_reply_to_id for threading + let meta = if let Some(reply_to) = rc["in_reply_to_id"].as_u64() { + let mut step_extra = HashMap::new(); + let mut gh_extra = serde_json::Map::new(); + gh_extra.insert("in_reply_to_id".to_string(), serde_json::json!(reply_to)); + step_extra.insert("github".to_string(), serde_json::Value::Object(gh_extra)); + Some(StepMeta { + extra: step_extra, + ..Default::default() + }) + } else { + None + }; + Ok(Step { step: StepIdentity { id: step_id, @@ -618,13 +667,14 @@ mod native { timestamp, }, change, - meta: None, + meta, }) } fn pr_comment_to_step( pc: &serde_json::Value, actors: &mut HashMap, + actor_associations: &mut HashMap, ) -> Result { let id = pc["id"].as_u64().unwrap_or(0); let step_id = format!("step-ic-{}", id); @@ -636,15 +686,22 @@ mod native { let login = pc["user"]["login"].as_str().unwrap_or("unknown"); let actor = format!("human:{}", login); - register_actor(actors, &actor, login, None); + let association = pc["author_association"].as_str(); + register_actor(actors, actor_associations, &actor, login, association); let body = pc["body"].as_str().unwrap_or("").to_string(); + let mut extra = HashMap::new(); + extra.insert("body".to_string(), serde_json::Value::String(body)); + let change = HashMap::from([( "review://conversation".to_string(), ArtifactChange { - raw: Some(body), - structural: None, + raw: None, + structural: Some(StructuralChange { + change_type: "review.conversation".to_string(), + extra, + }), }, )]); @@ -663,6 +720,7 @@ mod native { fn review_to_step( review: &serde_json::Value, actors: &mut HashMap, + actor_associations: &mut HashMap, ) -> Result { let id = review["id"].as_u64().unwrap_or(0); let step_id = format!("step-rv-{}", id); @@ -674,7 +732,8 @@ mod native { let login = review["user"]["login"].as_str().unwrap_or("unknown"); let actor = format!("human:{}", login); - register_actor(actors, &actor, login, None); + let association = review["author_association"].as_str(); + register_actor(actors, actor_associations, &actor, login, association); let state = review["state"].as_str().unwrap_or("COMMENTED").to_string(); let body = review["body"].as_str().unwrap_or("").to_string(); @@ -685,7 +744,11 @@ mod native { let change = HashMap::from([( "review://decision".to_string(), ArtifactChange { - raw: if body.is_empty() { None } else { Some(body) }, + raw: if body.is_empty() { + None + } else { + Some(body.clone()) + }, structural: Some(StructuralChange { change_type: "review.decision".to_string(), extra, @@ -693,6 +756,21 @@ mod native { }, )]); + // Set intent from review body so the md renderer picks it up + let meta = if !body.is_empty() { + let intent = if body.len() > 500 { + format!("{}...", &body[..500]) + } else { + body + }; + Some(StepMeta { + intent: Some(intent), + ..Default::default() + }) + } else { + None + }; + Ok(Step { step: StepIdentity { id: step_id, @@ -701,7 +779,7 @@ mod native { timestamp, }, change, - meta: None, + meta, }) } @@ -736,6 +814,12 @@ mod native { "conclusion".to_string(), serde_json::Value::String(conclusion), ); + if let Some(html_url) = run["html_url"].as_str() { + extra.insert( + "url".to_string(), + serde_json::Value::String(html_url.to_string()), + ); + } let artifact_uri = format!("ci://checks/{}", name); let change = HashMap::from([( @@ -764,6 +848,7 @@ mod native { fn build_path_meta( pr: &serde_json::Value, actors: &HashMap, + actor_associations: &HashMap, ) -> Result { let title = pr["title"].as_str().map(|s| s.to_string()); let body = pr["body"].as_str().unwrap_or(""); @@ -789,8 +874,62 @@ mod native { }) .collect(); - // Labels in extra + // GitHub-specific metadata in extra["github"] let mut extra: HashMap = HashMap::new(); + let mut github_meta = serde_json::Map::new(); + + // PR identity and state + if let Some(number) = pr["number"].as_u64() { + github_meta.insert("number".to_string(), serde_json::json!(number)); + } + if let Some(author) = pr["user"]["login"].as_str() { + github_meta.insert( + "author".to_string(), + serde_json::Value::String(author.to_string()), + ); + } + if let Some(state) = pr["state"].as_str() { + github_meta.insert( + "state".to_string(), + serde_json::Value::String(state.to_string()), + ); + } + if let Some(draft) = pr["draft"].as_bool() { + github_meta.insert("draft".to_string(), serde_json::json!(draft)); + } + + // Merge status + if let Some(merged) = pr["merged"].as_bool() { + github_meta.insert("merged".to_string(), serde_json::json!(merged)); + } + if let Some(merged_at) = pr["merged_at"].as_str() { + github_meta.insert( + "merged_at".to_string(), + serde_json::Value::String(merged_at.to_string()), + ); + } + if let Some(merged_by) = pr["merged_by"]["login"].as_str() { + github_meta.insert( + "merged_by".to_string(), + serde_json::Value::String(merged_by.to_string()), + ); + } + + // Diffstat + if let Some(additions) = pr["additions"].as_u64() { + github_meta.insert("additions".to_string(), serde_json::json!(additions)); + } + if let Some(deletions) = pr["deletions"].as_u64() { + github_meta.insert("deletions".to_string(), serde_json::json!(deletions)); + } + if let Some(changed_files) = pr["changed_files"].as_u64() { + github_meta.insert( + "changed_files".to_string(), + serde_json::json!(changed_files), + ); + } + + // Labels if let Some(labels) = pr["labels"].as_array() { let label_names: Vec = labels .iter() @@ -798,12 +937,26 @@ mod native { .map(|s| serde_json::Value::String(s.to_string())) .collect(); if !label_names.is_empty() { - let mut github_meta = serde_json::Map::new(); github_meta.insert("labels".to_string(), serde_json::Value::Array(label_names)); - extra.insert("github".to_string(), serde_json::Value::Object(github_meta)); } } + // Actor associations (MEMBER, COLLABORATOR, CONTRIBUTOR, etc.) + if !actor_associations.is_empty() { + let assoc_map: serde_json::Map = actor_associations + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(); + github_meta.insert( + "actor_associations".to_string(), + serde_json::Value::Object(assoc_map), + ); + } + + if !github_meta.is_empty() { + extra.insert("github".to_string(), serde_json::Value::Object(github_meta)); + } + Ok(PathMeta { title, intent, @@ -824,9 +977,10 @@ mod native { fn register_actor( actors: &mut HashMap, + actor_associations: &mut HashMap, actor_key: &str, login: &str, - _email: Option<&str>, + association: Option<&str>, ) { actors .entry(actor_key.to_string()) @@ -838,6 +992,13 @@ mod native { }], ..Default::default() }); + if let Some(assoc) = association + && assoc != "NONE" + { + actor_associations + .entry(actor_key.to_string()) + .or_insert_with(|| assoc.to_string()); + } } fn str_field(val: &serde_json::Value, key: &str) -> String { @@ -858,6 +1019,13 @@ mod native { "title": "Add feature X", "body": "This PR adds feature X.\n\nFixes #10\nCloses #20", "state": "open", + "draft": false, + "merged": false, + "merged_at": null, + "merged_by": null, + "additions": 150, + "deletions": 30, + "changed_files": 5, "user": { "login": "alice" }, "head": { "ref": "feature-x" }, "base": { @@ -917,6 +1085,8 @@ mod native { "path": path, "line": line, "body": "Consider using a constant here.", + "diff_hunk": "@@ -10,6 +10,7 @@\n fn example() {\n+ let x = 42;\n }", + "author_association": "COLLABORATOR", "created_at": "2026-01-15T14:00:00Z", "pull_request_review_id": 100, "in_reply_to_id": null @@ -928,6 +1098,7 @@ mod native { "id": id, "user": { "login": "carol" }, "body": "Looks good overall!", + "author_association": "CONTRIBUTOR", "created_at": "2026-01-15T16:00:00Z" }) } @@ -938,6 +1109,7 @@ mod native { "user": { "login": "dave" }, "state": state, "body": "Approved with minor comments.", + "author_association": "MEMBER", "submitted_at": "2026-01-15T17:00:00Z" }) } @@ -948,6 +1120,7 @@ mod native { "name": name, "app": { "slug": "github-actions" }, "conclusion": conclusion, + "html_url": format!("https://github.com/acme/widgets/actions/runs/{}", id), "completed_at": "2026-01-15T13:00:00Z", "started_at": "2026-01-15T12:30:00Z" }) @@ -957,8 +1130,9 @@ mod native { fn test_commit_to_step() { let detail = sample_commit_detail("abc12345deadbeef", None, "Initial commit"); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = commit_to_step(&detail, &mut actors).unwrap(); + let step = commit_to_step(&detail, &mut actors, &mut assoc).unwrap(); assert_eq!(step.step.id, "step-abc12345"); assert_eq!(step.step.actor, "human:alice"); @@ -975,8 +1149,9 @@ mod native { fn test_review_comment_to_step() { let rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = review_comment_to_step(&rc, &mut actors).unwrap(); + let step = review_comment_to_step(&rc, &mut actors, &mut assoc).unwrap(); assert_eq!(step.step.id, "step-rc-200"); assert_eq!(step.step.actor, "human:bob"); @@ -984,29 +1159,43 @@ mod native { assert!(step.step.parents.is_empty()); assert!(step.change.contains_key("review://src/main.rs#L42")); assert!(actors.contains_key("human:bob")); + // diff_hunk captured as raw + let change = &step.change["review://src/main.rs#L42"]; + assert!(change.raw.is_some()); + assert!(change.raw.as_deref().unwrap().contains("let x = 42")); + // author_association captured + assert_eq!( + assoc.get("human:bob").map(|s| s.as_str()), + Some("COLLABORATOR") + ); } #[test] fn test_pr_comment_to_step() { let pc = sample_pr_comment(300); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = pr_comment_to_step(&pc, &mut actors).unwrap(); + let step = pr_comment_to_step(&pc, &mut actors, &mut assoc).unwrap(); assert_eq!(step.step.id, "step-ic-300"); assert_eq!(step.step.actor, "human:carol"); assert!(step.step.parents.is_empty()); assert!(step.change.contains_key("review://conversation")); let change = &step.change["review://conversation"]; - assert_eq!(change.raw.as_deref(), Some("Looks good overall!")); + assert!(change.structural.is_some()); + let structural = change.structural.as_ref().unwrap(); + assert_eq!(structural.change_type, "review.conversation"); + assert_eq!(structural.extra["body"], "Looks good overall!"); } #[test] fn test_review_to_step() { let review = sample_review(400, "APPROVED"); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = review_to_step(&review, &mut actors).unwrap(); + let step = review_to_step(&review, &mut actors, &mut assoc).unwrap(); assert_eq!(step.step.id, "step-rv-400"); assert_eq!(step.step.actor, "human:dave"); @@ -1017,6 +1206,11 @@ mod native { let structural = change.structural.as_ref().unwrap(); assert_eq!(structural.change_type, "review.decision"); assert_eq!(structural.extra["state"], "APPROVED"); + // review body captured as meta.intent + assert_eq!( + step.meta.as_ref().unwrap().intent.as_deref(), + Some("Approved with minor comments.") + ); } #[test] @@ -1034,15 +1228,29 @@ mod native { let structural = change.structural.as_ref().unwrap(); assert_eq!(structural.change_type, "ci.run"); assert_eq!(structural.extra["conclusion"], "success"); + // html_url captured + assert!( + structural.extra["url"] + .as_str() + .unwrap() + .contains("actions/runs/500") + ); } #[test] fn test_build_path_meta() { let pr = sample_pr(); let mut actors = HashMap::new(); - register_actor(&mut actors, "human:alice", "alice", None); + let mut assoc = HashMap::new(); + register_actor( + &mut actors, + &mut assoc, + "human:alice", + "alice", + Some("MEMBER"), + ); - let meta = build_path_meta(&pr, &actors).unwrap(); + let meta = build_path_meta(&pr, &actors, &assoc).unwrap(); assert_eq!(meta.title.as_deref(), Some("Add feature X")); assert!(meta.intent.as_deref().unwrap().contains("feature X")); @@ -1052,10 +1260,20 @@ mod native { assert!(meta.refs[1].href.contains("/issues/20")); assert!(meta.actors.is_some()); - // Labels in extra + // GitHub extra metadata let github = meta.extra.get("github").unwrap(); let labels = github["labels"].as_array().unwrap(); assert_eq!(labels.len(), 2); + assert_eq!(github["state"], "open"); + assert_eq!(github["additions"], 150); + assert_eq!(github["deletions"], 30); + assert_eq!(github["changed_files"], 5); + assert_eq!(github["number"], 42); + assert_eq!(github["author"], "alice"); + assert_eq!(github["draft"], false); + assert_eq!(github["merged"], false); + // Actor associations + assert_eq!(github["actor_associations"]["human:alice"], "MEMBER"); } #[test] @@ -1213,8 +1431,9 @@ mod native { #[test] fn test_register_actor_idempotent() { let mut actors = HashMap::new(); - register_actor(&mut actors, "human:alice", "alice", None); - register_actor(&mut actors, "human:alice", "alice", None); + let mut assoc = HashMap::new(); + register_actor(&mut actors, &mut assoc, "human:alice", "alice", None); + register_actor(&mut actors, &mut assoc, "human:alice", "alice", None); assert_eq!(actors.len(), 1); } @@ -1267,8 +1486,9 @@ mod native { fn test_review_comment_artifact_uri_format() { let rc = sample_review_comment(700, "abc12345", "src/lib.rs", 100); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = review_comment_to_step(&rc, &mut actors).unwrap(); + let step = review_comment_to_step(&rc, &mut actors, &mut assoc).unwrap(); assert!(step.change.contains_key("review://src/lib.rs#L100")); } @@ -1303,11 +1523,14 @@ mod native { let mut review = sample_review(800, "APPROVED"); review["body"] = serde_json::json!(""); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = review_to_step(&review, &mut actors).unwrap(); + let step = review_to_step(&review, &mut actors, &mut assoc).unwrap(); let change = &step.change["review://decision"]; assert!(change.raw.is_none()); assert!(change.structural.is_some()); + // No meta.intent when body is empty + assert!(step.meta.is_none()); } #[test] @@ -1323,8 +1546,9 @@ mod native { "files": [] }); let mut actors = HashMap::new(); + let mut assoc = HashMap::new(); - let step = commit_to_step(&detail, &mut actors).unwrap(); + let step = commit_to_step(&detail, &mut actors, &mut assoc).unwrap(); assert!(step.change.is_empty()); } @@ -1373,6 +1597,82 @@ mod native { assert_eq!(path.steps[2].step.parents, vec!["step-22222222"]); assert_eq!(path.path.head, "step-33333333"); } + + #[test] + fn test_reply_threading() { + let pr = sample_pr(); + let commit = { + let mut c = sample_commit_detail("abc12345deadbeef", None, "Commit"); + c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T10:00:00Z"); + c + }; + + // Original review comment (id=200) + let rc1 = { + let mut rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42); + rc["created_at"] = serde_json::json!("2026-01-15T14:00:00Z"); + rc + }; + // Reply to comment 200 (id=201) + let rc2 = serde_json::json!({ + "id": 201, + "user": { "login": "alice" }, + "commit_id": "abc12345deadbeef", + "path": "src/main.rs", + "line": 42, + "body": "Good point, I'll fix that.", + "diff_hunk": "@@ -10,6 +10,7 @@\n fn example() {\n+ let x = 42;\n }", + "author_association": "CONTRIBUTOR", + "created_at": "2026-01-15T15:00:00Z", + "pull_request_review_id": 100, + "in_reply_to_id": 200 + }); + + let config = DeriveConfig { + token: "test".to_string(), + api_url: "https://api.github.com".to_string(), + include_ci: false, + include_comments: true, + }; + + let data = PrData { + pr: &pr, + commit_details: &[commit], + reviews: &[], + pr_comments: &[], + review_comments: &[rc1, rc2], + check_runs_by_sha: &HashMap::new(), + }; + let path = derive_from_data(&data, "acme", "widgets", &config).unwrap(); + + assert_eq!(path.steps.len(), 3); + + // Find steps by id + let commit_step = path + .steps + .iter() + .find(|s| s.step.id == "step-abc12345") + .unwrap(); + let rc1_step = path + .steps + .iter() + .find(|s| s.step.id == "step-rc-200") + .unwrap(); + let rc2_step = path + .steps + .iter() + .find(|s| s.step.id == "step-rc-201") + .unwrap(); + + // Commit is root + assert!(commit_step.step.parents.is_empty()); + // Original comment is trunk-chained after commit + assert_eq!(rc1_step.step.parents, vec!["step-abc12345"]); + // Reply branches off the original comment, NOT trunk-chained + assert_eq!(rc2_step.step.parents, vec!["step-rc-200"]); + // Head is the last trunk step (the original comment, not the reply) + assert_eq!(path.path.head, "step-rc-200"); + } } } diff --git a/crates/toolpath-md/Cargo.toml b/crates/toolpath-md/Cargo.toml new file mode 100644 index 0000000..e7457e1 --- /dev/null +++ b/crates/toolpath-md/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "toolpath-md" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository = "https://github.com/empathic/toolpath" +description = "Render Toolpath documents as Markdown for LLM consumption" +keywords = ["markdown", "provenance", "toolpath", "llm", "rendering"] +categories = ["development-tools", "text-processing"] + +[dependencies] +serde_json = { workspace = true } +toolpath = { workspace = true } + +[dev-dependencies] diff --git a/crates/toolpath-md/README.md b/crates/toolpath-md/README.md new file mode 100644 index 0000000..c1bc6ea --- /dev/null +++ b/crates/toolpath-md/README.md @@ -0,0 +1,112 @@ +# toolpath-md + +Render Toolpath documents as Markdown for LLM consumption. + +Provenance data is only useful if you can feed it to the systems that need it. +This crate renders the step DAG as readable Markdown — a narrative an LLM can +reason about. Dead ends are called out explicitly ("here's what was tried and +abandoned"), giving models anti-examples alongside the successful path. + +## Overview + +Renders any Toolpath `Document` (Step, Path, or Graph) as a Markdown string. +Steps are topologically sorted, dead ends are marked and summarized, and the +output includes enough anchoring information (step IDs, artifact paths, actor +strings) for an LLM to reference back into the original document. + +Depends only on `toolpath` — no template engines, no external dependencies. + +## Usage + +```rust +use toolpath::v1::Document; +use toolpath_md::{render, RenderOptions}; + +let json_str = r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"T"},"change":{}}}"#; +let doc = Document::from_json(json_str).unwrap(); +let md = render(&doc, &RenderOptions::default()); +assert!(md.contains("# s1")); +``` + +Pipe into an LLM for contextual assistance: + +```bash +path derive git --repo . --branch main | path render md | pbcopy +# Paste into Claude/ChatGPT: "here's what I've tried, help me with the next step" +``` + +## Render options + +```rust +use toolpath_md::{RenderOptions, Detail}; + +let options = RenderOptions { + detail: Detail::Summary, // or Detail::Full for inline diffs + front_matter: false, // emit YAML front matter with metadata +}; +``` + +### Detail levels + +- **`Summary`** (default) — file paths with diffstat (`+3 -1`). Compact, fits in tight context windows. +- **`Full`** — inline diffs as fenced code blocks. Use when the LLM needs to reason about specific line changes. + +### Front matter + +When `front_matter: true`, the output starts with YAML front matter containing +machine-readable metadata (document type, step count, actor list, artifact list, +dead end count). Useful for LLM workflows that parse structured preambles. + +## API + +| Function | Description | +|---|---| +| `render(doc, options)` | Render any `Document` variant | +| `render_step(step, options)` | Render a single Step | +| `render_path(path, options)` | Render a Path with its step DAG | +| `render_graph(graph, options)` | Render a Graph with all paths | + +## Output structure + +### Step + +```markdown +# step-001 +**Actor:** `human:alex` +**Timestamp:** 2026-01-29T10:00:00Z +> Fix greeting +- `src/main.rs` (+1 -1) +``` + +### Path + +```markdown +# Add email validation +**Base:** `github:org/repo` @ `main` +**Head:** `step-004` +**Steps:** 5 | **Artifacts:** 2 | **Dead ends:** 1 + +## Timeline +### step-001 — human:alex +### step-002a — agent:claude ❌ dead end +### step-002 — agent:claude +... + +## Dead Ends +- **step-002a** — Regex approach (abandoned) | Parent: `step-001` +``` + +### Graph + +Renders a summary table of all paths, then each path as a subsection. + +## Part of Toolpath + +This crate is part of the [Toolpath](https://github.com/empathic/toolpath) workspace. See also: + +- [`toolpath`](https://crates.io/crates/toolpath) — core types and query API +- [`toolpath-dot`](https://crates.io/crates/toolpath-dot) — Graphviz DOT visualization +- [`toolpath-git`](https://crates.io/crates/toolpath-git) — derive from git history +- [`toolpath-claude`](https://crates.io/crates/toolpath-claude) — derive from Claude conversations +- [`toolpath-cli`](https://crates.io/crates/toolpath-cli) — unified CLI (`cargo install toolpath-cli`) +- [RFC](https://github.com/empathic/toolpath/blob/main/RFC.md) — full format specification diff --git a/crates/toolpath-md/src/lib.rs b/crates/toolpath-md/src/lib.rs new file mode 100644 index 0000000..eac23d0 --- /dev/null +++ b/crates/toolpath-md/src/lib.rs @@ -0,0 +1,2203 @@ +#![doc = include_str!("../README.md")] + +mod source; + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; + +use toolpath::v1::{ArtifactChange, Document, Graph, Path, PathOrRef, Step, query}; + +/// Detail level for the rendered markdown. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Detail { + /// File-level change summaries, no diffs. + #[default] + Summary, + /// Full inline diffs as fenced code blocks. + Full, +} + +/// Options controlling the markdown output. +pub struct RenderOptions { + /// How much detail to include for each step's changes. + pub detail: Detail, + /// Emit YAML front matter with machine-readable metadata. + pub front_matter: bool, +} + +impl Default for RenderOptions { + fn default() -> Self { + Self { + detail: Detail::Summary, + front_matter: false, + } + } +} + +/// Render any Toolpath [`Document`] variant to a Markdown string. +/// +/// # Examples +/// +/// ``` +/// use toolpath::v1::{Document, Step}; +/// use toolpath_md::{render, RenderOptions}; +/// +/// let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z") +/// .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") +/// .with_intent("Fix greeting"); +/// let doc = Document::Step(step); +/// let md = render(&doc, &RenderOptions::default()); +/// assert!(md.contains("# s1")); +/// assert!(md.contains("human:alex")); +/// ``` +pub fn render(doc: &Document, options: &RenderOptions) -> String { + match doc { + Document::Graph(g) => render_graph(g, options), + Document::Path(p) => render_path(p, options), + Document::Step(s) => render_step(s, options), + } +} + +/// Render a single [`Step`] as Markdown. +pub fn render_step(step: &Step, options: &RenderOptions) -> String { + let mut out = String::new(); + + if options.front_matter { + write_step_front_matter(&mut out, step); + } + + writeln!(out, "# {}", step.step.id).unwrap(); + writeln!(out).unwrap(); + write_step_body(&mut out, step, options, false); + + out +} + +/// Render a [`Path`] as Markdown. +pub fn render_path(path: &Path, options: &RenderOptions) -> String { + let mut out = String::new(); + + if options.front_matter { + write_path_front_matter(&mut out, path); + } + + // Title + let title = path + .meta + .as_ref() + .and_then(|m| m.title.as_deref()) + .unwrap_or(&path.path.id); + writeln!(out, "# {title}").unwrap(); + writeln!(out).unwrap(); + + // Context block + write_path_context(&mut out, path); + + // Topological sort for readable ordering + let sorted = topo_sort(&path.steps); + let active = query::ancestors(&path.steps, &path.path.head); + let dead_end_set: HashSet<&str> = path + .steps + .iter() + .filter(|s| !active.contains(&s.step.id)) + .map(|s| s.step.id.as_str()) + .collect(); + + // Timeline + writeln!(out, "## Timeline").unwrap(); + writeln!(out).unwrap(); + + for step in &sorted { + let is_dead = dead_end_set.contains(step.step.id.as_str()); + let is_head = step.step.id == path.path.head; + write_path_step(&mut out, step, options, is_dead, is_head); + } + + // Dead ends section (if any) + if !dead_end_set.is_empty() { + write_dead_ends_section(&mut out, &sorted, &dead_end_set); + } + + // Review summary section + write_review_section(&mut out, &sorted); + + // Actors section (if defined in meta) + if let Some(meta) = &path.meta + && let Some(actors) = &meta.actors + { + write_actors_section(&mut out, actors); + } + + out +} + +/// Render a [`Graph`] as Markdown. +pub fn render_graph(graph: &Graph, options: &RenderOptions) -> String { + let mut out = String::new(); + + if options.front_matter { + write_graph_front_matter(&mut out, graph); + } + + // Title + let title = graph + .meta + .as_ref() + .and_then(|m| m.title.as_deref()) + .unwrap_or(&graph.graph.id); + writeln!(out, "# {title}").unwrap(); + writeln!(out).unwrap(); + + // Intent + if let Some(meta) = &graph.meta + && let Some(intent) = &meta.intent + { + writeln!(out, "> {intent}").unwrap(); + writeln!(out).unwrap(); + } + + // Refs + if let Some(meta) = &graph.meta + && !meta.refs.is_empty() + { + for r in &meta.refs { + writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap(); + } + writeln!(out).unwrap(); + } + + // Summary table + let inline_paths: Vec<&Path> = graph + .paths + .iter() + .filter_map(|por| match por { + PathOrRef::Path(p) => Some(p.as_ref()), + PathOrRef::Ref(_) => None, + }) + .collect(); + + let ref_urls: Vec<&str> = graph + .paths + .iter() + .filter_map(|por| match por { + PathOrRef::Ref(r) => Some(r.ref_url.as_str()), + PathOrRef::Path(_) => None, + }) + .collect(); + + if !inline_paths.is_empty() { + writeln!(out, "| Path | Steps | Actors | Head |").unwrap(); + writeln!(out, "|------|-------|--------|------|").unwrap(); + for path in &inline_paths { + let path_title = path + .meta + .as_ref() + .and_then(|m| m.title.as_deref()) + .unwrap_or(&path.path.id); + let step_count = path.steps.len(); + let actors = query::all_actors(&path.steps); + let actors_str = format_actor_list(&actors); + writeln!( + out, + "| {path_title} | {step_count} | {actors_str} | `{}` |", + path.path.head + ) + .unwrap(); + } + writeln!(out).unwrap(); + } + + if !ref_urls.is_empty() { + writeln!(out, "**External references:**").unwrap(); + for url in &ref_urls { + writeln!(out, "- `{url}`").unwrap(); + } + writeln!(out).unwrap(); + } + + // Each path as a section + for path in &inline_paths { + let path_title = path + .meta + .as_ref() + .and_then(|m| m.title.as_deref()) + .unwrap_or(&path.path.id); + writeln!(out, "---").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "## {path_title}").unwrap(); + writeln!(out).unwrap(); + + write_path_context(&mut out, path); + + let sorted = topo_sort(&path.steps); + let active = query::ancestors(&path.steps, &path.path.head); + let dead_end_set: HashSet<&str> = path + .steps + .iter() + .filter(|s| !active.contains(&s.step.id)) + .map(|s| s.step.id.as_str()) + .collect(); + + for step in &sorted { + let is_dead = dead_end_set.contains(step.step.id.as_str()); + let is_head = step.step.id == path.path.head; + write_path_step(&mut out, step, options, is_dead, is_head); + } + + if !dead_end_set.is_empty() { + write_dead_ends_section(&mut out, &sorted, &dead_end_set); + } + } + + // Actors section (if defined in meta) + if let Some(meta) = &graph.meta + && let Some(actors) = &meta.actors + { + writeln!(out, "---").unwrap(); + writeln!(out).unwrap(); + write_actors_section(&mut out, actors); + } + + out +} + +// ============================================================================ +// Internal rendering helpers +// ============================================================================ + +fn write_step_body(out: &mut String, step: &Step, options: &RenderOptions, compact: bool) { + let heading = if compact { "###" } else { "##" }; + + // Actor + timestamp line + writeln!(out, "**Actor:** `{}`", step.step.actor).unwrap(); + writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap(); + + // Parents + if !step.step.parents.is_empty() { + let parents: Vec = step.step.parents.iter().map(|p| format!("`{p}`")).collect(); + writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap(); + } + + writeln!(out).unwrap(); + + // Intent + if let Some(meta) = &step.meta + && let Some(intent) = &meta.intent + { + writeln!(out, "> {intent}").unwrap(); + writeln!(out).unwrap(); + } + + // Refs + if let Some(meta) = &step.meta + && !meta.refs.is_empty() + { + for r in &meta.refs { + writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap(); + } + writeln!(out).unwrap(); + } + + // Changes + if !step.change.is_empty() { + writeln!(out, "{heading} Changes").unwrap(); + writeln!(out).unwrap(); + + let mut artifacts: Vec<&String> = step.change.keys().collect(); + artifacts.sort(); + + for artifact in artifacts { + let change = &step.change[artifact]; + write_artifact_change(out, artifact, change, options); + } + } +} + +fn write_artifact_change( + out: &mut String, + artifact: &str, + change: &ArtifactChange, + options: &RenderOptions, +) { + let change_type = change + .structural + .as_ref() + .map(|s| s.change_type.as_str()) + .unwrap_or(""); + + match options.detail { + Detail::Summary => match change_type { + "review.comment" | "review.conversation" => { + let display = friendly_artifact_name(artifact); + let body = change + .structural + .as_ref() + .and_then(|s| s.extra.get("body")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let truncated = truncate_str(body, 120); + if truncated.is_empty() { + writeln!(out, "- `{display}` (comment)").unwrap(); + } else { + writeln!(out, "- `{display}` \u{2014} \"{truncated}\"").unwrap(); + } + } + "review.decision" => { + let state = change + .structural + .as_ref() + .and_then(|s| s.extra.get("state")) + .and_then(|v| v.as_str()) + .unwrap_or("COMMENTED"); + let marker = review_state_marker(state); + let body = change.raw.as_deref().unwrap_or(""); + let truncated = truncate_str(body, 120); + if truncated.is_empty() { + writeln!(out, "- {marker} {state}").unwrap(); + } else { + writeln!(out, "- {marker} {state} \u{2014} \"{truncated}\"").unwrap(); + } + } + "ci.run" => { + let name = friendly_artifact_name(artifact); + let conclusion = change + .structural + .as_ref() + .and_then(|s| s.extra.get("conclusion")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let marker = ci_conclusion_marker(conclusion); + writeln!(out, "- {name} {marker} {conclusion}").unwrap(); + } + _ => { + let display = friendly_artifact_name(artifact); + let annotation = change_annotation(change); + writeln!(out, "- `{display}`{annotation}").unwrap(); + } + }, + Detail::Full => { + match change_type { + "review.comment" | "review.conversation" => { + let display = friendly_artifact_name(artifact); + writeln!(out, "**`{display}`**").unwrap(); + let body = change + .structural + .as_ref() + .and_then(|s| s.extra.get("body")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !body.is_empty() { + writeln!(out).unwrap(); + for line in body.lines() { + writeln!(out, "> {line}").unwrap(); + } + } + // Show diff_hunk if present + if let Some(raw) = &change.raw { + writeln!(out).unwrap(); + writeln!(out, "```diff").unwrap(); + writeln!(out, "{raw}").unwrap(); + writeln!(out, "```").unwrap(); + } + writeln!(out).unwrap(); + } + "review.decision" => { + let state = change + .structural + .as_ref() + .and_then(|s| s.extra.get("state")) + .and_then(|v| v.as_str()) + .unwrap_or("COMMENTED"); + let marker = review_state_marker(state); + writeln!(out, "**{marker} {state}**").unwrap(); + if let Some(raw) = &change.raw { + writeln!(out).unwrap(); + for line in raw.lines() { + writeln!(out, "> {line}").unwrap(); + } + } + writeln!(out).unwrap(); + } + "ci.run" => { + let name = friendly_artifact_name(artifact); + let conclusion = change + .structural + .as_ref() + .and_then(|s| s.extra.get("conclusion")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let marker = ci_conclusion_marker(conclusion); + write!(out, "**{name}** {marker} {conclusion}").unwrap(); + if let Some(url) = change + .structural + .as_ref() + .and_then(|s| s.extra.get("url")) + .and_then(|v| v.as_str()) + { + write!(out, " ([details]({url}))").unwrap(); + } + writeln!(out).unwrap(); + writeln!(out).unwrap(); + } + _ => { + let display = friendly_artifact_name(artifact); + writeln!(out, "**`{display}`**").unwrap(); + if let Some(raw) = &change.raw { + writeln!(out).unwrap(); + writeln!(out, "```diff").unwrap(); + writeln!(out, "{raw}").unwrap(); + writeln!(out, "```").unwrap(); + } + if let Some(structural) = &change.structural { + writeln!(out).unwrap(); + let extra_str = if structural.extra.is_empty() { + String::new() + } else { + let pairs: Vec = structural + .extra + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + format!(" ({})", pairs.join(", ")) + }; + writeln!(out, "Structural: `{}`{extra_str}", structural.change_type) + .unwrap(); + } + writeln!(out).unwrap(); + } + } + } + } +} + +fn change_annotation(change: &ArtifactChange) -> String { + let mut parts = Vec::new(); + + if let Some(raw) = &change.raw { + let (add, del) = count_diff_lines(raw); + if add > 0 || del > 0 { + parts.push(format!("+{add} -{del}")); + } + } + + if let Some(structural) = &change.structural { + parts.push(structural.change_type.clone()); + } + + if parts.is_empty() { + String::new() + } else { + format!(" ({})", parts.join(", ")) + } +} + +fn count_diff_lines(raw: &str) -> (usize, usize) { + let mut add = 0; + let mut del = 0; + for line in raw.lines() { + if line.starts_with('+') && !line.starts_with("+++") { + add += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + del += 1; + } + } + (add, del) +} + +fn write_path_step( + out: &mut String, + step: &Step, + options: &RenderOptions, + is_dead: bool, + is_head: bool, +) { + // Header line with status markers + let actor_short = actor_display(&step.step.actor); + let markers = match (is_dead, is_head) { + (true, _) => " [dead end]", + (_, true) => " [head]", + _ => "", + }; + + writeln!( + out, + "### {} \u{2014} {}{}", + step.step.id, actor_short, markers + ) + .unwrap(); + writeln!(out).unwrap(); + + writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap(); + + // Parents + if !step.step.parents.is_empty() { + let parents: Vec = step.step.parents.iter().map(|p| format!("`{p}`")).collect(); + writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap(); + } + + writeln!(out).unwrap(); + + // Intent + if let Some(meta) = &step.meta + && let Some(intent) = &meta.intent + { + writeln!(out, "> {intent}").unwrap(); + writeln!(out).unwrap(); + } + + // Refs + if let Some(meta) = &step.meta + && !meta.refs.is_empty() + { + for r in &meta.refs { + writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap(); + } + writeln!(out).unwrap(); + } + + // Changes + if !step.change.is_empty() { + let mut artifacts: Vec<&String> = step.change.keys().collect(); + artifacts.sort(); + + for artifact in artifacts { + let change = &step.change[artifact]; + write_artifact_change(out, artifact, change, options); + } + if options.detail == Detail::Summary { + writeln!(out).unwrap(); + } + } +} + +fn write_path_context(out: &mut String, path: &Path) { + let ctx = source::detect(path); + + if let Some(identity) = &ctx.identity_line { + writeln!(out, "{identity}").unwrap(); + } + + if let Some(base) = &path.path.base { + write!(out, "**Base:** `{}`", base.uri).unwrap(); + if let Some(ref_str) = &base.ref_str { + write!(out, " @ `{ref_str}`").unwrap(); + } + writeln!(out).unwrap(); + } + + // Only show Head if no source-specific identity line (it's noise for PRs) + if ctx.identity_line.is_none() { + writeln!(out, "**Head:** `{}`", path.path.head).unwrap(); + } + + if let Some(meta) = &path.meta { + if let Some(source) = &meta.source { + writeln!(out, "**Source:** `{source}`").unwrap(); + } + if let Some(intent) = &meta.intent { + writeln!(out, "**Intent:** {intent}").unwrap(); + } + if !meta.refs.is_empty() { + for r in &meta.refs { + writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap(); + } + } + } + + // Diffstat — prefer source metadata, fall back to counting diffs + let (total_add, total_del, file_count) = ctx + .diffstat + .unwrap_or_else(|| count_total_diff_lines(&path.steps)); + + if total_add > 0 || total_del > 0 { + write!(out, "**Changes:** +{total_add} \u{2212}{total_del}").unwrap(); + if let Some(f) = file_count { + write!(out, " across {f} files").unwrap(); + } + writeln!(out).unwrap(); + } + + // Summary stats + let artifacts = query::all_artifacts(&path.steps); + let dead_ends = query::dead_ends(&path.steps, &path.path.head); + writeln!( + out, + "**Steps:** {} | **Artifacts:** {} | **Dead ends:** {}", + path.steps.len(), + artifacts.len(), + dead_ends.len() + ) + .unwrap(); + + writeln!(out).unwrap(); +} + +fn write_dead_ends_section(out: &mut String, sorted: &[&Step], dead_end_set: &HashSet<&str>) { + writeln!(out, "## Dead Ends").unwrap(); + writeln!(out).unwrap(); + writeln!( + out, + "These steps were attempted but did not contribute to the final result." + ) + .unwrap(); + writeln!(out).unwrap(); + + for step in sorted { + if !dead_end_set.contains(step.step.id.as_str()) { + continue; + } + let intent = step + .meta + .as_ref() + .and_then(|m| m.intent.as_deref()) + .unwrap_or("(no intent recorded)"); + let parents: Vec = step.step.parents.iter().map(|p| format!("`{p}`")).collect(); + let parent_str = if parents.is_empty() { + "root".to_string() + } else { + parents.join(", ") + }; + writeln!( + out, + "- **{}** ({}) \u{2014} {} | Parent: {}", + step.step.id, step.step.actor, intent, parent_str + ) + .unwrap(); + } + writeln!(out).unwrap(); +} + +fn write_review_section(out: &mut String, sorted: &[&Step]) { + // Collect review decisions and comments + struct ReviewDecision<'a> { + state: &'a str, + actor: &'a str, + body: Option<&'a str>, + } + struct ReviewComment<'a> { + artifact: String, + actor: &'a str, + body: &'a str, + diff_hunk: Option<&'a str>, + } + struct ConversationComment<'a> { + actor: &'a str, + body: &'a str, + } + + let mut decisions: Vec> = Vec::new(); + let mut comments: Vec> = Vec::new(); + let mut conversations: Vec> = Vec::new(); + + for step in sorted { + for (artifact, change) in &step.change { + let change_type = change + .structural + .as_ref() + .map(|s| s.change_type.as_str()) + .unwrap_or(""); + match change_type { + "review.decision" => { + let state = change + .structural + .as_ref() + .and_then(|s| s.extra.get("state")) + .and_then(|v| v.as_str()) + .unwrap_or("COMMENTED"); + let body = change.raw.as_deref(); + decisions.push(ReviewDecision { + state, + actor: &step.step.actor, + body, + }); + } + "review.comment" => { + let body = change + .structural + .as_ref() + .and_then(|s| s.extra.get("body")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !body.is_empty() { + comments.push(ReviewComment { + artifact: friendly_artifact_name(artifact), + actor: &step.step.actor, + body, + diff_hunk: change.raw.as_deref(), + }); + } + } + "review.conversation" => { + let body = change + .structural + .as_ref() + .and_then(|s| s.extra.get("body")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !body.is_empty() { + conversations.push(ConversationComment { + actor: &step.step.actor, + body, + }); + } + } + _ => {} + } + } + } + + if decisions.is_empty() && comments.is_empty() && conversations.is_empty() { + return; + } + + writeln!(out, "## Review").unwrap(); + writeln!(out).unwrap(); + + for d in &decisions { + let marker = review_state_marker(d.state); + let actor_short = d.actor.split(':').next_back().unwrap_or(d.actor); + write!(out, "**{} {}** by {actor_short}", marker, d.state).unwrap(); + if let Some(body) = d.body + && !body.is_empty() + { + writeln!(out, ":").unwrap(); + for line in body.lines() { + writeln!(out, "> {line}").unwrap(); + } + } else { + writeln!(out).unwrap(); + } + writeln!(out).unwrap(); + } + + if !conversations.is_empty() { + writeln!(out, "### Discussion").unwrap(); + writeln!(out).unwrap(); + + for c in &conversations { + let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor); + writeln!(out, "**{actor_short}:**").unwrap(); + for line in c.body.lines() { + writeln!(out, "> {line}").unwrap(); + } + writeln!(out).unwrap(); + } + } + + if !comments.is_empty() { + writeln!(out, "### Inline comments").unwrap(); + writeln!(out).unwrap(); + + for c in &comments { + let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor); + writeln!(out, "**{}** \u{2014} {actor_short}:", c.artifact).unwrap(); + for line in c.body.lines() { + writeln!(out, "> {line}").unwrap(); + } + if let Some(hunk) = c.diff_hunk { + writeln!(out).unwrap(); + writeln!(out, "```diff").unwrap(); + writeln!(out, "{hunk}").unwrap(); + writeln!(out, "```").unwrap(); + } + writeln!(out).unwrap(); + } + } +} + +fn write_actors_section(out: &mut String, actors: &HashMap) { + writeln!(out, "## Actors").unwrap(); + writeln!(out).unwrap(); + + let mut keys: Vec<&String> = actors.keys().collect(); + keys.sort(); + + for key in keys { + let def = &actors[key]; + let name = def.name.as_deref().unwrap_or(key); + write!(out, "- **`{key}`** \u{2014} {name}").unwrap(); + if let Some(provider) = &def.provider { + write!(out, " ({provider}").unwrap(); + if let Some(model) = &def.model { + write!(out, ", {model}").unwrap(); + } + write!(out, ")").unwrap(); + } + writeln!(out).unwrap(); + } + writeln!(out).unwrap(); +} + +// ============================================================================ +// Front matter +// ============================================================================ + +fn write_step_front_matter(out: &mut String, step: &Step) { + writeln!(out, "---").unwrap(); + writeln!(out, "type: step").unwrap(); + writeln!(out, "id: {}", step.step.id).unwrap(); + writeln!(out, "actor: {}", step.step.actor).unwrap(); + writeln!(out, "timestamp: {}", step.step.timestamp).unwrap(); + if !step.step.parents.is_empty() { + let parents: Vec<&str> = step.step.parents.iter().map(|s| s.as_str()).collect(); + writeln!(out, "parents: [{}]", parents.join(", ")).unwrap(); + } + let mut artifacts: Vec<&str> = step.change.keys().map(|k| k.as_str()).collect(); + artifacts.sort(); + if !artifacts.is_empty() { + writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap(); + } + writeln!(out, "---").unwrap(); + writeln!(out).unwrap(); +} + +fn write_path_front_matter(out: &mut String, path: &Path) { + writeln!(out, "---").unwrap(); + writeln!(out, "type: path").unwrap(); + writeln!(out, "id: {}", path.path.id).unwrap(); + writeln!(out, "head: {}", path.path.head).unwrap(); + if let Some(base) = &path.path.base { + writeln!(out, "base: {}", base.uri).unwrap(); + if let Some(ref_str) = &base.ref_str { + writeln!(out, "base_ref: {ref_str}").unwrap(); + } + } + writeln!(out, "steps: {}", path.steps.len()).unwrap(); + let actors = query::all_actors(&path.steps); + let mut actor_list: Vec<&str> = actors.iter().copied().collect(); + actor_list.sort(); + writeln!(out, "actors: [{}]", actor_list.join(", ")).unwrap(); + let mut artifacts: Vec<&str> = query::all_artifacts(&path.steps).into_iter().collect(); + artifacts.sort(); + writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap(); + let dead_ends = query::dead_ends(&path.steps, &path.path.head); + writeln!(out, "dead_ends: {}", dead_ends.len()).unwrap(); + writeln!(out, "---").unwrap(); + writeln!(out).unwrap(); +} + +fn write_graph_front_matter(out: &mut String, graph: &Graph) { + writeln!(out, "---").unwrap(); + writeln!(out, "type: graph").unwrap(); + writeln!(out, "id: {}", graph.graph.id).unwrap(); + let inline_count = graph + .paths + .iter() + .filter(|p| matches!(p, PathOrRef::Path(_))) + .count(); + let ref_count = graph + .paths + .iter() + .filter(|p| matches!(p, PathOrRef::Ref(_))) + .count(); + writeln!(out, "paths: {inline_count}").unwrap(); + if ref_count > 0 { + writeln!(out, "external_refs: {ref_count}").unwrap(); + } + writeln!(out, "---").unwrap(); + writeln!(out).unwrap(); +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/// Format an actor string for display: `"agent:claude-code/session-abc"` -> `"agent:claude-code/session-abc"`. +/// +/// We keep the full actor string — it's the anchor that lets an LLM +/// reference back into the toolpath document. +fn actor_display(actor: &str) -> &str { + actor +} + +/// Convert artifact URIs to friendlier display names: +/// - `review://path/to/file.rs#L42` -> `path/to/file.rs:42` +/// - `ci://checks/test` -> `test` +/// - `review://conversation` -> `conversation` +/// - `review://decision` -> `decision` +fn friendly_artifact_name(artifact: &str) -> String { + if let Some(rest) = artifact.strip_prefix("review://") { + if let Some(pos) = rest.rfind("#L") { + format!("{}:{}", &rest[..pos], &rest[pos + 2..]) + } else { + rest.to_string() + } + } else if let Some(rest) = artifact.strip_prefix("ci://checks/") { + rest.to_string() + } else { + artifact.to_string() + } +} + +/// Truncate a string to a maximum number of characters, adding "..." if truncated. +fn truncate_str(s: &str, max: usize) -> String { + let s = s.lines().collect::>().join(" ").trim().to_string(); + if s.len() <= max { + s + } else { + format!("{}...", &s[..max]) + } +} + +/// Text marker for review states. +fn review_state_marker(state: &str) -> &'static str { + match state { + "APPROVED" => "[approved]", + "CHANGES_REQUESTED" => "[changes requested]", + "COMMENTED" => "[commented]", + "DISMISSED" => "[dismissed]", + _ => "[review]", + } +} + +/// Count total diff lines across all steps (excluding review/CI artifacts). +fn count_total_diff_lines(steps: &[Step]) -> (u64, u64, Option) { + let mut total_add: u64 = 0; + let mut total_del: u64 = 0; + let mut files: HashSet<&str> = HashSet::new(); + for step in steps { + for (artifact, change) in &step.change { + // Skip review and CI artifacts + if artifact.starts_with("review://") || artifact.starts_with("ci://") { + continue; + } + if let Some(raw) = &change.raw { + let (a, d) = count_diff_lines(raw); + total_add += a as u64; + total_del += d as u64; + files.insert(artifact.as_str()); + } + } + } + let file_count = if files.is_empty() { + None + } else { + Some(files.len() as u64) + }; + (total_add, total_del, file_count) +} + +/// Compute a friendly date range string from step timestamps. +/// Returns empty string if no timestamps found. +pub(crate) fn friendly_date_range(steps: &[Step]) -> String { + if steps.is_empty() { + return String::new(); + } + + let mut first: Option<&str> = None; + let mut last: Option<&str> = None; + + for step in steps { + let ts = step.step.timestamp.as_str(); + if ts.is_empty() || ts.starts_with("1970") { + continue; + } + match first { + None => { + first = Some(ts); + last = Some(ts); + } + Some(f) => { + if ts < f { + first = Some(ts); + } + if ts > last.unwrap_or("") { + last = Some(ts); + } + } + } + } + + let Some(first) = first else { + return String::new(); + }; + let last = last.unwrap_or(first); + + // Extract YYYY-MM-DD from ISO 8601 + let first_date = &first[..first.len().min(10)]; + let last_date = &last[..last.len().min(10)]; + + let Some(first_fmt) = format_date(first_date) else { + return String::new(); + }; + + if first_date == last_date { + return first_fmt; + } + + let Some(last_fmt) = format_date(last_date) else { + return first_fmt; + }; + + // Same month and year + let first_parts: Vec<&str> = first_date.split('-').collect(); + let last_parts: Vec<&str> = last_date.split('-').collect(); + + if first_parts.len() == 3 && last_parts.len() == 3 { + if first_parts[0] == last_parts[0] && first_parts[1] == last_parts[1] { + // Same month: "Feb 26\u{2013}27, 2026" + let month = month_abbrev(first_parts[1]); + let day1 = first_parts[2].trim_start_matches('0'); + let day2 = last_parts[2].trim_start_matches('0'); + return format!("{month} {day1}\u{2013}{day2}, {}", first_parts[0]); + } + if first_parts[0] == last_parts[0] { + // Same year: "Feb 26 \u{2013} Mar 1, 2026" + let month1 = month_abbrev(first_parts[1]); + let day1 = first_parts[2].trim_start_matches('0'); + let month2 = month_abbrev(last_parts[1]); + let day2 = last_parts[2].trim_start_matches('0'); + return format!( + "{month1} {day1} \u{2013} {month2} {day2}, {}", + first_parts[0] + ); + } + } + + // Different years + format!("{first_fmt} \u{2013} {last_fmt}") +} + +/// Format a YYYY-MM-DD date string to "Mon DD, YYYY". +fn format_date(date: &str) -> Option { + let parts: Vec<&str> = date.split('-').collect(); + if parts.len() != 3 { + return None; + } + let month = month_abbrev(parts[1]); + let day = parts[2].trim_start_matches('0'); + Some(format!("{month} {day}, {}", parts[0])) +} + +fn month_abbrev(month: &str) -> &'static str { + match month { + "01" => "Jan", + "02" => "Feb", + "03" => "Mar", + "04" => "Apr", + "05" => "May", + "06" => "Jun", + "07" => "Jul", + "08" => "Aug", + "09" => "Sep", + "10" => "Oct", + "11" => "Nov", + "12" => "Dec", + _ => "???", + } +} + +/// Text marker for CI conclusions. +fn ci_conclusion_marker(conclusion: &str) -> &'static str { + match conclusion { + "success" => "[pass]", + "failure" => "[fail]", + "cancelled" | "timed_out" => "[cancelled]", + "skipped" => "[skip]", + "neutral" => "[neutral]", + _ => "[unknown]", + } +} + +/// Format a set of actors as a compact comma-separated string. +fn format_actor_list(actors: &HashSet<&str>) -> String { + let mut list: Vec<&str> = actors.iter().copied().collect(); + list.sort(); + list.iter() + .map(|a| format!("`{a}`")) + .collect::>() + .join(", ") +} + +/// Topological sort of steps respecting parent edges. +/// Falls back to input order for steps without declared parents. +fn topo_sort<'a>(steps: &'a [Step]) -> Vec<&'a Step> { + let index: HashMap<&str, &Step> = steps.iter().map(|s| (s.step.id.as_str(), s)).collect(); + let ids: Vec<&str> = steps.iter().map(|s| s.step.id.as_str()).collect(); + let id_set: HashSet<&str> = ids.iter().copied().collect(); + + // Kahn's algorithm + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut children: HashMap<&str, Vec<&str>> = HashMap::new(); + + for &id in &ids { + in_degree.entry(id).or_insert(0); + children.entry(id).or_default(); + } + + for step in steps { + for parent in &step.step.parents { + if id_set.contains(parent.as_str()) { + *in_degree.entry(step.step.id.as_str()).or_insert(0) += 1; + children + .entry(parent.as_str()) + .or_default() + .push(step.step.id.as_str()); + } + } + } + + // Seed queue with roots, ordered by position in input + let mut queue: Vec<&str> = ids + .iter() + .copied() + .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0) + .collect(); + + let mut result: Vec<&'a Step> = Vec::with_capacity(steps.len()); + + while let Some(id) = queue.first().copied() { + queue.remove(0); + if let Some(step) = index.get(id) { + result.push(step); + } + if let Some(kids) = children.get(id) { + for &child in kids { + let deg = in_degree.get_mut(child).unwrap(); + *deg -= 1; + if *deg == 0 { + queue.push(child); + } + } + } + } + + // Append any remaining (cycle or orphan) steps in original order + let placed: HashSet<&str> = result.iter().map(|s| s.step.id.as_str()).collect(); + for step in steps { + if !placed.contains(step.step.id.as_str()) { + result.push(step); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use toolpath::v1::{ + Base, Graph, GraphIdentity, GraphMeta, Path, PathIdentity, PathMeta, PathOrRef, PathRef, + Ref, Step, StructuralChange, + }; + + fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step { + let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z") + .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new"); + for p in parents { + step = step.with_parent(*p); + } + step + } + + fn make_step_with_intent(id: &str, actor: &str, parents: &[&str], intent: &str) -> Step { + make_step(id, actor, parents).with_intent(intent) + } + + // ── render_step ────────────────────────────────────────────────────── + + #[test] + fn test_render_step_basic() { + let step = make_step("s1", "human:alex", &[]); + let opts = RenderOptions::default(); + let md = render_step(&step, &opts); + + assert!(md.starts_with("# s1")); + assert!(md.contains("human:alex")); + assert!(md.contains("src/main.rs")); + } + + #[test] + fn test_render_step_with_intent() { + let step = make_step_with_intent("s1", "human:alex", &[], "Fix the bug"); + let opts = RenderOptions::default(); + let md = render_step(&step, &opts); + + assert!(md.contains("> Fix the bug")); + } + + #[test] + fn test_render_step_with_parents() { + let step = make_step("s2", "agent:claude", &["s1"]); + let opts = RenderOptions::default(); + let md = render_step(&step, &opts); + + assert!(md.contains("`s1`")); + } + + #[test] + fn test_render_step_with_front_matter() { + let step = make_step("s1", "human:alex", &[]); + let opts = RenderOptions { + front_matter: true, + ..Default::default() + }; + let md = render_step(&step, &opts); + + assert!(md.starts_with("---\n")); + assert!(md.contains("type: step")); + assert!(md.contains("id: s1")); + assert!(md.contains("actor: human:alex")); + } + + #[test] + fn test_render_step_full_detail() { + let step = make_step("s1", "human:alex", &[]); + let opts = RenderOptions { + detail: Detail::Full, + ..Default::default() + }; + let md = render_step(&step, &opts); + + assert!(md.contains("```diff")); + assert!(md.contains("-old")); + assert!(md.contains("+new")); + } + + #[test] + fn test_render_step_summary_has_diffstat() { + let step = make_step("s1", "human:alex", &[]); + let opts = RenderOptions::default(); + let md = render_step(&step, &opts); + + assert!(md.contains("+1 -1")); + } + + // ── render_path ────────────────────────────────────────────────────── + + #[test] + fn test_render_path_basic() { + let s1 = make_step_with_intent("s1", "human:alex", &[], "Start"); + let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Continue"); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: Some(Base::vcs("github:org/repo", "abc123")), + head: "s2".into(), + }, + steps: vec![s1, s2], + meta: Some(PathMeta { + title: Some("My PR".into()), + ..Default::default() + }), + }; + let opts = RenderOptions::default(); + let md = render_path(&path, &opts); + + assert!(md.starts_with("# My PR")); + assert!(md.contains("github:org/repo")); + assert!(md.contains("## Timeline")); + assert!(md.contains("### s1")); + assert!(md.contains("### s2")); + assert!(md.contains("[head]")); + } + + #[test] + fn test_render_path_with_dead_ends() { + let s1 = make_step("s1", "human:alex", &[]); + let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Good approach"); + let s2a = make_step_with_intent("s2a", "agent:claude", &["s1"], "Bad approach (abandoned)"); + let s3 = make_step("s3", "human:alex", &["s2"]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s3".into(), + }, + steps: vec![s1, s2, s2a, s3], + meta: None, + }; + let opts = RenderOptions::default(); + let md = render_path(&path, &opts); + + assert!(md.contains("[dead end]")); + assert!(md.contains("## Dead Ends")); + assert!(md.contains("Bad approach (abandoned)")); + } + + #[test] + fn test_render_path_with_front_matter() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let opts = RenderOptions { + front_matter: true, + ..Default::default() + }; + let md = render_path(&path, &opts); + + assert!(md.starts_with("---\n")); + assert!(md.contains("type: path")); + assert!(md.contains("id: p1")); + assert!(md.contains("steps: 1")); + assert!(md.contains("dead_ends: 0")); + } + + #[test] + fn test_render_path_stats_line() { + let s1 = make_step("s1", "human:alex", &[]); + let s2 = make_step("s2", "agent:claude", &["s1"]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s2".into(), + }, + steps: vec![s1, s2], + meta: None, + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("**Steps:** 2")); + assert!(md.contains("**Artifacts:** 1")); + assert!(md.contains("**Dead ends:** 0")); + } + + #[test] + fn test_render_path_with_refs() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: Some(PathMeta { + refs: vec![Ref { + rel: "fixes".into(), + href: "issue://github/org/repo/issues/42".into(), + }], + ..Default::default() + }), + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("**fixes:**")); + assert!(md.contains("issue://github/org/repo/issues/42")); + } + + // ── render_graph ───────────────────────────────────────────────────── + + #[test] + fn test_render_graph_basic() { + let s1 = make_step_with_intent("s1", "human:alex", &[], "First"); + let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Second"); + let path1 = Path { + path: PathIdentity { + id: "p1".into(), + base: Some(Base::vcs("github:org/repo", "abc")), + head: "s2".into(), + }, + steps: vec![s1, s2], + meta: Some(PathMeta { + title: Some("PR #42".into()), + ..Default::default() + }), + }; + + let s3 = make_step("s3", "human:bob", &[]); + let path2 = Path { + path: PathIdentity { + id: "p2".into(), + base: None, + head: "s3".into(), + }, + steps: vec![s3], + meta: Some(PathMeta { + title: Some("PR #43".into()), + ..Default::default() + }), + }; + + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![ + PathOrRef::Path(Box::new(path1)), + PathOrRef::Path(Box::new(path2)), + ], + meta: Some(GraphMeta { + title: Some("Release v2.0".into()), + ..Default::default() + }), + }; + let opts = RenderOptions::default(); + let md = render_graph(&graph, &opts); + + assert!(md.starts_with("# Release v2.0")); + assert!(md.contains("| PR #42")); + assert!(md.contains("| PR #43")); + assert!(md.contains("## PR #42")); + assert!(md.contains("## PR #43")); + } + + #[test] + fn test_render_graph_with_refs() { + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![PathOrRef::Ref(PathRef { + ref_url: "https://example.com/path.json".into(), + })], + meta: None, + }; + let md = render_graph(&graph, &RenderOptions::default()); + + assert!(md.contains("External references")); + assert!(md.contains("example.com/path.json")); + } + + #[test] + fn test_render_graph_with_front_matter() { + let s1 = make_step("s1", "human:alex", &[]); + let path1 = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![ + PathOrRef::Path(Box::new(path1)), + PathOrRef::Ref(PathRef { + ref_url: "https://example.com".into(), + }), + ], + meta: None, + }; + let opts = RenderOptions { + front_matter: true, + ..Default::default() + }; + let md = render_graph(&graph, &opts); + + assert!(md.starts_with("---\n")); + assert!(md.contains("type: graph")); + assert!(md.contains("paths: 1")); + assert!(md.contains("external_refs: 1")); + } + + #[test] + fn test_render_graph_with_meta_refs() { + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![], + meta: Some(GraphMeta { + title: Some("Release".into()), + refs: vec![Ref { + rel: "milestone".into(), + href: "issue://github/org/repo/milestone/5".into(), + }], + ..Default::default() + }), + }; + let md = render_graph(&graph, &RenderOptions::default()); + + assert!(md.contains("**milestone:**")); + } + + // ── render (dispatch) ──────────────────────────────────────────────── + + #[test] + fn test_render_dispatches_step() { + let step = make_step("s1", "human:alex", &[]); + let doc = Document::Step(step); + let md = render(&doc, &RenderOptions::default()); + assert!(md.contains("# s1")); + } + + #[test] + fn test_render_dispatches_path() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let doc = Document::Path(path); + let md = render(&doc, &RenderOptions::default()); + assert!(md.contains("## Timeline")); + } + + #[test] + fn test_render_dispatches_graph() { + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![], + meta: Some(GraphMeta { + title: Some("My Graph".into()), + ..Default::default() + }), + }; + let doc = Document::Graph(graph); + let md = render(&doc, &RenderOptions::default()); + assert!(md.contains("# My Graph")); + } + + // ── topo_sort ──────────────────────────────────────────────────────── + + #[test] + fn test_topo_sort_linear() { + let s1 = make_step("s1", "human:alex", &[]); + let s2 = make_step("s2", "agent:claude", &["s1"]); + let s3 = make_step("s3", "human:alex", &["s2"]); + let steps = vec![s3.clone(), s1.clone(), s2.clone()]; // scrambled input + let sorted = topo_sort(&steps); + let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect(); + assert_eq!(ids, vec!["s1", "s2", "s3"]); + } + + #[test] + fn test_topo_sort_branching() { + let s1 = make_step("s1", "human:alex", &[]); + let s2a = make_step("s2a", "agent:claude", &["s1"]); + let s2b = make_step("s2b", "agent:claude", &["s1"]); + let s3 = make_step("s3", "human:alex", &["s2a", "s2b"]); + let steps = vec![s1, s2a, s2b, s3]; + let sorted = topo_sort(&steps); + let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect(); + + // s1 must come first, s3 must come last + assert_eq!(ids[0], "s1"); + assert_eq!(ids[3], "s3"); + } + + #[test] + fn test_topo_sort_preserves_input_order_for_roots() { + let s1 = make_step("s1", "human:alex", &[]); + let s2 = make_step("s2", "human:bob", &[]); + let steps = vec![s1, s2]; + let sorted = topo_sort(&steps); + let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect(); + assert_eq!(ids, vec!["s1", "s2"]); + } + + // ── count_diff_lines ───────────────────────────────────────────────── + + #[test] + fn test_count_diff_lines() { + let diff = "@@ -1,3 +1,4 @@\n-old1\n-old2\n+new1\n+new2\n+new3\n context"; + let (add, del) = count_diff_lines(diff); + assert_eq!(add, 3); + assert_eq!(del, 2); + } + + #[test] + fn test_count_diff_lines_ignores_triple_prefix() { + let diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new"; + let (add, del) = count_diff_lines(diff); + assert_eq!(add, 1); + assert_eq!(del, 1); + } + + #[test] + fn test_count_diff_lines_empty() { + assert_eq!(count_diff_lines(""), (0, 0)); + } + + // ── structural changes ─────────────────────────────────────────────── + + #[test] + fn test_render_structural_change_summary() { + let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z"); + step.change.insert( + "src/main.rs".into(), + toolpath::v1::ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "rename_function".into(), + extra: Default::default(), + }), + }, + ); + let md = render_step(&step, &RenderOptions::default()); + assert!(md.contains("rename_function")); + } + + #[test] + fn test_render_structural_change_full() { + let mut extra = std::collections::HashMap::new(); + extra.insert("from".to_string(), serde_json::json!("foo")); + extra.insert("to".to_string(), serde_json::json!("bar")); + let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z"); + step.change.insert( + "src/main.rs".into(), + toolpath::v1::ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "rename_function".into(), + extra, + }), + }, + ); + let md = render_step( + &step, + &RenderOptions { + detail: Detail::Full, + ..Default::default() + }, + ); + assert!(md.contains("Structural: `rename_function`")); + } + + // ── actors section ─────────────────────────────────────────────────── + + #[test] + fn test_render_path_with_actors() { + let s1 = make_step("s1", "human:alex", &[]); + let mut actors = std::collections::HashMap::new(); + actors.insert( + "human:alex".into(), + toolpath::v1::ActorDefinition { + name: Some("Alex".into()), + provider: None, + model: None, + identities: vec![], + keys: vec![], + }, + ); + actors.insert( + "agent:claude-code".into(), + toolpath::v1::ActorDefinition { + name: Some("Claude Code".into()), + provider: Some("Anthropic".into()), + model: Some("claude-sonnet-4-20250514".into()), + identities: vec![], + keys: vec![], + }, + ); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: Some(PathMeta { + actors: Some(actors), + ..Default::default() + }), + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("## Actors")); + assert!(md.contains("Alex")); + assert!(md.contains("Claude Code")); + assert!(md.contains("Anthropic")); + } + + // ── full detail mode ───────────────────────────────────────────────── + + #[test] + fn test_render_path_full_detail() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let opts = RenderOptions { + detail: Detail::Full, + ..Default::default() + }; + let md = render_path(&path, &opts); + + assert!(md.contains("```diff")); + assert!(md.contains("-old")); + assert!(md.contains("+new")); + } + + // ── edge cases ─────────────────────────────────────────────────────── + + #[test] + fn test_render_path_no_title() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "path-42".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let md = render_path(&path, &RenderOptions::default()); + assert!(md.starts_with("# path-42")); + } + + #[test] + fn test_render_step_no_changes() { + let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z"); + let md = render_step(&step, &RenderOptions::default()); + assert!(md.contains("# s1")); + assert!(!md.contains("## Changes")); + } + + #[test] + fn test_render_graph_empty_paths() { + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![], + meta: None, + }; + let md = render_graph(&graph, &RenderOptions::default()); + assert!(md.contains("# g1")); + } + + // ── review/CI rendering ─────────────────────────────────────────── + + fn make_review_comment_step(id: &str, actor: &str, artifact: &str, body: &str) -> Step { + let mut extra = std::collections::HashMap::new(); + extra.insert("body".to_string(), serde_json::json!(body)); + let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z"); + step.change.insert( + artifact.to_string(), + ArtifactChange { + raw: Some("@@ -1,3 +1,4 @@\n fn example() {\n+ let x = 42;\n }".to_string()), + structural: Some(StructuralChange { + change_type: "review.comment".into(), + extra, + }), + }, + ); + step + } + + fn make_review_decision_step(id: &str, actor: &str, state: &str, body: &str) -> Step { + let mut extra = std::collections::HashMap::new(); + extra.insert("state".to_string(), serde_json::json!(state)); + let mut step = Step::new(id, actor, "2026-01-29T11:00:00Z"); + step.change.insert( + "review://decision".to_string(), + ArtifactChange { + raw: if body.is_empty() { + None + } else { + Some(body.to_string()) + }, + structural: Some(StructuralChange { + change_type: "review.decision".into(), + extra, + }), + }, + ); + step + } + + fn make_ci_step(id: &str, name: &str, conclusion: &str) -> Step { + let mut extra = std::collections::HashMap::new(); + extra.insert("conclusion".to_string(), serde_json::json!(conclusion)); + extra.insert( + "url".to_string(), + serde_json::json!("https://github.com/acme/widgets/actions/runs/123"), + ); + let mut step = Step::new(id, "ci:github-actions", "2026-01-29T12:00:00Z"); + step.change.insert( + format!("ci://checks/{}", name), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "ci.run".into(), + extra, + }), + }, + ); + step + } + + #[test] + fn test_render_review_comment_summary() { + let step = make_review_comment_step( + "s1", + "human:bob", + "review://src/main.rs#L42", + "Consider using a constant here.", + ); + let md = render_step(&step, &RenderOptions::default()); + + // Should show friendly artifact name and body + assert!(md.contains("src/main.rs:42")); + assert!(md.contains("Consider using a constant here.")); + // Should NOT show the opaque review:// URI + assert!(!md.contains("review://")); + } + + #[test] + fn test_render_review_comment_full() { + let step = make_review_comment_step( + "s1", + "human:bob", + "review://src/main.rs#L42", + "Consider using a constant here.", + ); + let md = render_step( + &step, + &RenderOptions { + detail: Detail::Full, + ..Default::default() + }, + ); + + // Should show body as blockquote + assert!(md.contains("> Consider using a constant here.")); + // Should show diff_hunk + assert!(md.contains("```diff")); + assert!(md.contains("let x = 42")); + } + + #[test] + fn test_render_review_decision_summary() { + let step = make_review_decision_step("s1", "human:dave", "APPROVED", "LGTM!"); + let md = render_step(&step, &RenderOptions::default()); + + assert!(md.contains("[approved]")); + assert!(md.contains("APPROVED")); + assert!(md.contains("LGTM!")); + } + + #[test] + fn test_render_ci_summary() { + let step = make_ci_step("s1", "test", "success"); + let md = render_step(&step, &RenderOptions::default()); + + assert!(md.contains("test")); + assert!(md.contains("[pass]")); + assert!(md.contains("success")); + // Should NOT show ci://checks/ prefix + assert!(!md.contains("ci://checks/")); + } + + #[test] + fn test_render_ci_failure() { + let step = make_ci_step("s1", "lint", "failure"); + let md = render_step(&step, &RenderOptions::default()); + + assert!(md.contains("lint")); + assert!(md.contains("[fail]")); + assert!(md.contains("failure")); + } + + #[test] + fn test_render_ci_full_with_url() { + let step = make_ci_step("s1", "test", "success"); + let md = render_step( + &step, + &RenderOptions { + detail: Detail::Full, + ..Default::default() + }, + ); + + assert!(md.contains("details")); + assert!(md.contains("actions/runs/123")); + } + + #[test] + fn test_render_review_section() { + let s1 = make_step("s1", "human:alice", &[]); + let s2 = make_review_comment_step( + "s2", + "human:bob", + "review://src/main.rs#L42", + "Consider using a constant.", + ); + let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!"); + let mut s2 = s2; + s2 = s2.with_parent("s1"); + let mut s3 = s3; + s3 = s3.with_parent("s2"); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s3".into(), + }, + steps: vec![s1, s2, s3], + meta: None, + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("## Review")); + assert!(md.contains("APPROVED")); + assert!(md.contains("Ship it!")); + assert!(md.contains("### Inline comments")); + assert!(md.contains("src/main.rs:42")); + assert!(md.contains("Consider using a constant.")); + } + + #[test] + fn test_render_no_review_section_without_reviews() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(!md.contains("## Review")); + } + + // ── PR identity and diffstat ────────────────────────────────────── + + #[test] + fn test_render_pr_identity() { + let s1 = make_step("s1", "human:alice", &[]); + let mut extra = std::collections::HashMap::new(); + let github = serde_json::json!({ + "number": 42, + "author": "alice", + "state": "open", + "draft": false, + "merged": false, + "additions": 150, + "deletions": 30, + "changed_files": 5 + }); + extra.insert("github".to_string(), github); + let path = Path { + path: PathIdentity { + id: "pr-42".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: Some(PathMeta { + title: Some("Add feature".into()), + extra, + ..Default::default() + }), + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("**PR #42**")); + assert!(md.contains("by alice")); + assert!(md.contains("open")); + assert!(md.contains("+150")); + assert!(md.contains("\u{2212}30")); + assert!(md.contains("5 files")); + // Should NOT show opaque head ID + assert!(!md.contains("**Head:**")); + } + + #[test] + fn test_render_no_pr_identity_without_github_meta() { + let s1 = make_step("s1", "human:alex", &[]); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let md = render_path(&path, &RenderOptions::default()); + + // Should show Head when no GitHub meta + assert!(md.contains("**Head:**")); + assert!(!md.contains("**PR #")); + } + + // ── friendly helpers ────────────────────────────────────────────── + + #[test] + fn test_friendly_artifact_name() { + assert_eq!( + friendly_artifact_name("review://src/main.rs#L42"), + "src/main.rs:42" + ); + assert_eq!(friendly_artifact_name("ci://checks/test"), "test"); + assert_eq!(friendly_artifact_name("review://decision"), "decision"); + assert_eq!(friendly_artifact_name("src/main.rs"), "src/main.rs"); + } + + #[test] + fn test_friendly_date_range_same_day() { + let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z"); + let s2 = Step::new("s2", "human:alex", "2026-02-26T14:00:00Z"); + assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26, 2026"); + } + + #[test] + fn test_friendly_date_range_same_month() { + let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z"); + let s2 = Step::new("s2", "human:alex", "2026-02-27T14:00:00Z"); + assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26\u{2013}27, 2026"); + } + + #[test] + fn test_friendly_date_range_different_months() { + let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z"); + let s2 = Step::new("s2", "human:alex", "2026-03-01T14:00:00Z"); + assert_eq!( + friendly_date_range(&[s1, s2]), + "Feb 26 \u{2013} Mar 1, 2026" + ); + } + + #[test] + fn test_friendly_date_range_empty() { + assert_eq!(friendly_date_range(&[]), ""); + } + + #[test] + fn test_truncate_str() { + assert_eq!(truncate_str("hello", 10), "hello"); + assert_eq!( + truncate_str("hello world this is long", 10), + "hello worl..." + ); + assert_eq!(truncate_str("line1\nline2", 20), "line1 line2"); + } + + // ── PR conversation comments ──────────────────────────────────── + + fn make_conversation_step(id: &str, actor: &str, body: &str) -> Step { + let mut extra = std::collections::HashMap::new(); + extra.insert("body".to_string(), serde_json::json!(body)); + let mut step = Step::new(id, actor, "2026-01-29T15:00:00Z"); + step.change.insert( + "review://conversation".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "review.conversation".into(), + extra, + }), + }, + ); + step + } + + #[test] + fn test_render_conversation_summary() { + let step = make_conversation_step("s1", "human:carol", "Looks good overall!"); + let md = render_step(&step, &RenderOptions::default()); + + assert!(md.contains("conversation")); + assert!(md.contains("Looks good overall!")); + // Should NOT show review:// prefix + assert!(!md.contains("review://")); + } + + #[test] + fn test_render_conversation_full() { + let step = make_conversation_step("s1", "human:carol", "Looks good overall!"); + let md = render_step( + &step, + &RenderOptions { + detail: Detail::Full, + ..Default::default() + }, + ); + + assert!(md.contains("> Looks good overall!")); + assert!(!md.contains("review://")); + } + + #[test] + fn test_review_section_includes_conversations() { + let s1 = make_step("s1", "human:alice", &[]); + let s2 = make_conversation_step("s2", "human:carol", "Looks good overall!"); + let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!"); + let s2 = s2.with_parent("s1"); + let s3 = s3.with_parent("s2"); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s3".into(), + }, + steps: vec![s1, s2, s3], + meta: None, + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("## Review")); + assert!(md.contains("### Discussion")); + assert!(md.contains("carol")); + assert!(md.contains("Looks good overall!")); + assert!(md.contains("APPROVED")); + } + + #[test] + fn test_render_merged_pr() { + let s1 = make_step("s1", "human:alice", &[]); + let mut extra = std::collections::HashMap::new(); + let github = serde_json::json!({ + "number": 7, + "author": "alice", + "state": "closed", + "draft": false, + "merged": true, + "additions": 42, + "deletions": 10, + "changed_files": 3 + }); + extra.insert("github".to_string(), github); + let path = Path { + path: PathIdentity { + id: "pr-7".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: Some(PathMeta { + title: Some("Fix the thing".into()), + extra, + ..Default::default() + }), + }; + let md = render_path(&path, &RenderOptions::default()); + + assert!(md.contains("**PR #7**")); + assert!(md.contains("by alice")); + // merged overrides state=closed + assert!(md.contains("merged")); + assert!(!md.contains("closed")); + } + + #[test] + fn test_catch_all_uses_friendly_name() { + // An artifact with an unknown structural type should still get a friendly name + let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z"); + step.change.insert( + "review://some/path#L5".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "review.custom".into(), + extra: Default::default(), + }), + }, + ); + let md = render_step(&step, &RenderOptions::default()); + + // Should use friendly name (some/path:5), not raw review:// URI + assert!(md.contains("some/path:5")); + assert!(!md.contains("review://")); + } +} diff --git a/crates/toolpath-md/src/source/github.rs b/crates/toolpath-md/src/source/github.rs new file mode 100644 index 0000000..9147cfd --- /dev/null +++ b/crates/toolpath-md/src/source/github.rs @@ -0,0 +1,184 @@ +use std::fmt::Write; + +use toolpath::v1::Path; + +use super::SourceContext; +use crate::friendly_date_range; + +/// Extract GitHub PR context from path metadata, if present. +/// +/// Returns `None` if the path has no `extra["github"]` with a `number` field. +pub(super) fn from_path(path: &Path) -> Option { + let gh = path.meta.as_ref()?.extra.get("github")?.as_object()?; + + let identity_line = build_identity_line(gh, &path.steps)?; + let diffstat = extract_diffstat(gh); + + Some(SourceContext { + identity_line: Some(identity_line), + diffstat, + }) +} + +/// Build the PR identity line: "**PR #42** by alice · merged · Feb 26–27, 2026" +fn build_identity_line( + gh: &serde_json::Map, + steps: &[toolpath::v1::Step], +) -> Option { + let number = gh.get("number")?.as_u64()?; + let author = gh.get("author").and_then(serde_json::Value::as_str); + let state = gh.get("state").and_then(serde_json::Value::as_str); + let merged = gh + .get("merged") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let draft = gh + .get("draft") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + let mut line = format!("**PR #{number}**"); + if let Some(a) = author { + write!(line, " by {a}").unwrap(); + } + let status = if merged { + "merged" + } else if draft { + "draft" + } else { + state.unwrap_or("open") + }; + write!(line, " \u{00b7} {status}").unwrap(); + + let date_range = friendly_date_range(steps); + if !date_range.is_empty() { + write!(line, " \u{00b7} {date_range}").unwrap(); + } + + Some(line) +} + +/// Extract diffstat from GitHub metadata fields. +fn extract_diffstat( + gh: &serde_json::Map, +) -> Option<(u64, u64, Option)> { + let a = gh.get("additions").and_then(serde_json::Value::as_u64); + let d = gh.get("deletions").and_then(serde_json::Value::as_u64); + if a.is_some() || d.is_some() { + let f = gh.get("changed_files").and_then(serde_json::Value::as_u64); + Some((a.unwrap_or(0), d.unwrap_or(0), f)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use toolpath::v1::{Path, PathIdentity, PathMeta, Step}; + + fn make_github_path(github_json: serde_json::Value) -> Path { + let s1 = Step::new("s1", "human:alice", "2026-02-26T10:00:00Z"); + let s2 = Step::new("s2", "human:alice", "2026-02-27T14:00:00Z").with_parent("s1"); + let mut extra = std::collections::HashMap::new(); + extra.insert("github".to_string(), github_json); + Path { + path: PathIdentity { + id: "pr-42".into(), + base: None, + head: "s2".into(), + }, + steps: vec![s1, s2], + meta: Some(PathMeta { + title: Some("Add feature".into()), + extra, + ..Default::default() + }), + } + } + + #[test] + fn from_path_with_full_meta() { + let path = make_github_path(serde_json::json!({ + "number": 42, + "author": "alice", + "state": "open", + "draft": false, + "merged": false, + "additions": 150, + "deletions": 30, + "changed_files": 5 + })); + let ctx = from_path(&path).unwrap(); + + let line = ctx.identity_line.unwrap(); + assert!(line.contains("**PR #42**")); + assert!(line.contains("by alice")); + assert!(line.contains("open")); + assert!(line.contains("Feb 26\u{2013}27, 2026")); + + let (add, del, files) = ctx.diffstat.unwrap(); + assert_eq!(add, 150); + assert_eq!(del, 30); + assert_eq!(files, Some(5)); + } + + #[test] + fn merged_overrides_state() { + let path = make_github_path(serde_json::json!({ + "number": 7, + "author": "alice", + "state": "closed", + "merged": true + })); + let ctx = from_path(&path).unwrap(); + let line = ctx.identity_line.unwrap(); + assert!(line.contains("merged")); + assert!(!line.contains("closed")); + } + + #[test] + fn missing_number_returns_none() { + let path = make_github_path(serde_json::json!({ + "author": "alice", + "state": "open" + })); + assert!(from_path(&path).is_none()); + } + + #[test] + fn partial_diffstat_additions_only() { + let path = make_github_path(serde_json::json!({ + "number": 1, + "additions": 10 + })); + let ctx = from_path(&path).unwrap(); + let (add, del, files) = ctx.diffstat.unwrap(); + assert_eq!(add, 10); + assert_eq!(del, 0); + assert_eq!(files, None); + } + + #[test] + fn no_diffstat_without_additions_or_deletions() { + let path = make_github_path(serde_json::json!({ + "number": 1, + "changed_files": 3 + })); + let ctx = from_path(&path).unwrap(); + assert!(ctx.diffstat.is_none()); + } + + #[test] + fn draft_status() { + let path = make_github_path(serde_json::json!({ + "number": 99, + "draft": true, + "state": "open" + })); + let ctx = from_path(&path).unwrap(); + let line = ctx.identity_line.unwrap(); + assert!(line.contains("draft")); + assert!(!line.contains("open")); + } +} diff --git a/crates/toolpath-md/src/source/mod.rs b/crates/toolpath-md/src/source/mod.rs new file mode 100644 index 0000000..8479075 --- /dev/null +++ b/crates/toolpath-md/src/source/mod.rs @@ -0,0 +1,49 @@ +mod github; + +use toolpath::v1::Path; + +/// Source-specific context extracted from path metadata. +/// +/// The generic renderer reads these fields without knowing which source produced them. +#[derive(Debug, Default)] +pub(crate) struct SourceContext { + /// Source-specific identity line (e.g., "**PR #42** by alice · merged"). + /// Replaces the generic **Head:** line when present. + pub identity_line: Option, + + /// Authoritative diffstat from source metadata, bypassing diff counting. + /// `(additions, deletions, changed_files)`. + pub diffstat: Option<(u64, u64, Option)>, +} + +/// Detect the source of a path and extract source-specific context. +pub(crate) fn detect(path: &Path) -> SourceContext { + if let Some(ctx) = github::from_path(path) { + return ctx; + } + // Future: git::from_path(path), claude::from_path(path) + SourceContext::default() +} + +#[cfg(test)] +mod tests { + use super::*; + use toolpath::v1::{Path, PathIdentity, Step}; + + #[test] + fn detect_returns_default_for_plain_path() { + let s1 = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z"); + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + }, + steps: vec![s1], + meta: None, + }; + let ctx = detect(&path); + assert!(ctx.identity_line.is_none()); + assert!(ctx.diffstat.is_none()); + } +} diff --git a/scripts/release.sh b/scripts/release.sh index 95fd150..6526535 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -17,7 +17,7 @@ set -euo pipefail # toolpath-claude (depends on toolpath, toolpath-convo) # 3. toolpath-cli (depends on all of the above) -ALL_CRATES=(toolpath toolpath-convo toolpath-git toolpath-github toolpath-dot toolpath-claude toolpath-cli) +ALL_CRATES=(toolpath toolpath-convo toolpath-git toolpath-github toolpath-dot toolpath-md toolpath-claude toolpath-cli) DRY_RUN="" AUTO_YES="" @@ -198,12 +198,12 @@ for crate in toolpath toolpath-convo; do done # Tier 2: satellite crates (depend on tier 1, no cross-deps) -for crate in toolpath-git toolpath-github toolpath-dot toolpath-claude; do +for crate in toolpath-git toolpath-github toolpath-dot toolpath-md toolpath-claude; do publish "$crate" done # Wait for tier 2 publishes to land before publishing the CLI -for crate in toolpath-git toolpath-github toolpath-dot toolpath-claude; do +for crate in toolpath-git toolpath-github toolpath-dot toolpath-md toolpath-claude; do if should_publish "$crate"; then wait_for_index "$crate" "$(crate_version "$crate")" fi diff --git a/site/_data/crates.json b/site/_data/crates.json index 170488d..8aec434 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -25,7 +25,7 @@ }, { "name": "toolpath-github", - "version": "0.1.0", + "version": "0.2.0", "description": "Derive from GitHub pull requests", "docs": "https://docs.rs/toolpath-github", "crate": "https://crates.io/crates/toolpath-github", @@ -47,6 +47,14 @@ "crate": "https://crates.io/crates/toolpath-dot", "role": "Renders any Toolpath Document as a Graphviz diagram. Steps are color-coded by actor type, dead ends get red dashed borders, and the DAG structure is preserved visually." }, + { + "name": "toolpath-md", + "version": "0.2.0", + "description": "Markdown rendering for LLM consumption", + "docs": "https://docs.rs/toolpath-md", + "crate": "https://crates.io/crates/toolpath-md", + "role": "Renders any Toolpath Document as readable Markdown — a narrative an LLM can reason about. Dead ends are called out explicitly, diffs are included at configurable detail levels, and the output preserves enough anchoring info for an LLM to reference back into the original document." + }, { "name": "toolpath-cli", "version": "0.2.1", diff --git a/site/index.md b/site/index.md index 069817d..7bf439b 100644 --- a/site/index.md +++ b/site/index.md @@ -188,14 +188,14 @@ path query filter --input doc.json --actor "agent:" Toolpath is a Rust workspace of focused crates: -| Crate | What it does | -| ------------------------------------------------------ | ------------------------------------------ | -| [`toolpath`](https://docs.rs/toolpath) | Core types, builders, query API | -| [`toolpath-convo`](https://docs.rs/toolpath-convo) | Provider-agnostic conversation traits | -| [`toolpath-git`](https://docs.rs/toolpath-git) | Derive from git history | -| [`toolpath-github`](https://docs.rs/toolpath-github) | Derive from GitHub pull requests | -| [`toolpath-claude`](https://docs.rs/toolpath-claude) | Derive from Claude conversations | -| [`toolpath-dot`](https://docs.rs/toolpath-dot) | Graphviz DOT visualization | -| [`toolpath-cli`](https://docs.rs/toolpath-cli) | Unified CLI (`cargo install toolpath-cli`) | +| Crate | What it does | +| ---------------------------------------------------- | ------------------------------------------ | +| [`toolpath`](https://docs.rs/toolpath) | Core types, builders, query API | +| [`toolpath-convo`](https://docs.rs/toolpath-convo) | Provider-agnostic conversation traits | +| [`toolpath-git`](https://docs.rs/toolpath-git) | Derive from git history | +| [`toolpath-github`](https://docs.rs/toolpath-github) | Derive from GitHub pull requests | +| [`toolpath-claude`](https://docs.rs/toolpath-claude) | Derive from Claude conversations | +| [`toolpath-dot`](https://docs.rs/toolpath-dot) | Graphviz DOT visualization | +| [`toolpath-cli`](https://docs.rs/toolpath-cli) | Unified CLI (`cargo install toolpath-cli`) | See [Crates](/crates/) for details, or [docs.rs](https://docs.rs/toolpath) for API reference. diff --git a/site/pages/crates.md b/site/pages/crates.md index adf957e..aa8ac7c 100644 --- a/site/pages/crates.md +++ b/site/pages/crates.md @@ -17,6 +17,7 @@ toolpath-cli (binary: path) +-- toolpath-github -> toolpath +-- toolpath-claude -> toolpath, toolpath-convo +-- toolpath-dot -> toolpath + +-- toolpath-md -> toolpath ``` No cross-dependencies between satellite crates except `toolpath-claude -> toolpath-convo`. @@ -96,3 +97,13 @@ use toolpath_dot::{render, RenderOptions}; let dot_string = render(&doc, &RenderOptions::default()); // Pipe through `dot -Tpng` for an image ``` + +### Markdown rendering + +```rust +use toolpath::v1::Document; +use toolpath_md::{render, RenderOptions}; + +let md_string = render(&doc, &RenderOptions::default()); +// Feed to an LLM for contextual assistance +```