Skip to main content

toolpath_md/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod source;
4
5use std::collections::{HashMap, HashSet};
6use std::fmt::Write;
7
8use toolpath::v1::{ArtifactChange, Graph, Path, PathOrRef, Step, query};
9
10/// Detail level for the rendered markdown.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub enum Detail {
13    /// File-level change summaries, no diffs.
14    #[default]
15    Summary,
16    /// Full inline diffs as fenced code blocks.
17    Full,
18}
19
20/// Options controlling the markdown output.
21pub struct RenderOptions {
22    /// How much detail to include for each step's changes.
23    pub detail: Detail,
24    /// Emit YAML front matter with machine-readable metadata.
25    pub front_matter: bool,
26}
27
28impl Default for RenderOptions {
29    fn default() -> Self {
30        Self {
31            detail: Detail::Summary,
32            front_matter: false,
33        }
34    }
35}
36
37/// Render a Toolpath [`Graph`] as a Markdown string. Single-path graphs use
38/// the path-focused layout, multi-path graphs use the cross-path layout.
39///
40/// # Examples
41///
42/// ```
43/// use toolpath::v1::{Graph, Path, PathIdentity, Step};
44/// use toolpath_md::{render, RenderOptions};
45///
46/// let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z")
47///     .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new")
48///     .with_intent("Fix greeting");
49/// let path = Path {
50///     path: PathIdentity {
51///         id: "p1".into(),
52///         base: None,
53///         head: "s1".into(),
54///         graph_ref: None,
55///     },
56///     steps: vec![step],
57///     meta: None,
58/// };
59/// let graph = Graph::from_path(path);
60/// let md = render(&graph, &RenderOptions::default());
61/// assert!(md.contains("human:alex"));
62/// ```
63pub fn render(graph: &Graph, options: &RenderOptions) -> String {
64    if graph.paths.len() == 1
65        && let PathOrRef::Path(p) = &graph.paths[0]
66    {
67        return render_path(p, options);
68    }
69    render_graph(graph, options)
70}
71
72/// Render a single [`Step`] as Markdown.
73pub fn render_step(step: &Step, options: &RenderOptions) -> String {
74    let mut out = String::new();
75
76    if options.front_matter {
77        write_step_front_matter(&mut out, step);
78    }
79
80    writeln!(out, "# {}", step.step.id).unwrap();
81    writeln!(out).unwrap();
82    write_step_body(&mut out, step, options, false);
83
84    out
85}
86
87/// Render a [`Path`] as Markdown.
88pub fn render_path(path: &Path, options: &RenderOptions) -> String {
89    if is_agent_coding_session(path) {
90        return render_conversation_transcript(path, options);
91    }
92
93    let mut out = String::new();
94
95    if options.front_matter {
96        write_path_front_matter(&mut out, path);
97    }
98
99    // Title
100    let title = path
101        .meta
102        .as_ref()
103        .and_then(|m| m.title.as_deref())
104        .unwrap_or(&path.path.id);
105    writeln!(out, "# {title}").unwrap();
106    writeln!(out).unwrap();
107
108    // Context block
109    write_path_context(&mut out, path);
110
111    // Topological sort for readable ordering
112    let sorted = topo_sort(&path.steps);
113    let active = query::ancestors(&path.steps, &path.path.head);
114    let dead_end_set: HashSet<&str> = path
115        .steps
116        .iter()
117        .filter(|s| !active.contains(&s.step.id))
118        .map(|s| s.step.id.as_str())
119        .collect();
120
121    // Timeline
122    writeln!(out, "## Timeline").unwrap();
123    writeln!(out).unwrap();
124
125    for step in &sorted {
126        let is_dead = dead_end_set.contains(step.step.id.as_str());
127        let is_head = step.step.id == path.path.head;
128        write_path_step(&mut out, step, options, is_dead, is_head);
129    }
130
131    // Dead ends section (if any)
132    if !dead_end_set.is_empty() {
133        write_dead_ends_section(&mut out, &sorted, &dead_end_set);
134    }
135
136    // Review summary section
137    write_review_section(&mut out, &sorted);
138
139    // Actors section (if defined in meta)
140    if let Some(meta) = &path.meta
141        && let Some(actors) = &meta.actors
142    {
143        write_actors_section(&mut out, actors);
144    }
145
146    out
147}
148
149/// Render a [`Graph`] as Markdown.
150pub fn render_graph(graph: &Graph, options: &RenderOptions) -> String {
151    let mut out = String::new();
152
153    if options.front_matter {
154        write_graph_front_matter(&mut out, graph);
155    }
156
157    // Title
158    let title = graph
159        .meta
160        .as_ref()
161        .and_then(|m| m.title.as_deref())
162        .unwrap_or(&graph.graph.id);
163    writeln!(out, "# {title}").unwrap();
164    writeln!(out).unwrap();
165
166    // Intent
167    if let Some(meta) = &graph.meta
168        && let Some(intent) = &meta.intent
169    {
170        writeln!(out, "> {intent}").unwrap();
171        writeln!(out).unwrap();
172    }
173
174    // Refs
175    if let Some(meta) = &graph.meta
176        && !meta.refs.is_empty()
177    {
178        for r in &meta.refs {
179            writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
180        }
181        writeln!(out).unwrap();
182    }
183
184    // Summary table
185    let inline_paths: Vec<&Path> = graph
186        .paths
187        .iter()
188        .filter_map(|por| match por {
189            PathOrRef::Path(p) => Some(p.as_ref()),
190            PathOrRef::Ref(_) => None,
191        })
192        .collect();
193
194    let ref_urls: Vec<&str> = graph
195        .paths
196        .iter()
197        .filter_map(|por| match por {
198            PathOrRef::Ref(r) => Some(r.ref_url.as_str()),
199            PathOrRef::Path(_) => None,
200        })
201        .collect();
202
203    if !inline_paths.is_empty() {
204        writeln!(out, "| Path | Steps | Actors | Head |").unwrap();
205        writeln!(out, "|------|-------|--------|------|").unwrap();
206        for path in &inline_paths {
207            let path_title = path
208                .meta
209                .as_ref()
210                .and_then(|m| m.title.as_deref())
211                .unwrap_or(&path.path.id);
212            let step_count = path.steps.len();
213            let actors = query::all_actors(&path.steps);
214            let actors_str = format_actor_list(&actors);
215            writeln!(
216                out,
217                "| {path_title} | {step_count} | {actors_str} | `{}` |",
218                path.path.head
219            )
220            .unwrap();
221        }
222        writeln!(out).unwrap();
223    }
224
225    if !ref_urls.is_empty() {
226        writeln!(out, "**External references:**").unwrap();
227        for url in &ref_urls {
228            writeln!(out, "- `{url}`").unwrap();
229        }
230        writeln!(out).unwrap();
231    }
232
233    // Each path as a section
234    for path in &inline_paths {
235        let path_title = path
236            .meta
237            .as_ref()
238            .and_then(|m| m.title.as_deref())
239            .unwrap_or(&path.path.id);
240        writeln!(out, "---").unwrap();
241        writeln!(out).unwrap();
242        writeln!(out, "## {path_title}").unwrap();
243        writeln!(out).unwrap();
244
245        if is_agent_coding_session(path) {
246            write_path_context(&mut out, path);
247            write_conversation_transcript_body(&mut out, path, options);
248            continue;
249        }
250
251        write_path_context(&mut out, path);
252
253        let sorted = topo_sort(&path.steps);
254        let active = query::ancestors(&path.steps, &path.path.head);
255        let dead_end_set: HashSet<&str> = path
256            .steps
257            .iter()
258            .filter(|s| !active.contains(&s.step.id))
259            .map(|s| s.step.id.as_str())
260            .collect();
261
262        for step in &sorted {
263            let is_dead = dead_end_set.contains(step.step.id.as_str());
264            let is_head = step.step.id == path.path.head;
265            write_path_step(&mut out, step, options, is_dead, is_head);
266        }
267
268        if !dead_end_set.is_empty() {
269            write_dead_ends_section(&mut out, &sorted, &dead_end_set);
270        }
271    }
272
273    // Actors section (if defined in meta)
274    if let Some(meta) = &graph.meta
275        && let Some(actors) = &meta.actors
276    {
277        writeln!(out, "---").unwrap();
278        writeln!(out).unwrap();
279        write_actors_section(&mut out, actors);
280    }
281
282    out
283}
284
285// ============================================================================
286// Internal rendering helpers
287// ============================================================================
288
289fn write_step_body(out: &mut String, step: &Step, options: &RenderOptions, compact: bool) {
290    let heading = if compact { "###" } else { "##" };
291
292    // Actor + timestamp line
293    writeln!(out, "**Actor:** `{}`", step.step.actor).unwrap();
294    writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
295
296    // Parents
297    if !step.step.parents.is_empty() {
298        let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
299        writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
300    }
301
302    writeln!(out).unwrap();
303
304    // Intent
305    if let Some(meta) = &step.meta
306        && let Some(intent) = &meta.intent
307    {
308        writeln!(out, "> {intent}").unwrap();
309        writeln!(out).unwrap();
310    }
311
312    // Refs
313    if let Some(meta) = &step.meta
314        && !meta.refs.is_empty()
315    {
316        for r in &meta.refs {
317            writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
318        }
319        writeln!(out).unwrap();
320    }
321
322    // Changes
323    if !step.change.is_empty() {
324        writeln!(out, "{heading} Changes").unwrap();
325        writeln!(out).unwrap();
326
327        let mut artifacts: Vec<&String> = step.change.keys().collect();
328        artifacts.sort();
329
330        for artifact in artifacts {
331            let change = &step.change[artifact];
332            write_artifact_change(out, artifact, change, options);
333        }
334    }
335}
336
337fn write_artifact_change(
338    out: &mut String,
339    artifact: &str,
340    change: &ArtifactChange,
341    options: &RenderOptions,
342) {
343    let change_type = change
344        .structural
345        .as_ref()
346        .map(|s| s.change_type.as_str())
347        .unwrap_or("");
348
349    match options.detail {
350        Detail::Summary => match change_type {
351            "review.comment" | "review.conversation" => {
352                let display = friendly_artifact_name(artifact);
353                let body = change
354                    .structural
355                    .as_ref()
356                    .and_then(|s| s.extra.get("body"))
357                    .and_then(|v| v.as_str())
358                    .unwrap_or("");
359                let truncated = truncate_str(body, 120);
360                if truncated.is_empty() {
361                    writeln!(out, "- `{display}` (comment)").unwrap();
362                } else {
363                    writeln!(out, "- `{display}` \u{2014} \"{truncated}\"").unwrap();
364                }
365            }
366            "review.decision" => {
367                let state = change
368                    .structural
369                    .as_ref()
370                    .and_then(|s| s.extra.get("state"))
371                    .and_then(|v| v.as_str())
372                    .unwrap_or("COMMENTED");
373                let marker = review_state_marker(state);
374                let body = change.raw.as_deref().unwrap_or("");
375                let truncated = truncate_str(body, 120);
376                if truncated.is_empty() {
377                    writeln!(out, "- {marker} {state}").unwrap();
378                } else {
379                    writeln!(out, "- {marker} {state} \u{2014} \"{truncated}\"").unwrap();
380                }
381            }
382            "ci.run" => {
383                let name = friendly_artifact_name(artifact);
384                let conclusion = change
385                    .structural
386                    .as_ref()
387                    .and_then(|s| s.extra.get("conclusion"))
388                    .and_then(|v| v.as_str())
389                    .unwrap_or("unknown");
390                let marker = ci_conclusion_marker(conclusion);
391                writeln!(out, "- {name} {marker} {conclusion}").unwrap();
392            }
393            _ => {
394                let display = friendly_artifact_name(artifact);
395                let annotation = change_annotation(change);
396                writeln!(out, "- `{display}`{annotation}").unwrap();
397            }
398        },
399        Detail::Full => {
400            match change_type {
401                "review.comment" | "review.conversation" => {
402                    let display = friendly_artifact_name(artifact);
403                    writeln!(out, "**`{display}`**").unwrap();
404                    let body = change
405                        .structural
406                        .as_ref()
407                        .and_then(|s| s.extra.get("body"))
408                        .and_then(|v| v.as_str())
409                        .unwrap_or("");
410                    if !body.is_empty() {
411                        writeln!(out).unwrap();
412                        for line in body.lines() {
413                            writeln!(out, "> {line}").unwrap();
414                        }
415                    }
416                    // Show diff_hunk if present
417                    if let Some(raw) = &change.raw {
418                        writeln!(out).unwrap();
419                        writeln!(out, "```diff").unwrap();
420                        writeln!(out, "{raw}").unwrap();
421                        writeln!(out, "```").unwrap();
422                    }
423                    writeln!(out).unwrap();
424                }
425                "review.decision" => {
426                    let state = change
427                        .structural
428                        .as_ref()
429                        .and_then(|s| s.extra.get("state"))
430                        .and_then(|v| v.as_str())
431                        .unwrap_or("COMMENTED");
432                    let marker = review_state_marker(state);
433                    writeln!(out, "**{marker} {state}**").unwrap();
434                    if let Some(raw) = &change.raw {
435                        writeln!(out).unwrap();
436                        for line in raw.lines() {
437                            writeln!(out, "> {line}").unwrap();
438                        }
439                    }
440                    writeln!(out).unwrap();
441                }
442                "ci.run" => {
443                    let name = friendly_artifact_name(artifact);
444                    let conclusion = change
445                        .structural
446                        .as_ref()
447                        .and_then(|s| s.extra.get("conclusion"))
448                        .and_then(|v| v.as_str())
449                        .unwrap_or("unknown");
450                    let marker = ci_conclusion_marker(conclusion);
451                    write!(out, "**{name}** {marker} {conclusion}").unwrap();
452                    if let Some(url) = change
453                        .structural
454                        .as_ref()
455                        .and_then(|s| s.extra.get("url"))
456                        .and_then(|v| v.as_str())
457                    {
458                        write!(out, " ([details]({url}))").unwrap();
459                    }
460                    writeln!(out).unwrap();
461                    writeln!(out).unwrap();
462                }
463                _ => {
464                    let display = friendly_artifact_name(artifact);
465                    writeln!(out, "**`{display}`**").unwrap();
466                    if let Some(raw) = &change.raw {
467                        writeln!(out).unwrap();
468                        writeln!(out, "```diff").unwrap();
469                        writeln!(out, "{raw}").unwrap();
470                        writeln!(out, "```").unwrap();
471                    }
472                    if let Some(structural) = &change.structural {
473                        writeln!(out).unwrap();
474                        let extra_str = if structural.extra.is_empty() {
475                            String::new()
476                        } else {
477                            let pairs: Vec<String> = structural
478                                .extra
479                                .iter()
480                                .map(|(k, v)| format!("{k}={v}"))
481                                .collect();
482                            format!(" ({})", pairs.join(", "))
483                        };
484                        writeln!(out, "Structural: `{}`{extra_str}", structural.change_type)
485                            .unwrap();
486                    }
487                    writeln!(out).unwrap();
488                }
489            }
490        }
491    }
492}
493
494// ── Kind: agent-coding-session rendering ────────────────────────────
495
496fn is_agent_coding_session(path: &Path) -> bool {
497    path.meta.as_ref().and_then(|m| m.kind.as_deref())
498        == Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION)
499}
500
501/// Render an agent-coding-session path as a flat transcript: the active
502/// (head-ancestry) turns in order, speaker-labeled, with the generic
503/// step/DAG scaffolding dropped. Non-turn event steps are omitted.
504fn render_conversation_transcript(path: &Path, options: &RenderOptions) -> String {
505    let mut out = String::new();
506
507    if options.front_matter {
508        write_path_front_matter(&mut out, path);
509    }
510
511    let title = path
512        .meta
513        .as_ref()
514        .and_then(|m| m.title.as_deref())
515        .unwrap_or(&path.path.id);
516    writeln!(out, "# {title}").unwrap();
517    writeln!(out).unwrap();
518
519    write_transcript_context(&mut out, path);
520    write_conversation_transcript_body(&mut out, path, options);
521    out
522}
523
524/// Minimal header for a transcript: producing harness and base, without the
525/// diffstat/step-count framing the generic path context carries.
526fn write_transcript_context(out: &mut String, path: &Path) {
527    if let Some(src) = path.meta.as_ref().and_then(|m| m.source.as_deref()) {
528        writeln!(out, "**Source:** `{src}`").unwrap();
529    }
530    if let Some(base) = &path.path.base {
531        let branch = base
532            .branch
533            .as_deref()
534            .map(|b| format!(" (`{b}`)"))
535            .unwrap_or_default();
536        writeln!(out, "**Base:** `{}`{branch}", base.uri).unwrap();
537    }
538    writeln!(out).unwrap();
539}
540
541/// The turn-by-turn transcript body, shared by the single-path and graph
542/// layouts. Walks active (head-ancestry) turns in causal order; abandoned
543/// branches are summarized as a count, event steps are skipped.
544fn write_conversation_transcript_body(out: &mut String, path: &Path, options: &RenderOptions) {
545    let active = query::ancestors(&path.steps, &path.path.head);
546    let sorted = topo_sort(&path.steps);
547
548    let mut turns: Vec<&Step> = Vec::new();
549    let mut omitted = 0usize;
550    for &step in &sorted {
551        let is_turn = step.change.values().any(|c| {
552            c.structural.as_ref().map(|s| s.change_type.as_str()) == Some("conversation.append")
553        });
554        if !is_turn {
555            continue; // event-only / non-turn steps
556        }
557        if !active.contains(step.step.id.as_str()) {
558            omitted += 1;
559            continue;
560        }
561        turns.push(step);
562    }
563
564    if options.detail == Detail::Summary {
565        write_compact_transcript(out, &turns);
566    } else {
567        for step in &turns {
568            let append = step
569                .change
570                .values()
571                .find(|c| {
572                    c.structural.as_ref().map(|s| s.change_type.as_str())
573                        == Some("conversation.append")
574                })
575                .expect("turn step has a conversation.append change");
576            let mut files: Vec<(&String, &ArtifactChange)> = step
577                .change
578                .iter()
579                .filter(|(_, c)| {
580                    c.structural.as_ref().map(|s| s.change_type.as_str()) == Some("file.write")
581                })
582                .collect();
583            files.sort_by(|a, b| a.0.cmp(b.0));
584            write_transcript_turn(out, append, &files, options);
585        }
586    }
587
588    if omitted > 0 {
589        writeln!(
590            out,
591            "_{omitted} abandoned turn{} omitted._",
592            if omitted == 1 { "" } else { "s" }
593        )
594        .unwrap();
595        writeln!(out).unwrap();
596    }
597}
598
599/// Compact transcript: prose only. Turns with text render as speaker lines;
600/// runs of text-less turns collapse into a per-tool breakdown line
601/// (`*tools: Read (3), Write (1)*`). Empty turns produce no output.
602fn write_compact_transcript(out: &mut String, turns: &[&Step]) {
603    // Tool-name counts accumulated since the last speaker line, in first-seen
604    // order.
605    let mut pending: Vec<(String, usize)> = Vec::new();
606
607    for step in turns {
608        let Some(append) = step.change.values().find(|c| {
609            c.structural.as_ref().map(|s| s.change_type.as_str()) == Some("conversation.append")
610        }) else {
611            continue;
612        };
613        let Some(s) = append.structural.as_ref() else {
614            continue;
615        };
616        let extra = &s.extra;
617        let text = extra
618            .get("text")
619            .and_then(|v| v.as_str())
620            .unwrap_or("")
621            .trim();
622        let tools = extra.get("tool_uses").and_then(|v| v.as_array());
623
624        if text.is_empty() {
625            accumulate_tools(&mut pending, tools);
626            continue;
627        }
628
629        flush_tool_breakdown(out, &mut pending);
630
631        let role = extra.get("role").and_then(|v| v.as_str()).unwrap_or("");
632        let display = if role == "user" {
633            text.to_string()
634        } else {
635            truncate_str(&text.replace('\n', " "), 200)
636        };
637        writeln!(out, "**{}:** {display}", speaker_label(role)).unwrap();
638        writeln!(out).unwrap();
639
640        accumulate_tools(&mut pending, tools);
641    }
642
643    flush_tool_breakdown(out, &mut pending);
644}
645
646/// Tally each `tool_uses[].name` into `pending` (first-seen order preserved).
647fn accumulate_tools(pending: &mut Vec<(String, usize)>, tools: Option<&Vec<serde_json::Value>>) {
648    let Some(tools) = tools else { return };
649    for tool in tools {
650        let name = tool.get("name").and_then(|v| v.as_str()).unwrap_or("?");
651        match pending.iter_mut().find(|(n, _)| n == name) {
652            Some((_, count)) => *count += 1,
653            None => pending.push((name.to_string(), 1)),
654        }
655    }
656}
657
658/// Emit and clear the accumulated tool breakdown, if any.
659fn flush_tool_breakdown(out: &mut String, pending: &mut Vec<(String, usize)>) {
660    if pending.is_empty() {
661        return;
662    }
663    let parts: Vec<String> = pending.iter().map(|(n, c)| format!("{n} ({c})")).collect();
664    writeln!(out, "*tools: {}*", parts.join(", ")).unwrap();
665    writeln!(out).unwrap();
666    pending.clear();
667}
668
669/// One full-detail transcript turn: speaker line, then reasoning, tool calls
670/// with inputs/results, delegations, file diffs, and the stop/tokens/cwd line.
671fn write_transcript_turn(
672    out: &mut String,
673    append: &ArtifactChange,
674    files: &[(&String, &ArtifactChange)],
675    options: &RenderOptions,
676) {
677    let Some(s) = append.structural.as_ref() else {
678        return;
679    };
680    let extra = &s.extra;
681    let str_field = |k: &str| extra.get(k).and_then(|v| v.as_str()).unwrap_or("");
682    let text = str_field("text").trim();
683    let thinking = str_field("thinking").trim();
684    let tool_uses = extra
685        .get("tool_uses")
686        .and_then(|v| v.as_array())
687        .filter(|t| !t.is_empty());
688    let delegations = extra
689        .get("delegations")
690        .and_then(|v| v.as_array())
691        .filter(|d| !d.is_empty());
692
693    // Skip carrier turns that have nothing to show.
694    if text.is_empty()
695        && thinking.is_empty()
696        && tool_uses.is_none()
697        && delegations.is_none()
698        && files.is_empty()
699    {
700        return;
701    }
702
703    let speaker = speaker_label(str_field("role"));
704    if text.is_empty() {
705        writeln!(out, "**{speaker}:**").unwrap();
706    } else {
707        writeln!(out, "**{speaker}:** {text}").unwrap();
708    }
709    writeln!(out).unwrap();
710
711    if !thinking.is_empty() {
712        writeln!(out, "**Reasoning:**").unwrap();
713        writeln!(out).unwrap();
714        for line in thinking.lines() {
715            writeln!(out, "> {line}").unwrap();
716        }
717        writeln!(out).unwrap();
718    }
719    if let Some(tools) = tool_uses {
720        writeln!(out, "**Tools:**").unwrap();
721        for tool in tools {
722            write_tool_use(out, tool);
723        }
724        writeln!(out).unwrap();
725    }
726    if let Some(delegs) = delegations {
727        writeln!(out, "**Delegations:**").unwrap();
728        for d in delegs {
729            let agent = d.get("agent_id").and_then(|v| v.as_str()).unwrap_or("?");
730            let prompt = truncate_str(
731                &d.get("prompt")
732                    .and_then(|v| v.as_str())
733                    .unwrap_or("")
734                    .replace('\n', " "),
735                80,
736            );
737            writeln!(out, "- `{agent}` \u{2014} {prompt}").unwrap();
738        }
739        writeln!(out).unwrap();
740    }
741    for (artifact, change) in files {
742        write_conversation_file_write(out, artifact, change, options);
743    }
744    let meta_line = conversation_meta_line(extra);
745    if !meta_line.is_empty() {
746        writeln!(out, "*{meta_line}*").unwrap();
747        writeln!(out).unwrap();
748    }
749}
750
751/// Capitalized speaker label for a turn `role`.
752fn speaker_label(role: &str) -> String {
753    match role {
754        "user" => "User".into(),
755        "assistant" => "Assistant".into(),
756        "system" => "System".into(),
757        "" => "?".into(),
758        other => {
759            let mut chars = other.chars();
760            let first = chars.next().unwrap();
761            first.to_uppercase().collect::<String>() + chars.as_str()
762        }
763    }
764}
765
766/// One `tool_uses[]` entry as a list item: name, compact input, and result.
767fn write_tool_use(out: &mut String, tool: &serde_json::Value) {
768    let name = tool.get("name").and_then(|v| v.as_str()).unwrap_or("?");
769    let input = tool
770        .get("input")
771        .map(compact_json)
772        .filter(|s| !s.is_empty() && s != "{}" && s != "null")
773        .map(|s| format!(" `{}`", truncate_str(&s, 80)))
774        .unwrap_or_default();
775    write!(out, "- `{name}`{input}").unwrap();
776    if let Some(result) = tool.get("result") {
777        let is_error = result
778            .get("is_error")
779            .and_then(|v| v.as_bool())
780            .unwrap_or(false);
781        let content = result.get("content").and_then(|v| v.as_str()).unwrap_or("");
782        let marker = if is_error { "error: " } else { "" };
783        let content = truncate_str(&content.replace('\n', " "), 80);
784        if !content.is_empty() {
785            write!(out, " \u{2192} {marker}{content}").unwrap();
786        } else if is_error {
787            write!(out, " \u{2192} error").unwrap();
788        }
789    }
790    writeln!(out).unwrap();
791}
792
793/// Compact one-line metadata: stop reason, token usage, environment.
794fn conversation_meta_line(extra: &HashMap<String, serde_json::Value>) -> String {
795    let mut parts: Vec<String> = Vec::new();
796
797    if let Some(stop) = extra.get("stop_reason").and_then(|v| v.as_str()) {
798        parts.push(format!("stop: {stop}"));
799    }
800
801    if let Some(usage) = extra.get("token_usage") {
802        let n = |k: &str| usage.get(k).and_then(|v| v.as_u64());
803        if let (Some(input), Some(output)) = (n("input_tokens"), n("output_tokens")) {
804            let mut t = format!("tokens: {input} in, {output} out");
805            if let Some(cached) = n("cache_read_tokens") {
806                t.push_str(&format!(", {cached} cached"));
807            }
808            parts.push(t);
809        }
810    }
811
812    if let Some(env) = extra.get("environment")
813        && let Some(wd) = env.get("working_dir").and_then(|v| v.as_str())
814    {
815        let branch = env
816            .get("vcs_branch")
817            .and_then(|v| v.as_str())
818            .map(|b| format!(" ({b})"))
819            .unwrap_or_default();
820        parts.push(format!("cwd: {wd}{branch}"));
821    }
822
823    parts.join(" \u{00b7} ")
824}
825
826/// Render a `file.write` sibling change: the path, operation, and diff.
827fn write_conversation_file_write(
828    out: &mut String,
829    artifact: &str,
830    change: &ArtifactChange,
831    options: &RenderOptions,
832) {
833    let display = friendly_artifact_name(artifact);
834    let op = change
835        .structural
836        .as_ref()
837        .and_then(|s| s.extra.get("operation"))
838        .and_then(|v| v.as_str())
839        .map(|o| format!(" ({o})"))
840        .unwrap_or_default();
841
842    if options.detail == Detail::Summary {
843        writeln!(out, "- wrote `{display}`{op}").unwrap();
844        return;
845    }
846
847    writeln!(out, "**wrote `{display}`**{op}").unwrap();
848    if let Some(raw) = &change.raw {
849        writeln!(out).unwrap();
850        writeln!(out, "```diff").unwrap();
851        writeln!(out, "{raw}").unwrap();
852        writeln!(out, "```").unwrap();
853    }
854    writeln!(out).unwrap();
855}
856
857/// Serialize a JSON value to a compact single-line string.
858fn compact_json(v: &serde_json::Value) -> String {
859    match v {
860        serde_json::Value::String(s) => s.clone(),
861        other => serde_json::to_string(other).unwrap_or_default(),
862    }
863}
864
865fn change_annotation(change: &ArtifactChange) -> String {
866    let mut parts = Vec::new();
867
868    if let Some(raw) = &change.raw {
869        let (add, del) = count_diff_lines(raw);
870        if add > 0 || del > 0 {
871            parts.push(format!("+{add} -{del}"));
872        }
873    }
874
875    if let Some(structural) = &change.structural {
876        parts.push(structural.change_type.clone());
877    }
878
879    if parts.is_empty() {
880        String::new()
881    } else {
882        format!(" ({})", parts.join(", "))
883    }
884}
885
886fn count_diff_lines(raw: &str) -> (usize, usize) {
887    let mut add = 0;
888    let mut del = 0;
889    for line in raw.lines() {
890        if line.starts_with('+') && !line.starts_with("+++") {
891            add += 1;
892        } else if line.starts_with('-') && !line.starts_with("---") {
893            del += 1;
894        }
895    }
896    (add, del)
897}
898
899fn write_path_step(
900    out: &mut String,
901    step: &Step,
902    options: &RenderOptions,
903    is_dead: bool,
904    is_head: bool,
905) {
906    // Header line with status markers
907    let actor_short = actor_display(&step.step.actor);
908    let markers = match (is_dead, is_head) {
909        (true, _) => " [dead end]",
910        (_, true) => " [head]",
911        _ => "",
912    };
913
914    writeln!(
915        out,
916        "### {} \u{2014} {}{}",
917        step.step.id, actor_short, markers
918    )
919    .unwrap();
920    writeln!(out).unwrap();
921
922    writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
923
924    // Parents
925    if !step.step.parents.is_empty() {
926        let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
927        writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
928    }
929
930    writeln!(out).unwrap();
931
932    // Intent
933    if let Some(meta) = &step.meta
934        && let Some(intent) = &meta.intent
935    {
936        writeln!(out, "> {intent}").unwrap();
937        writeln!(out).unwrap();
938    }
939
940    // Refs
941    if let Some(meta) = &step.meta
942        && !meta.refs.is_empty()
943    {
944        for r in &meta.refs {
945            writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
946        }
947        writeln!(out).unwrap();
948    }
949
950    // Changes
951    if !step.change.is_empty() {
952        let mut artifacts: Vec<&String> = step.change.keys().collect();
953        artifacts.sort();
954
955        for artifact in artifacts {
956            let change = &step.change[artifact];
957            write_artifact_change(out, artifact, change, options);
958        }
959        if options.detail == Detail::Summary {
960            writeln!(out).unwrap();
961        }
962    }
963}
964
965fn write_path_context(out: &mut String, path: &Path) {
966    let ctx = source::detect(path);
967
968    if let Some(identity) = &ctx.identity_line {
969        writeln!(out, "{identity}").unwrap();
970    }
971
972    if let Some(base) = &path.path.base {
973        write!(out, "**Base:** `{}`", base.uri).unwrap();
974        if let Some(ref_str) = &base.ref_str {
975            write!(out, " @ `{ref_str}`").unwrap();
976        }
977        if let Some(branch) = &base.branch {
978            write!(out, " (`{branch}`)").unwrap();
979        }
980        writeln!(out).unwrap();
981    }
982
983    // Only show Head if no source-specific identity line (it's noise for PRs)
984    if ctx.identity_line.is_none() {
985        writeln!(out, "**Head:** `{}`", path.path.head).unwrap();
986    }
987
988    if let Some(meta) = &path.meta {
989        if let Some(source) = &meta.source {
990            writeln!(out, "**Source:** `{source}`").unwrap();
991        }
992        if let Some(intent) = &meta.intent {
993            writeln!(out, "**Intent:** {intent}").unwrap();
994        }
995        if !meta.refs.is_empty() {
996            for r in &meta.refs {
997                writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
998            }
999        }
1000    }
1001
1002    // Diffstat — prefer source metadata, fall back to counting diffs
1003    let (total_add, total_del, file_count) = ctx
1004        .diffstat
1005        .unwrap_or_else(|| count_total_diff_lines(&path.steps));
1006
1007    if total_add > 0 || total_del > 0 {
1008        write!(out, "**Changes:** +{total_add} \u{2212}{total_del}").unwrap();
1009        if let Some(f) = file_count {
1010            write!(out, " across {f} files").unwrap();
1011        }
1012        writeln!(out).unwrap();
1013    }
1014
1015    // Summary stats
1016    let artifacts = query::all_artifacts(&path.steps);
1017    let dead_ends = query::dead_ends(&path.steps, &path.path.head);
1018    writeln!(
1019        out,
1020        "**Steps:** {} | **Artifacts:** {} | **Dead ends:** {}",
1021        path.steps.len(),
1022        artifacts.len(),
1023        dead_ends.len()
1024    )
1025    .unwrap();
1026
1027    writeln!(out).unwrap();
1028}
1029
1030fn write_dead_ends_section(out: &mut String, sorted: &[&Step], dead_end_set: &HashSet<&str>) {
1031    writeln!(out, "## Dead Ends").unwrap();
1032    writeln!(out).unwrap();
1033    writeln!(
1034        out,
1035        "These steps were attempted but did not contribute to the final result."
1036    )
1037    .unwrap();
1038    writeln!(out).unwrap();
1039
1040    for step in sorted {
1041        if !dead_end_set.contains(step.step.id.as_str()) {
1042            continue;
1043        }
1044        let intent = step
1045            .meta
1046            .as_ref()
1047            .and_then(|m| m.intent.as_deref())
1048            .unwrap_or("(no intent recorded)");
1049        let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
1050        let parent_str = if parents.is_empty() {
1051            "root".to_string()
1052        } else {
1053            parents.join(", ")
1054        };
1055        writeln!(
1056            out,
1057            "- **{}** ({}) \u{2014} {} | Parent: {}",
1058            step.step.id, step.step.actor, intent, parent_str
1059        )
1060        .unwrap();
1061    }
1062    writeln!(out).unwrap();
1063}
1064
1065fn write_review_section(out: &mut String, sorted: &[&Step]) {
1066    // Collect review decisions and comments
1067    struct ReviewDecision<'a> {
1068        state: &'a str,
1069        actor: &'a str,
1070        body: Option<&'a str>,
1071    }
1072    struct ReviewComment<'a> {
1073        artifact: String,
1074        actor: &'a str,
1075        body: &'a str,
1076        diff_hunk: Option<&'a str>,
1077    }
1078    struct ConversationComment<'a> {
1079        actor: &'a str,
1080        body: &'a str,
1081    }
1082
1083    let mut decisions: Vec<ReviewDecision<'_>> = Vec::new();
1084    let mut comments: Vec<ReviewComment<'_>> = Vec::new();
1085    let mut conversations: Vec<ConversationComment<'_>> = Vec::new();
1086
1087    for step in sorted {
1088        for (artifact, change) in &step.change {
1089            let change_type = change
1090                .structural
1091                .as_ref()
1092                .map(|s| s.change_type.as_str())
1093                .unwrap_or("");
1094            match change_type {
1095                "review.decision" => {
1096                    let state = change
1097                        .structural
1098                        .as_ref()
1099                        .and_then(|s| s.extra.get("state"))
1100                        .and_then(|v| v.as_str())
1101                        .unwrap_or("COMMENTED");
1102                    let body = change.raw.as_deref();
1103                    decisions.push(ReviewDecision {
1104                        state,
1105                        actor: &step.step.actor,
1106                        body,
1107                    });
1108                }
1109                "review.comment" => {
1110                    let body = change
1111                        .structural
1112                        .as_ref()
1113                        .and_then(|s| s.extra.get("body"))
1114                        .and_then(|v| v.as_str())
1115                        .unwrap_or("");
1116                    if !body.is_empty() {
1117                        comments.push(ReviewComment {
1118                            artifact: friendly_artifact_name(artifact),
1119                            actor: &step.step.actor,
1120                            body,
1121                            diff_hunk: change.raw.as_deref(),
1122                        });
1123                    }
1124                }
1125                "review.conversation" => {
1126                    let body = change
1127                        .structural
1128                        .as_ref()
1129                        .and_then(|s| s.extra.get("body"))
1130                        .and_then(|v| v.as_str())
1131                        .unwrap_or("");
1132                    if !body.is_empty() {
1133                        conversations.push(ConversationComment {
1134                            actor: &step.step.actor,
1135                            body,
1136                        });
1137                    }
1138                }
1139                _ => {}
1140            }
1141        }
1142    }
1143
1144    if decisions.is_empty() && comments.is_empty() && conversations.is_empty() {
1145        return;
1146    }
1147
1148    writeln!(out, "## Review").unwrap();
1149    writeln!(out).unwrap();
1150
1151    for d in &decisions {
1152        let marker = review_state_marker(d.state);
1153        let actor_short = d.actor.split(':').next_back().unwrap_or(d.actor);
1154        write!(out, "**{} {}** by {actor_short}", marker, d.state).unwrap();
1155        if let Some(body) = d.body
1156            && !body.is_empty()
1157        {
1158            writeln!(out, ":").unwrap();
1159            for line in body.lines() {
1160                writeln!(out, "> {line}").unwrap();
1161            }
1162        } else {
1163            writeln!(out).unwrap();
1164        }
1165        writeln!(out).unwrap();
1166    }
1167
1168    if !conversations.is_empty() {
1169        writeln!(out, "### Discussion").unwrap();
1170        writeln!(out).unwrap();
1171
1172        for c in &conversations {
1173            let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
1174            writeln!(out, "**{actor_short}:**").unwrap();
1175            for line in c.body.lines() {
1176                writeln!(out, "> {line}").unwrap();
1177            }
1178            writeln!(out).unwrap();
1179        }
1180    }
1181
1182    if !comments.is_empty() {
1183        writeln!(out, "### Inline comments").unwrap();
1184        writeln!(out).unwrap();
1185
1186        for c in &comments {
1187            let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
1188            writeln!(out, "**{}** \u{2014} {actor_short}:", c.artifact).unwrap();
1189            for line in c.body.lines() {
1190                writeln!(out, "> {line}").unwrap();
1191            }
1192            if let Some(hunk) = c.diff_hunk {
1193                writeln!(out).unwrap();
1194                writeln!(out, "```diff").unwrap();
1195                writeln!(out, "{hunk}").unwrap();
1196                writeln!(out, "```").unwrap();
1197            }
1198            writeln!(out).unwrap();
1199        }
1200    }
1201}
1202
1203fn write_actors_section(out: &mut String, actors: &HashMap<String, toolpath::v1::ActorDefinition>) {
1204    writeln!(out, "## Actors").unwrap();
1205    writeln!(out).unwrap();
1206
1207    let mut keys: Vec<&String> = actors.keys().collect();
1208    keys.sort();
1209
1210    for key in keys {
1211        let def = &actors[key];
1212        let name = def.name.as_deref().unwrap_or(key);
1213        write!(out, "- **`{key}`** \u{2014} {name}").unwrap();
1214        if let Some(provider) = &def.provider {
1215            write!(out, " ({provider}").unwrap();
1216            if let Some(model) = &def.model {
1217                write!(out, ", {model}").unwrap();
1218            }
1219            write!(out, ")").unwrap();
1220        }
1221        writeln!(out).unwrap();
1222    }
1223    writeln!(out).unwrap();
1224}
1225
1226// ============================================================================
1227// Front matter
1228// ============================================================================
1229
1230fn write_step_front_matter(out: &mut String, step: &Step) {
1231    writeln!(out, "---").unwrap();
1232    writeln!(out, "type: step").unwrap();
1233    writeln!(out, "id: {}", step.step.id).unwrap();
1234    writeln!(out, "actor: {}", step.step.actor).unwrap();
1235    writeln!(out, "timestamp: {}", step.step.timestamp).unwrap();
1236    if !step.step.parents.is_empty() {
1237        let parents: Vec<&str> = step.step.parents.iter().map(|s| s.as_str()).collect();
1238        writeln!(out, "parents: [{}]", parents.join(", ")).unwrap();
1239    }
1240    let mut artifacts: Vec<&str> = step.change.keys().map(|k| k.as_str()).collect();
1241    artifacts.sort();
1242    if !artifacts.is_empty() {
1243        writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
1244    }
1245    writeln!(out, "---").unwrap();
1246    writeln!(out).unwrap();
1247}
1248
1249fn write_path_front_matter(out: &mut String, path: &Path) {
1250    writeln!(out, "---").unwrap();
1251    writeln!(out, "type: path").unwrap();
1252    writeln!(out, "id: {}", path.path.id).unwrap();
1253    writeln!(out, "head: {}", path.path.head).unwrap();
1254    if let Some(base) = &path.path.base {
1255        writeln!(out, "base: {}", base.uri).unwrap();
1256        if let Some(ref_str) = &base.ref_str {
1257            writeln!(out, "base_ref: {ref_str}").unwrap();
1258        }
1259        if let Some(branch) = &base.branch {
1260            writeln!(out, "base_branch: {branch}").unwrap();
1261        }
1262    }
1263    writeln!(out, "steps: {}", path.steps.len()).unwrap();
1264    let actors = query::all_actors(&path.steps);
1265    let mut actor_list: Vec<&str> = actors.iter().copied().collect();
1266    actor_list.sort();
1267    writeln!(out, "actors: [{}]", actor_list.join(", ")).unwrap();
1268    let mut artifacts: Vec<&str> = query::all_artifacts(&path.steps).into_iter().collect();
1269    artifacts.sort();
1270    writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
1271    let dead_ends = query::dead_ends(&path.steps, &path.path.head);
1272    writeln!(out, "dead_ends: {}", dead_ends.len()).unwrap();
1273    writeln!(out, "---").unwrap();
1274    writeln!(out).unwrap();
1275}
1276
1277fn write_graph_front_matter(out: &mut String, graph: &Graph) {
1278    writeln!(out, "---").unwrap();
1279    writeln!(out, "type: graph").unwrap();
1280    writeln!(out, "id: {}", graph.graph.id).unwrap();
1281    let inline_count = graph
1282        .paths
1283        .iter()
1284        .filter(|p| matches!(p, PathOrRef::Path(_)))
1285        .count();
1286    let ref_count = graph
1287        .paths
1288        .iter()
1289        .filter(|p| matches!(p, PathOrRef::Ref(_)))
1290        .count();
1291    writeln!(out, "paths: {inline_count}").unwrap();
1292    if ref_count > 0 {
1293        writeln!(out, "external_refs: {ref_count}").unwrap();
1294    }
1295    writeln!(out, "---").unwrap();
1296    writeln!(out).unwrap();
1297}
1298
1299// ============================================================================
1300// Utilities
1301// ============================================================================
1302
1303/// Format an actor string for display: `"agent:claude-code/session-abc"` -> `"agent:claude-code/session-abc"`.
1304///
1305/// We keep the full actor string — it's the anchor that lets an LLM
1306/// reference back into the toolpath document.
1307fn actor_display(actor: &str) -> &str {
1308    actor
1309}
1310
1311/// Convert artifact URIs to friendlier display names:
1312/// - `review://path/to/file.rs#L42` -> `path/to/file.rs:42`
1313/// - `ci://checks/test` -> `test`
1314/// - `review://conversation` -> `conversation`
1315/// - `review://decision` -> `decision`
1316fn friendly_artifact_name(artifact: &str) -> String {
1317    if let Some(rest) = artifact.strip_prefix("review://") {
1318        if let Some(pos) = rest.rfind("#L") {
1319            format!("{}:{}", &rest[..pos], &rest[pos + 2..])
1320        } else {
1321            rest.to_string()
1322        }
1323    } else if let Some(rest) = artifact.strip_prefix("ci://checks/") {
1324        rest.to_string()
1325    } else {
1326        artifact.to_string()
1327    }
1328}
1329
1330/// Truncate a string to a maximum number of characters, adding "..." if truncated.
1331fn truncate_str(s: &str, max: usize) -> String {
1332    let s = s.lines().collect::<Vec<_>>().join(" ").trim().to_string();
1333    if s.chars().count() <= max {
1334        s
1335    } else {
1336        let truncated: String = s.chars().take(max).collect();
1337        format!("{truncated}...")
1338    }
1339}
1340
1341/// Text marker for review states.
1342fn review_state_marker(state: &str) -> &'static str {
1343    match state {
1344        "APPROVED" => "[approved]",
1345        "CHANGES_REQUESTED" => "[changes requested]",
1346        "COMMENTED" => "[commented]",
1347        "DISMISSED" => "[dismissed]",
1348        _ => "[review]",
1349    }
1350}
1351
1352/// Count total diff lines across all steps (excluding review/CI artifacts).
1353fn count_total_diff_lines(steps: &[Step]) -> (u64, u64, Option<u64>) {
1354    let mut total_add: u64 = 0;
1355    let mut total_del: u64 = 0;
1356    let mut files: HashSet<&str> = HashSet::new();
1357    for step in steps {
1358        for (artifact, change) in &step.change {
1359            // Skip review and CI artifacts
1360            if artifact.starts_with("review://") || artifact.starts_with("ci://") {
1361                continue;
1362            }
1363            if let Some(raw) = &change.raw {
1364                let (a, d) = count_diff_lines(raw);
1365                total_add += a as u64;
1366                total_del += d as u64;
1367                files.insert(artifact.as_str());
1368            }
1369        }
1370    }
1371    let file_count = if files.is_empty() {
1372        None
1373    } else {
1374        Some(files.len() as u64)
1375    };
1376    (total_add, total_del, file_count)
1377}
1378
1379/// Compute a friendly date range string from step timestamps.
1380/// Returns empty string if no timestamps found.
1381pub(crate) fn friendly_date_range(steps: &[Step]) -> String {
1382    if steps.is_empty() {
1383        return String::new();
1384    }
1385
1386    let mut first: Option<&str> = None;
1387    let mut last: Option<&str> = None;
1388
1389    for step in steps {
1390        let ts = step.step.timestamp.as_str();
1391        if ts.is_empty() || ts.starts_with("1970") {
1392            continue;
1393        }
1394        match first {
1395            None => {
1396                first = Some(ts);
1397                last = Some(ts);
1398            }
1399            Some(f) => {
1400                if ts < f {
1401                    first = Some(ts);
1402                }
1403                if ts > last.unwrap_or("") {
1404                    last = Some(ts);
1405                }
1406            }
1407        }
1408    }
1409
1410    let Some(first) = first else {
1411        return String::new();
1412    };
1413    let last = last.unwrap_or(first);
1414
1415    // Extract YYYY-MM-DD from ISO 8601
1416    let first_date = &first[..first.len().min(10)];
1417    let last_date = &last[..last.len().min(10)];
1418
1419    let Some(first_fmt) = format_date(first_date) else {
1420        return String::new();
1421    };
1422
1423    if first_date == last_date {
1424        return first_fmt;
1425    }
1426
1427    let Some(last_fmt) = format_date(last_date) else {
1428        return first_fmt;
1429    };
1430
1431    // Same month and year
1432    let first_parts: Vec<&str> = first_date.split('-').collect();
1433    let last_parts: Vec<&str> = last_date.split('-').collect();
1434
1435    if first_parts.len() == 3 && last_parts.len() == 3 {
1436        if first_parts[0] == last_parts[0] && first_parts[1] == last_parts[1] {
1437            // Same month: "Feb 26\u{2013}27, 2026"
1438            let month = month_abbrev(first_parts[1]);
1439            let day1 = first_parts[2].trim_start_matches('0');
1440            let day2 = last_parts[2].trim_start_matches('0');
1441            return format!("{month} {day1}\u{2013}{day2}, {}", first_parts[0]);
1442        }
1443        if first_parts[0] == last_parts[0] {
1444            // Same year: "Feb 26 \u{2013} Mar 1, 2026"
1445            let month1 = month_abbrev(first_parts[1]);
1446            let day1 = first_parts[2].trim_start_matches('0');
1447            let month2 = month_abbrev(last_parts[1]);
1448            let day2 = last_parts[2].trim_start_matches('0');
1449            return format!(
1450                "{month1} {day1} \u{2013} {month2} {day2}, {}",
1451                first_parts[0]
1452            );
1453        }
1454    }
1455
1456    // Different years
1457    format!("{first_fmt} \u{2013} {last_fmt}")
1458}
1459
1460/// Format a YYYY-MM-DD date string to "Mon DD, YYYY".
1461fn format_date(date: &str) -> Option<String> {
1462    let parts: Vec<&str> = date.split('-').collect();
1463    if parts.len() != 3 {
1464        return None;
1465    }
1466    let month = month_abbrev(parts[1]);
1467    let day = parts[2].trim_start_matches('0');
1468    Some(format!("{month} {day}, {}", parts[0]))
1469}
1470
1471fn month_abbrev(month: &str) -> &'static str {
1472    match month {
1473        "01" => "Jan",
1474        "02" => "Feb",
1475        "03" => "Mar",
1476        "04" => "Apr",
1477        "05" => "May",
1478        "06" => "Jun",
1479        "07" => "Jul",
1480        "08" => "Aug",
1481        "09" => "Sep",
1482        "10" => "Oct",
1483        "11" => "Nov",
1484        "12" => "Dec",
1485        _ => "???",
1486    }
1487}
1488
1489/// Text marker for CI conclusions.
1490fn ci_conclusion_marker(conclusion: &str) -> &'static str {
1491    match conclusion {
1492        "success" => "[pass]",
1493        "failure" => "[fail]",
1494        "cancelled" | "timed_out" => "[cancelled]",
1495        "skipped" => "[skip]",
1496        "neutral" => "[neutral]",
1497        _ => "[unknown]",
1498    }
1499}
1500
1501/// Format a set of actors as a compact comma-separated string.
1502fn format_actor_list(actors: &HashSet<&str>) -> String {
1503    let mut list: Vec<&str> = actors.iter().copied().collect();
1504    list.sort();
1505    list.iter()
1506        .map(|a| format!("`{a}`"))
1507        .collect::<Vec<_>>()
1508        .join(", ")
1509}
1510
1511/// Topological sort of steps respecting parent edges.
1512/// Falls back to input order for steps without declared parents.
1513fn topo_sort<'a>(steps: &'a [Step]) -> Vec<&'a Step> {
1514    let index: HashMap<&str, &Step> = steps.iter().map(|s| (s.step.id.as_str(), s)).collect();
1515    let ids: Vec<&str> = steps.iter().map(|s| s.step.id.as_str()).collect();
1516    let id_set: HashSet<&str> = ids.iter().copied().collect();
1517
1518    // Kahn's algorithm
1519    let mut in_degree: HashMap<&str, usize> = HashMap::new();
1520    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1521
1522    for &id in &ids {
1523        in_degree.entry(id).or_insert(0);
1524        children.entry(id).or_default();
1525    }
1526
1527    for step in steps {
1528        for parent in &step.step.parents {
1529            if id_set.contains(parent.as_str()) {
1530                *in_degree.entry(step.step.id.as_str()).or_insert(0) += 1;
1531                children
1532                    .entry(parent.as_str())
1533                    .or_default()
1534                    .push(step.step.id.as_str());
1535            }
1536        }
1537    }
1538
1539    // Seed queue with roots, ordered by position in input
1540    let mut queue: Vec<&str> = ids
1541        .iter()
1542        .copied()
1543        .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
1544        .collect();
1545
1546    let mut result: Vec<&'a Step> = Vec::with_capacity(steps.len());
1547
1548    while let Some(id) = queue.first().copied() {
1549        queue.remove(0);
1550        if let Some(step) = index.get(id) {
1551            result.push(step);
1552        }
1553        if let Some(kids) = children.get(id) {
1554            for &child in kids {
1555                let deg = in_degree.get_mut(child).unwrap();
1556                *deg -= 1;
1557                if *deg == 0 {
1558                    queue.push(child);
1559                }
1560            }
1561        }
1562    }
1563
1564    // Append any remaining (cycle or orphan) steps in original order
1565    let placed: HashSet<&str> = result.iter().map(|s| s.step.id.as_str()).collect();
1566    for step in steps {
1567        if !placed.contains(step.step.id.as_str()) {
1568            result.push(step);
1569        }
1570    }
1571
1572    result
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577    use super::*;
1578    use toolpath::v1::{
1579        Base, Graph, GraphIdentity, GraphMeta, Path, PathIdentity, PathMeta, PathOrRef, PathRef,
1580        Ref, Step, StructuralChange,
1581    };
1582
1583    fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step {
1584        let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z")
1585            .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new");
1586        for p in parents {
1587            step = step.with_parent(*p);
1588        }
1589        step
1590    }
1591
1592    fn make_step_with_intent(id: &str, actor: &str, parents: &[&str], intent: &str) -> Step {
1593        make_step(id, actor, parents).with_intent(intent)
1594    }
1595
1596    // ── render_step ──────────────────────────────────────────────────────
1597
1598    #[test]
1599    fn test_render_step_basic() {
1600        let step = make_step("s1", "human:alex", &[]);
1601        let opts = RenderOptions::default();
1602        let md = render_step(&step, &opts);
1603
1604        assert!(md.starts_with("# s1"));
1605        assert!(md.contains("human:alex"));
1606        assert!(md.contains("src/main.rs"));
1607    }
1608
1609    #[test]
1610    fn test_render_step_with_intent() {
1611        let step = make_step_with_intent("s1", "human:alex", &[], "Fix the bug");
1612        let opts = RenderOptions::default();
1613        let md = render_step(&step, &opts);
1614
1615        assert!(md.contains("> Fix the bug"));
1616    }
1617
1618    #[test]
1619    fn test_render_step_with_parents() {
1620        let step = make_step("s2", "agent:claude", &["s1"]);
1621        let opts = RenderOptions::default();
1622        let md = render_step(&step, &opts);
1623
1624        assert!(md.contains("`s1`"));
1625    }
1626
1627    #[test]
1628    fn test_render_step_with_front_matter() {
1629        let step = make_step("s1", "human:alex", &[]);
1630        let opts = RenderOptions {
1631            front_matter: true,
1632            ..Default::default()
1633        };
1634        let md = render_step(&step, &opts);
1635
1636        assert!(md.starts_with("---\n"));
1637        assert!(md.contains("type: step"));
1638        assert!(md.contains("id: s1"));
1639        assert!(md.contains("actor: human:alex"));
1640    }
1641
1642    #[test]
1643    fn test_render_step_full_detail() {
1644        let step = make_step("s1", "human:alex", &[]);
1645        let opts = RenderOptions {
1646            detail: Detail::Full,
1647            ..Default::default()
1648        };
1649        let md = render_step(&step, &opts);
1650
1651        assert!(md.contains("```diff"));
1652        assert!(md.contains("-old"));
1653        assert!(md.contains("+new"));
1654    }
1655
1656    #[test]
1657    fn test_render_step_summary_has_diffstat() {
1658        let step = make_step("s1", "human:alex", &[]);
1659        let opts = RenderOptions::default();
1660        let md = render_step(&step, &opts);
1661
1662        assert!(md.contains("+1 -1"));
1663    }
1664
1665    // ── render_path ──────────────────────────────────────────────────────
1666
1667    #[test]
1668    fn test_render_path_basic() {
1669        let s1 = make_step_with_intent("s1", "human:alex", &[], "Start");
1670        let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Continue");
1671        let path = Path {
1672            path: PathIdentity {
1673                id: "p1".into(),
1674                base: Some(Base::vcs("github:org/repo", "abc123")),
1675                head: "s2".into(),
1676                graph_ref: None,
1677            },
1678            steps: vec![s1, s2],
1679            meta: Some(PathMeta {
1680                title: Some("My PR".into()),
1681                ..Default::default()
1682            }),
1683        };
1684        let opts = RenderOptions::default();
1685        let md = render_path(&path, &opts);
1686
1687        assert!(md.starts_with("# My PR"));
1688        assert!(md.contains("github:org/repo"));
1689        assert!(md.contains("## Timeline"));
1690        assert!(md.contains("### s1"));
1691        assert!(md.contains("### s2"));
1692        assert!(md.contains("[head]"));
1693    }
1694
1695    #[test]
1696    fn test_render_path_with_dead_ends() {
1697        let s1 = make_step("s1", "human:alex", &[]);
1698        let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Good approach");
1699        let s2a = make_step_with_intent("s2a", "agent:claude", &["s1"], "Bad approach (abandoned)");
1700        let s3 = make_step("s3", "human:alex", &["s2"]);
1701        let path = Path {
1702            path: PathIdentity {
1703                id: "p1".into(),
1704                base: None,
1705                head: "s3".into(),
1706                graph_ref: None,
1707            },
1708            steps: vec![s1, s2, s2a, s3],
1709            meta: None,
1710        };
1711        let opts = RenderOptions::default();
1712        let md = render_path(&path, &opts);
1713
1714        assert!(md.contains("[dead end]"));
1715        assert!(md.contains("## Dead Ends"));
1716        assert!(md.contains("Bad approach (abandoned)"));
1717    }
1718
1719    #[test]
1720    fn test_render_path_with_front_matter() {
1721        let s1 = make_step("s1", "human:alex", &[]);
1722        let path = Path {
1723            path: PathIdentity {
1724                id: "p1".into(),
1725                base: None,
1726                head: "s1".into(),
1727                graph_ref: None,
1728            },
1729            steps: vec![s1],
1730            meta: None,
1731        };
1732        let opts = RenderOptions {
1733            front_matter: true,
1734            ..Default::default()
1735        };
1736        let md = render_path(&path, &opts);
1737
1738        assert!(md.starts_with("---\n"));
1739        assert!(md.contains("type: path"));
1740        assert!(md.contains("id: p1"));
1741        assert!(md.contains("steps: 1"));
1742        assert!(md.contains("dead_ends: 0"));
1743    }
1744
1745    #[test]
1746    fn test_render_path_stats_line() {
1747        let s1 = make_step("s1", "human:alex", &[]);
1748        let s2 = make_step("s2", "agent:claude", &["s1"]);
1749        let path = Path {
1750            path: PathIdentity {
1751                id: "p1".into(),
1752                base: None,
1753                head: "s2".into(),
1754                graph_ref: None,
1755            },
1756            steps: vec![s1, s2],
1757            meta: None,
1758        };
1759        let md = render_path(&path, &RenderOptions::default());
1760
1761        assert!(md.contains("**Steps:** 2"));
1762        assert!(md.contains("**Artifacts:** 1"));
1763        assert!(md.contains("**Dead ends:** 0"));
1764    }
1765
1766    #[test]
1767    fn test_render_path_with_refs() {
1768        let s1 = make_step("s1", "human:alex", &[]);
1769        let path = Path {
1770            path: PathIdentity {
1771                id: "p1".into(),
1772                base: None,
1773                head: "s1".into(),
1774                graph_ref: None,
1775            },
1776            steps: vec![s1],
1777            meta: Some(PathMeta {
1778                refs: vec![Ref {
1779                    rel: "fixes".into(),
1780                    href: "issue://github/org/repo/issues/42".into(),
1781                }],
1782                ..Default::default()
1783            }),
1784        };
1785        let md = render_path(&path, &RenderOptions::default());
1786
1787        assert!(md.contains("**fixes:**"));
1788        assert!(md.contains("issue://github/org/repo/issues/42"));
1789    }
1790
1791    // ── render_graph ─────────────────────────────────────────────────────
1792
1793    #[test]
1794    fn test_render_graph_basic() {
1795        let s1 = make_step_with_intent("s1", "human:alex", &[], "First");
1796        let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Second");
1797        let path1 = Path {
1798            path: PathIdentity {
1799                id: "p1".into(),
1800                base: Some(Base::vcs("github:org/repo", "abc")),
1801                head: "s2".into(),
1802                graph_ref: None,
1803            },
1804            steps: vec![s1, s2],
1805            meta: Some(PathMeta {
1806                title: Some("PR #42".into()),
1807                ..Default::default()
1808            }),
1809        };
1810
1811        let s3 = make_step("s3", "human:bob", &[]);
1812        let path2 = Path {
1813            path: PathIdentity {
1814                id: "p2".into(),
1815                base: None,
1816                head: "s3".into(),
1817                graph_ref: None,
1818            },
1819            steps: vec![s3],
1820            meta: Some(PathMeta {
1821                title: Some("PR #43".into()),
1822                ..Default::default()
1823            }),
1824        };
1825
1826        let graph = Graph {
1827            graph: GraphIdentity { id: "g1".into() },
1828            paths: vec![
1829                PathOrRef::Path(Box::new(path1)),
1830                PathOrRef::Path(Box::new(path2)),
1831            ],
1832            meta: Some(GraphMeta {
1833                title: Some("Release v2.0".into()),
1834                ..Default::default()
1835            }),
1836        };
1837        let opts = RenderOptions::default();
1838        let md = render_graph(&graph, &opts);
1839
1840        assert!(md.starts_with("# Release v2.0"));
1841        assert!(md.contains("| PR #42"));
1842        assert!(md.contains("| PR #43"));
1843        assert!(md.contains("## PR #42"));
1844        assert!(md.contains("## PR #43"));
1845    }
1846
1847    #[test]
1848    fn test_render_graph_with_refs() {
1849        let graph = Graph {
1850            graph: GraphIdentity { id: "g1".into() },
1851            paths: vec![PathOrRef::Ref(PathRef {
1852                ref_url: "https://example.com/path.json".into(),
1853            })],
1854            meta: None,
1855        };
1856        let md = render_graph(&graph, &RenderOptions::default());
1857
1858        assert!(md.contains("External references"));
1859        assert!(md.contains("example.com/path.json"));
1860    }
1861
1862    #[test]
1863    fn test_render_graph_with_front_matter() {
1864        let s1 = make_step("s1", "human:alex", &[]);
1865        let path1 = Path {
1866            path: PathIdentity {
1867                id: "p1".into(),
1868                base: None,
1869                head: "s1".into(),
1870                graph_ref: None,
1871            },
1872            steps: vec![s1],
1873            meta: None,
1874        };
1875        let graph = Graph {
1876            graph: GraphIdentity { id: "g1".into() },
1877            paths: vec![
1878                PathOrRef::Path(Box::new(path1)),
1879                PathOrRef::Ref(PathRef {
1880                    ref_url: "https://example.com".into(),
1881                }),
1882            ],
1883            meta: None,
1884        };
1885        let opts = RenderOptions {
1886            front_matter: true,
1887            ..Default::default()
1888        };
1889        let md = render_graph(&graph, &opts);
1890
1891        assert!(md.starts_with("---\n"));
1892        assert!(md.contains("type: graph"));
1893        assert!(md.contains("paths: 1"));
1894        assert!(md.contains("external_refs: 1"));
1895    }
1896
1897    #[test]
1898    fn test_render_graph_with_meta_refs() {
1899        let graph = Graph {
1900            graph: GraphIdentity { id: "g1".into() },
1901            paths: vec![],
1902            meta: Some(GraphMeta {
1903                title: Some("Release".into()),
1904                refs: vec![Ref {
1905                    rel: "milestone".into(),
1906                    href: "issue://github/org/repo/milestone/5".into(),
1907                }],
1908                ..Default::default()
1909            }),
1910        };
1911        let md = render_graph(&graph, &RenderOptions::default());
1912
1913        assert!(md.contains("**milestone:**"));
1914    }
1915
1916    // ── render (dispatch) ────────────────────────────────────────────────
1917
1918    #[test]
1919    fn test_render_single_path_graph_uses_path_layout() {
1920        let s1 = make_step("s1", "human:alex", &[]);
1921        let path = Path {
1922            path: PathIdentity {
1923                id: "p1".into(),
1924                base: None,
1925                head: "s1".into(),
1926                graph_ref: None,
1927            },
1928            steps: vec![s1],
1929            meta: None,
1930        };
1931        let graph = Graph::from_path(path);
1932        let md = render(&graph, &RenderOptions::default());
1933        assert!(md.contains("## Timeline"));
1934    }
1935
1936    #[test]
1937    fn test_render_empty_graph_uses_graph_layout() {
1938        let graph = Graph {
1939            graph: GraphIdentity { id: "g1".into() },
1940            paths: vec![],
1941            meta: Some(GraphMeta {
1942                title: Some("My Graph".into()),
1943                ..Default::default()
1944            }),
1945        };
1946        let md = render(&graph, &RenderOptions::default());
1947        assert!(md.contains("# My Graph"));
1948    }
1949
1950    // ── topo_sort ────────────────────────────────────────────────────────
1951
1952    #[test]
1953    fn test_topo_sort_linear() {
1954        let s1 = make_step("s1", "human:alex", &[]);
1955        let s2 = make_step("s2", "agent:claude", &["s1"]);
1956        let s3 = make_step("s3", "human:alex", &["s2"]);
1957        let steps = vec![s3.clone(), s1.clone(), s2.clone()]; // scrambled input
1958        let sorted = topo_sort(&steps);
1959        let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1960        assert_eq!(ids, vec!["s1", "s2", "s3"]);
1961    }
1962
1963    #[test]
1964    fn test_topo_sort_branching() {
1965        let s1 = make_step("s1", "human:alex", &[]);
1966        let s2a = make_step("s2a", "agent:claude", &["s1"]);
1967        let s2b = make_step("s2b", "agent:claude", &["s1"]);
1968        let s3 = make_step("s3", "human:alex", &["s2a", "s2b"]);
1969        let steps = vec![s1, s2a, s2b, s3];
1970        let sorted = topo_sort(&steps);
1971        let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1972
1973        // s1 must come first, s3 must come last
1974        assert_eq!(ids[0], "s1");
1975        assert_eq!(ids[3], "s3");
1976    }
1977
1978    #[test]
1979    fn test_topo_sort_preserves_input_order_for_roots() {
1980        let s1 = make_step("s1", "human:alex", &[]);
1981        let s2 = make_step("s2", "human:bob", &[]);
1982        let steps = vec![s1, s2];
1983        let sorted = topo_sort(&steps);
1984        let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1985        assert_eq!(ids, vec!["s1", "s2"]);
1986    }
1987
1988    // ── count_diff_lines ─────────────────────────────────────────────────
1989
1990    #[test]
1991    fn test_count_diff_lines() {
1992        let diff = "@@ -1,3 +1,4 @@\n-old1\n-old2\n+new1\n+new2\n+new3\n context";
1993        let (add, del) = count_diff_lines(diff);
1994        assert_eq!(add, 3);
1995        assert_eq!(del, 2);
1996    }
1997
1998    #[test]
1999    fn test_count_diff_lines_ignores_triple_prefix() {
2000        let diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new";
2001        let (add, del) = count_diff_lines(diff);
2002        assert_eq!(add, 1);
2003        assert_eq!(del, 1);
2004    }
2005
2006    #[test]
2007    fn test_count_diff_lines_empty() {
2008        assert_eq!(count_diff_lines(""), (0, 0));
2009    }
2010
2011    // ── structural changes ───────────────────────────────────────────────
2012
2013    #[test]
2014    fn test_render_structural_change_summary() {
2015        let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2016        step.change.insert(
2017            "src/main.rs".into(),
2018            toolpath::v1::ArtifactChange {
2019                raw: None,
2020                structural: Some(StructuralChange {
2021                    change_type: "rename_function".into(),
2022                    extra: Default::default(),
2023                }),
2024            },
2025        );
2026        let md = render_step(&step, &RenderOptions::default());
2027        assert!(md.contains("rename_function"));
2028    }
2029
2030    #[test]
2031    fn test_render_structural_change_full() {
2032        let mut extra = std::collections::HashMap::new();
2033        extra.insert("from".to_string(), serde_json::json!("foo"));
2034        extra.insert("to".to_string(), serde_json::json!("bar"));
2035        let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2036        step.change.insert(
2037            "src/main.rs".into(),
2038            toolpath::v1::ArtifactChange {
2039                raw: None,
2040                structural: Some(StructuralChange {
2041                    change_type: "rename_function".into(),
2042                    extra,
2043                }),
2044            },
2045        );
2046        let md = render_step(
2047            &step,
2048            &RenderOptions {
2049                detail: Detail::Full,
2050                ..Default::default()
2051            },
2052        );
2053        assert!(md.contains("Structural: `rename_function`"));
2054    }
2055
2056    // ── actors section ───────────────────────────────────────────────────
2057
2058    #[test]
2059    fn test_render_path_with_actors() {
2060        let s1 = make_step("s1", "human:alex", &[]);
2061        let mut actors = std::collections::HashMap::new();
2062        actors.insert(
2063            "human:alex".into(),
2064            toolpath::v1::ActorDefinition {
2065                name: Some("Alex".into()),
2066                provider: None,
2067                model: None,
2068                identities: vec![],
2069                keys: vec![],
2070            },
2071        );
2072        actors.insert(
2073            "agent:claude-code".into(),
2074            toolpath::v1::ActorDefinition {
2075                name: Some("Claude Code".into()),
2076                provider: Some("Anthropic".into()),
2077                model: Some("claude-sonnet-4-20250514".into()),
2078                identities: vec![],
2079                keys: vec![],
2080            },
2081        );
2082        let path = Path {
2083            path: PathIdentity {
2084                id: "p1".into(),
2085                base: None,
2086                head: "s1".into(),
2087                graph_ref: None,
2088            },
2089            steps: vec![s1],
2090            meta: Some(PathMeta {
2091                actors: Some(actors),
2092                ..Default::default()
2093            }),
2094        };
2095        let md = render_path(&path, &RenderOptions::default());
2096
2097        assert!(md.contains("## Actors"));
2098        assert!(md.contains("Alex"));
2099        assert!(md.contains("Claude Code"));
2100        assert!(md.contains("Anthropic"));
2101    }
2102
2103    // ── full detail mode ─────────────────────────────────────────────────
2104
2105    #[test]
2106    fn test_render_path_full_detail() {
2107        let s1 = make_step("s1", "human:alex", &[]);
2108        let path = Path {
2109            path: PathIdentity {
2110                id: "p1".into(),
2111                base: None,
2112                head: "s1".into(),
2113                graph_ref: None,
2114            },
2115            steps: vec![s1],
2116            meta: None,
2117        };
2118        let opts = RenderOptions {
2119            detail: Detail::Full,
2120            ..Default::default()
2121        };
2122        let md = render_path(&path, &opts);
2123
2124        assert!(md.contains("```diff"));
2125        assert!(md.contains("-old"));
2126        assert!(md.contains("+new"));
2127    }
2128
2129    // ── edge cases ───────────────────────────────────────────────────────
2130
2131    #[test]
2132    fn test_render_path_no_title() {
2133        let s1 = make_step("s1", "human:alex", &[]);
2134        let path = Path {
2135            path: PathIdentity {
2136                id: "path-42".into(),
2137                base: None,
2138                head: "s1".into(),
2139                graph_ref: None,
2140            },
2141            steps: vec![s1],
2142            meta: None,
2143        };
2144        let md = render_path(&path, &RenderOptions::default());
2145        assert!(md.starts_with("# path-42"));
2146    }
2147
2148    #[test]
2149    fn test_render_step_no_changes() {
2150        let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2151        let md = render_step(&step, &RenderOptions::default());
2152        assert!(md.contains("# s1"));
2153        assert!(!md.contains("## Changes"));
2154    }
2155
2156    #[test]
2157    fn test_render_graph_empty_paths() {
2158        let graph = Graph {
2159            graph: GraphIdentity { id: "g1".into() },
2160            paths: vec![],
2161            meta: None,
2162        };
2163        let md = render_graph(&graph, &RenderOptions::default());
2164        assert!(md.contains("# g1"));
2165    }
2166
2167    // ── review/CI rendering ───────────────────────────────────────────
2168
2169    fn make_review_comment_step(id: &str, actor: &str, artifact: &str, body: &str) -> Step {
2170        let mut extra = std::collections::HashMap::new();
2171        extra.insert("body".to_string(), serde_json::json!(body));
2172        let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z");
2173        step.change.insert(
2174            artifact.to_string(),
2175            ArtifactChange {
2176                raw: Some("@@ -1,3 +1,4 @@\n fn example() {\n+    let x = 42;\n }".to_string()),
2177                structural: Some(StructuralChange {
2178                    change_type: "review.comment".into(),
2179                    extra,
2180                }),
2181            },
2182        );
2183        step
2184    }
2185
2186    fn make_review_decision_step(id: &str, actor: &str, state: &str, body: &str) -> Step {
2187        let mut extra = std::collections::HashMap::new();
2188        extra.insert("state".to_string(), serde_json::json!(state));
2189        let mut step = Step::new(id, actor, "2026-01-29T11:00:00Z");
2190        step.change.insert(
2191            "review://decision".to_string(),
2192            ArtifactChange {
2193                raw: if body.is_empty() {
2194                    None
2195                } else {
2196                    Some(body.to_string())
2197                },
2198                structural: Some(StructuralChange {
2199                    change_type: "review.decision".into(),
2200                    extra,
2201                }),
2202            },
2203        );
2204        step
2205    }
2206
2207    fn make_ci_step(id: &str, name: &str, conclusion: &str) -> Step {
2208        let mut extra = std::collections::HashMap::new();
2209        extra.insert("conclusion".to_string(), serde_json::json!(conclusion));
2210        extra.insert(
2211            "url".to_string(),
2212            serde_json::json!("https://github.com/acme/widgets/actions/runs/123"),
2213        );
2214        let mut step = Step::new(id, "ci:github-actions", "2026-01-29T12:00:00Z");
2215        step.change.insert(
2216            format!("ci://checks/{}", name),
2217            ArtifactChange {
2218                raw: None,
2219                structural: Some(StructuralChange {
2220                    change_type: "ci.run".into(),
2221                    extra,
2222                }),
2223            },
2224        );
2225        step
2226    }
2227
2228    #[test]
2229    fn test_render_review_comment_summary() {
2230        let step = make_review_comment_step(
2231            "s1",
2232            "human:bob",
2233            "review://src/main.rs#L42",
2234            "Consider using a constant here.",
2235        );
2236        let md = render_step(&step, &RenderOptions::default());
2237
2238        // Should show friendly artifact name and body
2239        assert!(md.contains("src/main.rs:42"));
2240        assert!(md.contains("Consider using a constant here."));
2241        // Should NOT show the opaque review:// URI
2242        assert!(!md.contains("review://"));
2243    }
2244
2245    #[test]
2246    fn test_render_review_comment_full() {
2247        let step = make_review_comment_step(
2248            "s1",
2249            "human:bob",
2250            "review://src/main.rs#L42",
2251            "Consider using a constant here.",
2252        );
2253        let md = render_step(
2254            &step,
2255            &RenderOptions {
2256                detail: Detail::Full,
2257                ..Default::default()
2258            },
2259        );
2260
2261        // Should show body as blockquote
2262        assert!(md.contains("> Consider using a constant here."));
2263        // Should show diff_hunk
2264        assert!(md.contains("```diff"));
2265        assert!(md.contains("let x = 42"));
2266    }
2267
2268    #[test]
2269    fn test_render_review_decision_summary() {
2270        let step = make_review_decision_step("s1", "human:dave", "APPROVED", "LGTM!");
2271        let md = render_step(&step, &RenderOptions::default());
2272
2273        assert!(md.contains("[approved]"));
2274        assert!(md.contains("APPROVED"));
2275        assert!(md.contains("LGTM!"));
2276    }
2277
2278    #[test]
2279    fn test_render_ci_summary() {
2280        let step = make_ci_step("s1", "test", "success");
2281        let md = render_step(&step, &RenderOptions::default());
2282
2283        assert!(md.contains("test"));
2284        assert!(md.contains("[pass]"));
2285        assert!(md.contains("success"));
2286        // Should NOT show ci://checks/ prefix
2287        assert!(!md.contains("ci://checks/"));
2288    }
2289
2290    #[test]
2291    fn test_render_ci_failure() {
2292        let step = make_ci_step("s1", "lint", "failure");
2293        let md = render_step(&step, &RenderOptions::default());
2294
2295        assert!(md.contains("lint"));
2296        assert!(md.contains("[fail]"));
2297        assert!(md.contains("failure"));
2298    }
2299
2300    #[test]
2301    fn test_render_ci_full_with_url() {
2302        let step = make_ci_step("s1", "test", "success");
2303        let md = render_step(
2304            &step,
2305            &RenderOptions {
2306                detail: Detail::Full,
2307                ..Default::default()
2308            },
2309        );
2310
2311        assert!(md.contains("details"));
2312        assert!(md.contains("actions/runs/123"));
2313    }
2314
2315    #[test]
2316    fn test_render_review_section() {
2317        let s1 = make_step("s1", "human:alice", &[]);
2318        let s2 = make_review_comment_step(
2319            "s2",
2320            "human:bob",
2321            "review://src/main.rs#L42",
2322            "Consider using a constant.",
2323        );
2324        let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
2325        let mut s2 = s2;
2326        s2 = s2.with_parent("s1");
2327        let mut s3 = s3;
2328        s3 = s3.with_parent("s2");
2329        let path = Path {
2330            path: PathIdentity {
2331                id: "p1".into(),
2332                base: None,
2333                head: "s3".into(),
2334                graph_ref: None,
2335            },
2336            steps: vec![s1, s2, s3],
2337            meta: None,
2338        };
2339        let md = render_path(&path, &RenderOptions::default());
2340
2341        assert!(md.contains("## Review"));
2342        assert!(md.contains("APPROVED"));
2343        assert!(md.contains("Ship it!"));
2344        assert!(md.contains("### Inline comments"));
2345        assert!(md.contains("src/main.rs:42"));
2346        assert!(md.contains("Consider using a constant."));
2347    }
2348
2349    #[test]
2350    fn test_render_no_review_section_without_reviews() {
2351        let s1 = make_step("s1", "human:alex", &[]);
2352        let path = Path {
2353            path: PathIdentity {
2354                id: "p1".into(),
2355                base: None,
2356                head: "s1".into(),
2357                graph_ref: None,
2358            },
2359            steps: vec![s1],
2360            meta: None,
2361        };
2362        let md = render_path(&path, &RenderOptions::default());
2363
2364        assert!(!md.contains("## Review"));
2365    }
2366
2367    // ── PR identity and diffstat ──────────────────────────────────────
2368
2369    #[test]
2370    fn test_render_pr_identity() {
2371        let s1 = make_step("s1", "human:alice", &[]);
2372        let mut extra = std::collections::HashMap::new();
2373        let github = serde_json::json!({
2374            "number": 42,
2375            "author": "alice",
2376            "state": "open",
2377            "draft": false,
2378            "merged": false,
2379            "additions": 150,
2380            "deletions": 30,
2381            "changed_files": 5
2382        });
2383        extra.insert("github".to_string(), github);
2384        let path = Path {
2385            path: PathIdentity {
2386                id: "pr-42".into(),
2387                base: None,
2388                head: "s1".into(),
2389                graph_ref: None,
2390            },
2391            steps: vec![s1],
2392            meta: Some(PathMeta {
2393                title: Some("Add feature".into()),
2394                extra,
2395                ..Default::default()
2396            }),
2397        };
2398        let md = render_path(&path, &RenderOptions::default());
2399
2400        assert!(md.contains("**PR #42**"));
2401        assert!(md.contains("by alice"));
2402        assert!(md.contains("open"));
2403        assert!(md.contains("+150"));
2404        assert!(md.contains("\u{2212}30"));
2405        assert!(md.contains("5 files"));
2406        // Should NOT show opaque head ID
2407        assert!(!md.contains("**Head:**"));
2408    }
2409
2410    #[test]
2411    fn test_render_no_pr_identity_without_github_meta() {
2412        let s1 = make_step("s1", "human:alex", &[]);
2413        let path = Path {
2414            path: PathIdentity {
2415                id: "p1".into(),
2416                base: None,
2417                head: "s1".into(),
2418                graph_ref: None,
2419            },
2420            steps: vec![s1],
2421            meta: None,
2422        };
2423        let md = render_path(&path, &RenderOptions::default());
2424
2425        // Should show Head when no GitHub meta
2426        assert!(md.contains("**Head:**"));
2427        assert!(!md.contains("**PR #"));
2428    }
2429
2430    // ── friendly helpers ──────────────────────────────────────────────
2431
2432    #[test]
2433    fn test_friendly_artifact_name() {
2434        assert_eq!(
2435            friendly_artifact_name("review://src/main.rs#L42"),
2436            "src/main.rs:42"
2437        );
2438        assert_eq!(friendly_artifact_name("ci://checks/test"), "test");
2439        assert_eq!(friendly_artifact_name("review://decision"), "decision");
2440        assert_eq!(friendly_artifact_name("src/main.rs"), "src/main.rs");
2441    }
2442
2443    #[test]
2444    fn test_friendly_date_range_same_day() {
2445        let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2446        let s2 = Step::new("s2", "human:alex", "2026-02-26T14:00:00Z");
2447        assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26, 2026");
2448    }
2449
2450    #[test]
2451    fn test_friendly_date_range_same_month() {
2452        let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2453        let s2 = Step::new("s2", "human:alex", "2026-02-27T14:00:00Z");
2454        assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26\u{2013}27, 2026");
2455    }
2456
2457    #[test]
2458    fn test_friendly_date_range_different_months() {
2459        let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2460        let s2 = Step::new("s2", "human:alex", "2026-03-01T14:00:00Z");
2461        assert_eq!(
2462            friendly_date_range(&[s1, s2]),
2463            "Feb 26 \u{2013} Mar 1, 2026"
2464        );
2465    }
2466
2467    #[test]
2468    fn test_friendly_date_range_empty() {
2469        assert_eq!(friendly_date_range(&[]), "");
2470    }
2471
2472    #[test]
2473    fn test_truncate_str() {
2474        assert_eq!(truncate_str("hello", 10), "hello");
2475        assert_eq!(
2476            truncate_str("hello world this is long", 10),
2477            "hello worl..."
2478        );
2479        assert_eq!(truncate_str("line1\nline2", 20), "line1 line2");
2480    }
2481
2482    // ── PR conversation comments ────────────────────────────────────
2483
2484    fn make_conversation_step(id: &str, actor: &str, body: &str) -> Step {
2485        let mut extra = std::collections::HashMap::new();
2486        extra.insert("body".to_string(), serde_json::json!(body));
2487        let mut step = Step::new(id, actor, "2026-01-29T15:00:00Z");
2488        step.change.insert(
2489            "review://conversation".to_string(),
2490            ArtifactChange {
2491                raw: None,
2492                structural: Some(StructuralChange {
2493                    change_type: "review.conversation".into(),
2494                    extra,
2495                }),
2496            },
2497        );
2498        step
2499    }
2500
2501    #[test]
2502    fn test_render_conversation_summary() {
2503        let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2504        let md = render_step(&step, &RenderOptions::default());
2505
2506        assert!(md.contains("conversation"));
2507        assert!(md.contains("Looks good overall!"));
2508        // Should NOT show review:// prefix
2509        assert!(!md.contains("review://"));
2510    }
2511
2512    #[test]
2513    fn test_render_conversation_full() {
2514        let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2515        let md = render_step(
2516            &step,
2517            &RenderOptions {
2518                detail: Detail::Full,
2519                ..Default::default()
2520            },
2521        );
2522
2523        assert!(md.contains("> Looks good overall!"));
2524        assert!(!md.contains("review://"));
2525    }
2526
2527    #[test]
2528    fn test_review_section_includes_conversations() {
2529        let s1 = make_step("s1", "human:alice", &[]);
2530        let s2 = make_conversation_step("s2", "human:carol", "Looks good overall!");
2531        let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
2532        let s2 = s2.with_parent("s1");
2533        let s3 = s3.with_parent("s2");
2534        let path = Path {
2535            path: PathIdentity {
2536                id: "p1".into(),
2537                base: None,
2538                head: "s3".into(),
2539                graph_ref: None,
2540            },
2541            steps: vec![s1, s2, s3],
2542            meta: None,
2543        };
2544        let md = render_path(&path, &RenderOptions::default());
2545
2546        assert!(md.contains("## Review"));
2547        assert!(md.contains("### Discussion"));
2548        assert!(md.contains("carol"));
2549        assert!(md.contains("Looks good overall!"));
2550        assert!(md.contains("APPROVED"));
2551    }
2552
2553    #[test]
2554    fn test_render_merged_pr() {
2555        let s1 = make_step("s1", "human:alice", &[]);
2556        let mut extra = std::collections::HashMap::new();
2557        let github = serde_json::json!({
2558            "number": 7,
2559            "author": "alice",
2560            "state": "closed",
2561            "draft": false,
2562            "merged": true,
2563            "additions": 42,
2564            "deletions": 10,
2565            "changed_files": 3
2566        });
2567        extra.insert("github".to_string(), github);
2568        let path = Path {
2569            path: PathIdentity {
2570                id: "pr-7".into(),
2571                base: None,
2572                head: "s1".into(),
2573                graph_ref: None,
2574            },
2575            steps: vec![s1],
2576            meta: Some(PathMeta {
2577                title: Some("Fix the thing".into()),
2578                extra,
2579                ..Default::default()
2580            }),
2581        };
2582        let md = render_path(&path, &RenderOptions::default());
2583
2584        assert!(md.contains("**PR #7**"));
2585        assert!(md.contains("by alice"));
2586        // merged overrides state=closed
2587        assert!(md.contains("merged"));
2588        assert!(!md.contains("closed"));
2589    }
2590
2591    #[test]
2592    fn test_catch_all_uses_friendly_name() {
2593        // An artifact with an unknown structural type should still get a friendly name
2594        let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2595        step.change.insert(
2596            "review://some/path#L5".to_string(),
2597            ArtifactChange {
2598                raw: None,
2599                structural: Some(StructuralChange {
2600                    change_type: "review.custom".into(),
2601                    extra: Default::default(),
2602                }),
2603            },
2604        );
2605        let md = render_step(&step, &RenderOptions::default());
2606
2607        // Should use friendly name (some/path:5), not raw review:// URI
2608        assert!(md.contains("some/path:5"));
2609        assert!(!md.contains("review://"));
2610    }
2611
2612    // ── Kind: agent-coding-session rendering ──────────────────────────
2613
2614    fn conv_append(role: &str, extras: &[(&str, serde_json::Value)]) -> ArtifactChange {
2615        let mut extra: std::collections::HashMap<String, serde_json::Value> =
2616            std::collections::HashMap::new();
2617        extra.insert("role".into(), serde_json::json!(role));
2618        for (k, v) in extras {
2619            extra.insert((*k).into(), v.clone());
2620        }
2621        ArtifactChange {
2622            raw: None,
2623            structural: Some(StructuralChange {
2624                change_type: "conversation.append".into(),
2625                extra,
2626            }),
2627        }
2628    }
2629
2630    fn agent_coding_session_path() -> Path {
2631        let key = "claude-code://sess-1";
2632
2633        let mut user = Step::new("u1", "human:user", "2026-01-01T00:00:00Z");
2634        user.change.insert(
2635            key.into(),
2636            conv_append("user", &[("text", serde_json::json!("add a greeting"))]),
2637        );
2638
2639        let mut asst = Step::new("a1", "agent:gpt-5.5", "2026-01-01T00:00:01Z");
2640        asst.step.parents = vec!["u1".into()];
2641        asst.change.insert(
2642            key.into(),
2643            conv_append(
2644                "assistant",
2645                &[
2646                    ("text", serde_json::json!("done")),
2647                    ("thinking", serde_json::json!("I'll edit main.rs")),
2648                    ("stop_reason", serde_json::json!("tool_use")),
2649                    (
2650                        "token_usage",
2651                        serde_json::json!({"input_tokens": 100, "output_tokens": 20, "cache_read_tokens": 50}),
2652                    ),
2653                    (
2654                        "tool_uses",
2655                        serde_json::json!([{
2656                            "id": "c1", "name": "write_file",
2657                            "input": {"file_path": "main.rs"},
2658                            "category": "file_write",
2659                            "result": {"content": "ok", "is_error": false}
2660                        }]),
2661                    ),
2662                ],
2663            ),
2664        );
2665        asst.change.insert(
2666            "main.rs".into(),
2667            ArtifactChange {
2668                raw: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
2669                structural: Some(StructuralChange {
2670                    change_type: "file.write".into(),
2671                    extra: std::collections::HashMap::from([(
2672                        "operation".to_string(),
2673                        serde_json::json!("add"),
2674                    )]),
2675                }),
2676            },
2677        );
2678
2679        let mut ev = Step::new("e1", "tool:claude-code", "2026-01-01T00:00:02Z");
2680        ev.step.parents = vec!["a1".into()];
2681        ev.change.insert(
2682            key.into(),
2683            ArtifactChange {
2684                raw: None,
2685                structural: Some(StructuralChange {
2686                    change_type: "conversation.event".into(),
2687                    extra: std::collections::HashMap::from([(
2688                        "entry_type".to_string(),
2689                        serde_json::json!("attachment"),
2690                    )]),
2691                }),
2692            },
2693        );
2694
2695        Path {
2696            path: PathIdentity {
2697                id: "p1".into(),
2698                base: None,
2699                head: "e1".into(),
2700                graph_ref: None,
2701            },
2702            steps: vec![user, asst, ev],
2703            meta: Some(PathMeta {
2704                kind: Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION.into()),
2705                ..Default::default()
2706            }),
2707        }
2708    }
2709
2710    #[test]
2711    fn truncate_str_is_char_boundary_safe() {
2712        // Truncating at a byte index that lands inside a multibyte char (here
2713        // the em-dash, bytes 198..201 at max=200) must not panic.
2714        let s = format!("{}—tail", "a".repeat(198));
2715        let out = truncate_str(&s, 200);
2716        assert!(out.ends_with("..."));
2717        assert!(out.starts_with(&"a".repeat(198)));
2718    }
2719
2720    #[test]
2721    fn agent_coding_session_renders_flat_transcript() {
2722        let path = agent_coding_session_path();
2723        let md = render_path(
2724            &path,
2725            &RenderOptions {
2726                detail: Detail::Full,
2727                front_matter: false,
2728            },
2729        );
2730
2731        // Speaker-labeled turns with content.
2732        assert!(
2733            md.contains("**User:** add a greeting"),
2734            "user turn missing:\n{md}"
2735        );
2736        assert!(md.contains("**Assistant:** done"), "assistant turn missing");
2737        assert!(
2738            md.contains("**Reasoning:**") && md.contains("I'll edit main.rs"),
2739            "reasoning missing:\n{md}"
2740        );
2741        assert!(
2742            md.contains("**Tools:**") && md.contains("`write_file`") && md.contains("\u{2192} ok"),
2743            "tool call missing:\n{md}"
2744        );
2745        assert!(
2746            md.contains("tokens: 100 in, 20 out, 50 cached"),
2747            "token usage missing:\n{md}"
2748        );
2749        assert!(md.contains("stop: tool_use"), "stop reason missing");
2750        assert!(
2751            md.contains("wrote `main.rs`") && md.contains("(add)"),
2752            "file.write missing:\n{md}"
2753        );
2754
2755        // The generic DAG scaffolding is dropped: no per-step UUID headers,
2756        // no timestamp/parents lines, no dead-end markers, no event noise.
2757        assert!(!md.contains("### a1"), "step header leaked:\n{md}");
2758        assert!(!md.contains("**Timestamp:**"), "timestamp leaked:\n{md}");
2759        assert!(!md.contains("[dead end]"), "dead-end marker leaked:\n{md}");
2760        assert!(!md.contains("_attachment_"), "event noise leaked:\n{md}");
2761        assert!(
2762            !md.contains("## Timeline"),
2763            "timeline heading leaked:\n{md}"
2764        );
2765    }
2766
2767    #[test]
2768    fn agent_coding_session_summary_compacts_tool_calls() {
2769        let path = agent_coding_session_path();
2770        let md = render_path(&path, &RenderOptions::default()); // Summary
2771        assert!(md.contains("**User:** add a greeting"), "user:\n{md}");
2772        assert!(md.contains("**Assistant:** done"), "assistant:\n{md}");
2773        // Tools collapse into a per-name breakdown; no diffs or reasoning.
2774        assert!(
2775            md.contains("*tools: write_file (1)*"),
2776            "tool breakdown:\n{md}"
2777        );
2778        assert!(
2779            !md.contains("```diff"),
2780            "summary should not emit diffs:\n{md}"
2781        );
2782        assert!(
2783            !md.contains("**Reasoning:**"),
2784            "summary omits reasoning:\n{md}"
2785        );
2786    }
2787
2788    #[test]
2789    fn agent_coding_session_summary_drops_empty_turns_and_breaks_down_tools() {
2790        // user → assistant (no text, 2× Read + 1× Bash) → assistant ("ok", 1× Read)
2791        let key = "claude-code://sess-1";
2792        let mut user = Step::new("u1", "human:user", "2026-01-01T00:00:00Z");
2793        user.change.insert(
2794            key.into(),
2795            conv_append("user", &[("text", serde_json::json!("go"))]),
2796        );
2797
2798        let mut work = Step::new("a1", "agent:gpt-5.5", "2026-01-01T00:00:01Z");
2799        work.step.parents = vec!["u1".into()];
2800        work.change.insert(
2801            key.into(),
2802            conv_append(
2803                "assistant",
2804                &[(
2805                    "tool_uses",
2806                    serde_json::json!([
2807                        {"id": "1", "name": "Read", "input": {}, "category": "file_read"},
2808                        {"id": "2", "name": "Read", "input": {}, "category": "file_read"},
2809                        {"id": "3", "name": "Bash", "input": {}, "category": "shell"}
2810                    ]),
2811                )],
2812            ),
2813        );
2814
2815        let mut reply = Step::new("a2", "agent:gpt-5.5", "2026-01-01T00:00:02Z");
2816        reply.step.parents = vec!["a1".into()];
2817        reply.change.insert(
2818            key.into(),
2819            conv_append(
2820                "assistant",
2821                &[
2822                    ("text", serde_json::json!("ok")),
2823                    ("tool_uses", serde_json::json!([{"id": "4", "name": "Read", "input": {}, "category": "file_read"}])),
2824                ],
2825            ),
2826        );
2827
2828        let path = Path {
2829            path: PathIdentity {
2830                id: "p1".into(),
2831                base: None,
2832                head: "a2".into(),
2833                graph_ref: None,
2834            },
2835            steps: vec![user, work, reply],
2836            meta: Some(PathMeta {
2837                kind: Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION.into()),
2838                ..Default::default()
2839            }),
2840        };
2841
2842        let md = render_path(&path, &RenderOptions::default()); // Summary
2843        // Text-less work turn produces no speaker line; only the two real
2844        // messages get one.
2845        assert_eq!(
2846            md.matches("**Assistant:**").count(),
2847            1,
2848            "empty turn rendered:\n{md}"
2849        );
2850        assert!(md.contains("**User:** go"));
2851        assert!(md.contains("**Assistant:** ok"));
2852        // The work turn's tools collapse into a per-name breakdown before "ok".
2853        assert!(
2854            md.contains("*tools: Read (2), Bash (1)*"),
2855            "breakdown:\n{md}"
2856        );
2857    }
2858
2859    #[test]
2860    fn agent_coding_session_omits_abandoned_turns() {
2861        // Add a dead-end assistant turn off the user turn that isn't on the
2862        // head's ancestry; the transcript notes it as omitted, not inline.
2863        let mut path = agent_coding_session_path();
2864        let mut dead = Step::new("d1", "agent:gpt-5.5", "2026-01-01T00:00:03Z");
2865        dead.step.parents = vec!["u1".into()];
2866        dead.change.insert(
2867            "claude-code://sess-1".into(),
2868            conv_append(
2869                "assistant",
2870                &[("text", serde_json::json!("abandoned attempt"))],
2871            ),
2872        );
2873        path.steps.push(dead);
2874
2875        let md = render_path(
2876            &path,
2877            &RenderOptions {
2878                detail: Detail::Full,
2879                front_matter: false,
2880            },
2881        );
2882        assert!(
2883            !md.contains("abandoned attempt"),
2884            "dead-end content shown:\n{md}"
2885        );
2886        assert!(
2887            md.contains("1 abandoned turn omitted"),
2888            "omission note:\n{md}"
2889        );
2890    }
2891
2892    #[test]
2893    fn without_kind_conversation_renders_generically() {
2894        // Same changes, but no meta.kind: the renderer must not apply the
2895        // agent-coding-session transcript treatment.
2896        let mut path = agent_coding_session_path();
2897        path.meta = None;
2898        let md = render_path(
2899            &path,
2900            &RenderOptions {
2901                detail: Detail::Full,
2902                front_matter: false,
2903            },
2904        );
2905        assert!(
2906            !md.contains("**Reasoning:**"),
2907            "kind treatment leaked:\n{md}"
2908        );
2909        assert!(
2910            md.contains("Structural: `conversation.append`"),
2911            "expected the generic structural dump:\n{md}"
2912        );
2913    }
2914}