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, Document, 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 any Toolpath [`Document`] variant to a Markdown string.
38///
39/// # Examples
40///
41/// ```
42/// use toolpath::v1::{Document, Step};
43/// use toolpath_md::{render, RenderOptions};
44///
45/// let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z")
46///     .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new")
47///     .with_intent("Fix greeting");
48/// let doc = Document::Step(step);
49/// let md = render(&doc, &RenderOptions::default());
50/// assert!(md.contains("# s1"));
51/// assert!(md.contains("human:alex"));
52/// ```
53pub fn render(doc: &Document, options: &RenderOptions) -> String {
54    match doc {
55        Document::Graph(g) => render_graph(g, options),
56        Document::Path(p) => render_path(p, options),
57        Document::Step(s) => render_step(s, options),
58    }
59}
60
61/// Render a single [`Step`] as Markdown.
62pub fn render_step(step: &Step, options: &RenderOptions) -> String {
63    let mut out = String::new();
64
65    if options.front_matter {
66        write_step_front_matter(&mut out, step);
67    }
68
69    writeln!(out, "# {}", step.step.id).unwrap();
70    writeln!(out).unwrap();
71    write_step_body(&mut out, step, options, false);
72
73    out
74}
75
76/// Render a [`Path`] as Markdown.
77pub fn render_path(path: &Path, options: &RenderOptions) -> String {
78    let mut out = String::new();
79
80    if options.front_matter {
81        write_path_front_matter(&mut out, path);
82    }
83
84    // Title
85    let title = path
86        .meta
87        .as_ref()
88        .and_then(|m| m.title.as_deref())
89        .unwrap_or(&path.path.id);
90    writeln!(out, "# {title}").unwrap();
91    writeln!(out).unwrap();
92
93    // Context block
94    write_path_context(&mut out, path);
95
96    // Topological sort for readable ordering
97    let sorted = topo_sort(&path.steps);
98    let active = query::ancestors(&path.steps, &path.path.head);
99    let dead_end_set: HashSet<&str> = path
100        .steps
101        .iter()
102        .filter(|s| !active.contains(&s.step.id))
103        .map(|s| s.step.id.as_str())
104        .collect();
105
106    // Timeline
107    writeln!(out, "## Timeline").unwrap();
108    writeln!(out).unwrap();
109
110    for step in &sorted {
111        let is_dead = dead_end_set.contains(step.step.id.as_str());
112        let is_head = step.step.id == path.path.head;
113        write_path_step(&mut out, step, options, is_dead, is_head);
114    }
115
116    // Dead ends section (if any)
117    if !dead_end_set.is_empty() {
118        write_dead_ends_section(&mut out, &sorted, &dead_end_set);
119    }
120
121    // Review summary section
122    write_review_section(&mut out, &sorted);
123
124    // Actors section (if defined in meta)
125    if let Some(meta) = &path.meta
126        && let Some(actors) = &meta.actors
127    {
128        write_actors_section(&mut out, actors);
129    }
130
131    out
132}
133
134/// Render a [`Graph`] as Markdown.
135pub fn render_graph(graph: &Graph, options: &RenderOptions) -> String {
136    let mut out = String::new();
137
138    if options.front_matter {
139        write_graph_front_matter(&mut out, graph);
140    }
141
142    // Title
143    let title = graph
144        .meta
145        .as_ref()
146        .and_then(|m| m.title.as_deref())
147        .unwrap_or(&graph.graph.id);
148    writeln!(out, "# {title}").unwrap();
149    writeln!(out).unwrap();
150
151    // Intent
152    if let Some(meta) = &graph.meta
153        && let Some(intent) = &meta.intent
154    {
155        writeln!(out, "> {intent}").unwrap();
156        writeln!(out).unwrap();
157    }
158
159    // Refs
160    if let Some(meta) = &graph.meta
161        && !meta.refs.is_empty()
162    {
163        for r in &meta.refs {
164            writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
165        }
166        writeln!(out).unwrap();
167    }
168
169    // Summary table
170    let inline_paths: Vec<&Path> = graph
171        .paths
172        .iter()
173        .filter_map(|por| match por {
174            PathOrRef::Path(p) => Some(p.as_ref()),
175            PathOrRef::Ref(_) => None,
176        })
177        .collect();
178
179    let ref_urls: Vec<&str> = graph
180        .paths
181        .iter()
182        .filter_map(|por| match por {
183            PathOrRef::Ref(r) => Some(r.ref_url.as_str()),
184            PathOrRef::Path(_) => None,
185        })
186        .collect();
187
188    if !inline_paths.is_empty() {
189        writeln!(out, "| Path | Steps | Actors | Head |").unwrap();
190        writeln!(out, "|------|-------|--------|------|").unwrap();
191        for path in &inline_paths {
192            let path_title = path
193                .meta
194                .as_ref()
195                .and_then(|m| m.title.as_deref())
196                .unwrap_or(&path.path.id);
197            let step_count = path.steps.len();
198            let actors = query::all_actors(&path.steps);
199            let actors_str = format_actor_list(&actors);
200            writeln!(
201                out,
202                "| {path_title} | {step_count} | {actors_str} | `{}` |",
203                path.path.head
204            )
205            .unwrap();
206        }
207        writeln!(out).unwrap();
208    }
209
210    if !ref_urls.is_empty() {
211        writeln!(out, "**External references:**").unwrap();
212        for url in &ref_urls {
213            writeln!(out, "- `{url}`").unwrap();
214        }
215        writeln!(out).unwrap();
216    }
217
218    // Each path as a section
219    for path in &inline_paths {
220        let path_title = path
221            .meta
222            .as_ref()
223            .and_then(|m| m.title.as_deref())
224            .unwrap_or(&path.path.id);
225        writeln!(out, "---").unwrap();
226        writeln!(out).unwrap();
227        writeln!(out, "## {path_title}").unwrap();
228        writeln!(out).unwrap();
229
230        write_path_context(&mut out, path);
231
232        let sorted = topo_sort(&path.steps);
233        let active = query::ancestors(&path.steps, &path.path.head);
234        let dead_end_set: HashSet<&str> = path
235            .steps
236            .iter()
237            .filter(|s| !active.contains(&s.step.id))
238            .map(|s| s.step.id.as_str())
239            .collect();
240
241        for step in &sorted {
242            let is_dead = dead_end_set.contains(step.step.id.as_str());
243            let is_head = step.step.id == path.path.head;
244            write_path_step(&mut out, step, options, is_dead, is_head);
245        }
246
247        if !dead_end_set.is_empty() {
248            write_dead_ends_section(&mut out, &sorted, &dead_end_set);
249        }
250    }
251
252    // Actors section (if defined in meta)
253    if let Some(meta) = &graph.meta
254        && let Some(actors) = &meta.actors
255    {
256        writeln!(out, "---").unwrap();
257        writeln!(out).unwrap();
258        write_actors_section(&mut out, actors);
259    }
260
261    out
262}
263
264// ============================================================================
265// Internal rendering helpers
266// ============================================================================
267
268fn write_step_body(out: &mut String, step: &Step, options: &RenderOptions, compact: bool) {
269    let heading = if compact { "###" } else { "##" };
270
271    // Actor + timestamp line
272    writeln!(out, "**Actor:** `{}`", step.step.actor).unwrap();
273    writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
274
275    // Parents
276    if !step.step.parents.is_empty() {
277        let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
278        writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
279    }
280
281    writeln!(out).unwrap();
282
283    // Intent
284    if let Some(meta) = &step.meta
285        && let Some(intent) = &meta.intent
286    {
287        writeln!(out, "> {intent}").unwrap();
288        writeln!(out).unwrap();
289    }
290
291    // Refs
292    if let Some(meta) = &step.meta
293        && !meta.refs.is_empty()
294    {
295        for r in &meta.refs {
296            writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
297        }
298        writeln!(out).unwrap();
299    }
300
301    // Changes
302    if !step.change.is_empty() {
303        writeln!(out, "{heading} Changes").unwrap();
304        writeln!(out).unwrap();
305
306        let mut artifacts: Vec<&String> = step.change.keys().collect();
307        artifacts.sort();
308
309        for artifact in artifacts {
310            let change = &step.change[artifact];
311            write_artifact_change(out, artifact, change, options);
312        }
313    }
314}
315
316fn write_artifact_change(
317    out: &mut String,
318    artifact: &str,
319    change: &ArtifactChange,
320    options: &RenderOptions,
321) {
322    let change_type = change
323        .structural
324        .as_ref()
325        .map(|s| s.change_type.as_str())
326        .unwrap_or("");
327
328    match options.detail {
329        Detail::Summary => match change_type {
330            "review.comment" | "review.conversation" => {
331                let display = friendly_artifact_name(artifact);
332                let body = change
333                    .structural
334                    .as_ref()
335                    .and_then(|s| s.extra.get("body"))
336                    .and_then(|v| v.as_str())
337                    .unwrap_or("");
338                let truncated = truncate_str(body, 120);
339                if truncated.is_empty() {
340                    writeln!(out, "- `{display}` (comment)").unwrap();
341                } else {
342                    writeln!(out, "- `{display}` \u{2014} \"{truncated}\"").unwrap();
343                }
344            }
345            "review.decision" => {
346                let state = change
347                    .structural
348                    .as_ref()
349                    .and_then(|s| s.extra.get("state"))
350                    .and_then(|v| v.as_str())
351                    .unwrap_or("COMMENTED");
352                let marker = review_state_marker(state);
353                let body = change.raw.as_deref().unwrap_or("");
354                let truncated = truncate_str(body, 120);
355                if truncated.is_empty() {
356                    writeln!(out, "- {marker} {state}").unwrap();
357                } else {
358                    writeln!(out, "- {marker} {state} \u{2014} \"{truncated}\"").unwrap();
359                }
360            }
361            "ci.run" => {
362                let name = friendly_artifact_name(artifact);
363                let conclusion = change
364                    .structural
365                    .as_ref()
366                    .and_then(|s| s.extra.get("conclusion"))
367                    .and_then(|v| v.as_str())
368                    .unwrap_or("unknown");
369                let marker = ci_conclusion_marker(conclusion);
370                writeln!(out, "- {name} {marker} {conclusion}").unwrap();
371            }
372            _ => {
373                let display = friendly_artifact_name(artifact);
374                let annotation = change_annotation(change);
375                writeln!(out, "- `{display}`{annotation}").unwrap();
376            }
377        },
378        Detail::Full => {
379            match change_type {
380                "review.comment" | "review.conversation" => {
381                    let display = friendly_artifact_name(artifact);
382                    writeln!(out, "**`{display}`**").unwrap();
383                    let body = change
384                        .structural
385                        .as_ref()
386                        .and_then(|s| s.extra.get("body"))
387                        .and_then(|v| v.as_str())
388                        .unwrap_or("");
389                    if !body.is_empty() {
390                        writeln!(out).unwrap();
391                        for line in body.lines() {
392                            writeln!(out, "> {line}").unwrap();
393                        }
394                    }
395                    // Show diff_hunk if present
396                    if let Some(raw) = &change.raw {
397                        writeln!(out).unwrap();
398                        writeln!(out, "```diff").unwrap();
399                        writeln!(out, "{raw}").unwrap();
400                        writeln!(out, "```").unwrap();
401                    }
402                    writeln!(out).unwrap();
403                }
404                "review.decision" => {
405                    let state = change
406                        .structural
407                        .as_ref()
408                        .and_then(|s| s.extra.get("state"))
409                        .and_then(|v| v.as_str())
410                        .unwrap_or("COMMENTED");
411                    let marker = review_state_marker(state);
412                    writeln!(out, "**{marker} {state}**").unwrap();
413                    if let Some(raw) = &change.raw {
414                        writeln!(out).unwrap();
415                        for line in raw.lines() {
416                            writeln!(out, "> {line}").unwrap();
417                        }
418                    }
419                    writeln!(out).unwrap();
420                }
421                "ci.run" => {
422                    let name = friendly_artifact_name(artifact);
423                    let conclusion = change
424                        .structural
425                        .as_ref()
426                        .and_then(|s| s.extra.get("conclusion"))
427                        .and_then(|v| v.as_str())
428                        .unwrap_or("unknown");
429                    let marker = ci_conclusion_marker(conclusion);
430                    write!(out, "**{name}** {marker} {conclusion}").unwrap();
431                    if let Some(url) = change
432                        .structural
433                        .as_ref()
434                        .and_then(|s| s.extra.get("url"))
435                        .and_then(|v| v.as_str())
436                    {
437                        write!(out, " ([details]({url}))").unwrap();
438                    }
439                    writeln!(out).unwrap();
440                    writeln!(out).unwrap();
441                }
442                _ => {
443                    let display = friendly_artifact_name(artifact);
444                    writeln!(out, "**`{display}`**").unwrap();
445                    if let Some(raw) = &change.raw {
446                        writeln!(out).unwrap();
447                        writeln!(out, "```diff").unwrap();
448                        writeln!(out, "{raw}").unwrap();
449                        writeln!(out, "```").unwrap();
450                    }
451                    if let Some(structural) = &change.structural {
452                        writeln!(out).unwrap();
453                        let extra_str = if structural.extra.is_empty() {
454                            String::new()
455                        } else {
456                            let pairs: Vec<String> = structural
457                                .extra
458                                .iter()
459                                .map(|(k, v)| format!("{k}={v}"))
460                                .collect();
461                            format!(" ({})", pairs.join(", "))
462                        };
463                        writeln!(out, "Structural: `{}`{extra_str}", structural.change_type)
464                            .unwrap();
465                    }
466                    writeln!(out).unwrap();
467                }
468            }
469        }
470    }
471}
472
473fn change_annotation(change: &ArtifactChange) -> String {
474    let mut parts = Vec::new();
475
476    if let Some(raw) = &change.raw {
477        let (add, del) = count_diff_lines(raw);
478        if add > 0 || del > 0 {
479            parts.push(format!("+{add} -{del}"));
480        }
481    }
482
483    if let Some(structural) = &change.structural {
484        parts.push(structural.change_type.clone());
485    }
486
487    if parts.is_empty() {
488        String::new()
489    } else {
490        format!(" ({})", parts.join(", "))
491    }
492}
493
494fn count_diff_lines(raw: &str) -> (usize, usize) {
495    let mut add = 0;
496    let mut del = 0;
497    for line in raw.lines() {
498        if line.starts_with('+') && !line.starts_with("+++") {
499            add += 1;
500        } else if line.starts_with('-') && !line.starts_with("---") {
501            del += 1;
502        }
503    }
504    (add, del)
505}
506
507fn write_path_step(
508    out: &mut String,
509    step: &Step,
510    options: &RenderOptions,
511    is_dead: bool,
512    is_head: bool,
513) {
514    // Header line with status markers
515    let actor_short = actor_display(&step.step.actor);
516    let markers = match (is_dead, is_head) {
517        (true, _) => " [dead end]",
518        (_, true) => " [head]",
519        _ => "",
520    };
521
522    writeln!(
523        out,
524        "### {} \u{2014} {}{}",
525        step.step.id, actor_short, markers
526    )
527    .unwrap();
528    writeln!(out).unwrap();
529
530    writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
531
532    // Parents
533    if !step.step.parents.is_empty() {
534        let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
535        writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
536    }
537
538    writeln!(out).unwrap();
539
540    // Intent
541    if let Some(meta) = &step.meta
542        && let Some(intent) = &meta.intent
543    {
544        writeln!(out, "> {intent}").unwrap();
545        writeln!(out).unwrap();
546    }
547
548    // Refs
549    if let Some(meta) = &step.meta
550        && !meta.refs.is_empty()
551    {
552        for r in &meta.refs {
553            writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
554        }
555        writeln!(out).unwrap();
556    }
557
558    // Changes
559    if !step.change.is_empty() {
560        let mut artifacts: Vec<&String> = step.change.keys().collect();
561        artifacts.sort();
562
563        for artifact in artifacts {
564            let change = &step.change[artifact];
565            write_artifact_change(out, artifact, change, options);
566        }
567        if options.detail == Detail::Summary {
568            writeln!(out).unwrap();
569        }
570    }
571}
572
573fn write_path_context(out: &mut String, path: &Path) {
574    let ctx = source::detect(path);
575
576    if let Some(identity) = &ctx.identity_line {
577        writeln!(out, "{identity}").unwrap();
578    }
579
580    if let Some(base) = &path.path.base {
581        write!(out, "**Base:** `{}`", base.uri).unwrap();
582        if let Some(ref_str) = &base.ref_str {
583            write!(out, " @ `{ref_str}`").unwrap();
584        }
585        writeln!(out).unwrap();
586    }
587
588    // Only show Head if no source-specific identity line (it's noise for PRs)
589    if ctx.identity_line.is_none() {
590        writeln!(out, "**Head:** `{}`", path.path.head).unwrap();
591    }
592
593    if let Some(meta) = &path.meta {
594        if let Some(source) = &meta.source {
595            writeln!(out, "**Source:** `{source}`").unwrap();
596        }
597        if let Some(intent) = &meta.intent {
598            writeln!(out, "**Intent:** {intent}").unwrap();
599        }
600        if !meta.refs.is_empty() {
601            for r in &meta.refs {
602                writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
603            }
604        }
605    }
606
607    // Diffstat — prefer source metadata, fall back to counting diffs
608    let (total_add, total_del, file_count) = ctx
609        .diffstat
610        .unwrap_or_else(|| count_total_diff_lines(&path.steps));
611
612    if total_add > 0 || total_del > 0 {
613        write!(out, "**Changes:** +{total_add} \u{2212}{total_del}").unwrap();
614        if let Some(f) = file_count {
615            write!(out, " across {f} files").unwrap();
616        }
617        writeln!(out).unwrap();
618    }
619
620    // Summary stats
621    let artifacts = query::all_artifacts(&path.steps);
622    let dead_ends = query::dead_ends(&path.steps, &path.path.head);
623    writeln!(
624        out,
625        "**Steps:** {} | **Artifacts:** {} | **Dead ends:** {}",
626        path.steps.len(),
627        artifacts.len(),
628        dead_ends.len()
629    )
630    .unwrap();
631
632    writeln!(out).unwrap();
633}
634
635fn write_dead_ends_section(out: &mut String, sorted: &[&Step], dead_end_set: &HashSet<&str>) {
636    writeln!(out, "## Dead Ends").unwrap();
637    writeln!(out).unwrap();
638    writeln!(
639        out,
640        "These steps were attempted but did not contribute to the final result."
641    )
642    .unwrap();
643    writeln!(out).unwrap();
644
645    for step in sorted {
646        if !dead_end_set.contains(step.step.id.as_str()) {
647            continue;
648        }
649        let intent = step
650            .meta
651            .as_ref()
652            .and_then(|m| m.intent.as_deref())
653            .unwrap_or("(no intent recorded)");
654        let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
655        let parent_str = if parents.is_empty() {
656            "root".to_string()
657        } else {
658            parents.join(", ")
659        };
660        writeln!(
661            out,
662            "- **{}** ({}) \u{2014} {} | Parent: {}",
663            step.step.id, step.step.actor, intent, parent_str
664        )
665        .unwrap();
666    }
667    writeln!(out).unwrap();
668}
669
670fn write_review_section(out: &mut String, sorted: &[&Step]) {
671    // Collect review decisions and comments
672    struct ReviewDecision<'a> {
673        state: &'a str,
674        actor: &'a str,
675        body: Option<&'a str>,
676    }
677    struct ReviewComment<'a> {
678        artifact: String,
679        actor: &'a str,
680        body: &'a str,
681        diff_hunk: Option<&'a str>,
682    }
683    struct ConversationComment<'a> {
684        actor: &'a str,
685        body: &'a str,
686    }
687
688    let mut decisions: Vec<ReviewDecision<'_>> = Vec::new();
689    let mut comments: Vec<ReviewComment<'_>> = Vec::new();
690    let mut conversations: Vec<ConversationComment<'_>> = Vec::new();
691
692    for step in sorted {
693        for (artifact, change) in &step.change {
694            let change_type = change
695                .structural
696                .as_ref()
697                .map(|s| s.change_type.as_str())
698                .unwrap_or("");
699            match change_type {
700                "review.decision" => {
701                    let state = change
702                        .structural
703                        .as_ref()
704                        .and_then(|s| s.extra.get("state"))
705                        .and_then(|v| v.as_str())
706                        .unwrap_or("COMMENTED");
707                    let body = change.raw.as_deref();
708                    decisions.push(ReviewDecision {
709                        state,
710                        actor: &step.step.actor,
711                        body,
712                    });
713                }
714                "review.comment" => {
715                    let body = change
716                        .structural
717                        .as_ref()
718                        .and_then(|s| s.extra.get("body"))
719                        .and_then(|v| v.as_str())
720                        .unwrap_or("");
721                    if !body.is_empty() {
722                        comments.push(ReviewComment {
723                            artifact: friendly_artifact_name(artifact),
724                            actor: &step.step.actor,
725                            body,
726                            diff_hunk: change.raw.as_deref(),
727                        });
728                    }
729                }
730                "review.conversation" => {
731                    let body = change
732                        .structural
733                        .as_ref()
734                        .and_then(|s| s.extra.get("body"))
735                        .and_then(|v| v.as_str())
736                        .unwrap_or("");
737                    if !body.is_empty() {
738                        conversations.push(ConversationComment {
739                            actor: &step.step.actor,
740                            body,
741                        });
742                    }
743                }
744                _ => {}
745            }
746        }
747    }
748
749    if decisions.is_empty() && comments.is_empty() && conversations.is_empty() {
750        return;
751    }
752
753    writeln!(out, "## Review").unwrap();
754    writeln!(out).unwrap();
755
756    for d in &decisions {
757        let marker = review_state_marker(d.state);
758        let actor_short = d.actor.split(':').next_back().unwrap_or(d.actor);
759        write!(out, "**{} {}** by {actor_short}", marker, d.state).unwrap();
760        if let Some(body) = d.body
761            && !body.is_empty()
762        {
763            writeln!(out, ":").unwrap();
764            for line in body.lines() {
765                writeln!(out, "> {line}").unwrap();
766            }
767        } else {
768            writeln!(out).unwrap();
769        }
770        writeln!(out).unwrap();
771    }
772
773    if !conversations.is_empty() {
774        writeln!(out, "### Discussion").unwrap();
775        writeln!(out).unwrap();
776
777        for c in &conversations {
778            let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
779            writeln!(out, "**{actor_short}:**").unwrap();
780            for line in c.body.lines() {
781                writeln!(out, "> {line}").unwrap();
782            }
783            writeln!(out).unwrap();
784        }
785    }
786
787    if !comments.is_empty() {
788        writeln!(out, "### Inline comments").unwrap();
789        writeln!(out).unwrap();
790
791        for c in &comments {
792            let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
793            writeln!(out, "**{}** \u{2014} {actor_short}:", c.artifact).unwrap();
794            for line in c.body.lines() {
795                writeln!(out, "> {line}").unwrap();
796            }
797            if let Some(hunk) = c.diff_hunk {
798                writeln!(out).unwrap();
799                writeln!(out, "```diff").unwrap();
800                writeln!(out, "{hunk}").unwrap();
801                writeln!(out, "```").unwrap();
802            }
803            writeln!(out).unwrap();
804        }
805    }
806}
807
808fn write_actors_section(out: &mut String, actors: &HashMap<String, toolpath::v1::ActorDefinition>) {
809    writeln!(out, "## Actors").unwrap();
810    writeln!(out).unwrap();
811
812    let mut keys: Vec<&String> = actors.keys().collect();
813    keys.sort();
814
815    for key in keys {
816        let def = &actors[key];
817        let name = def.name.as_deref().unwrap_or(key);
818        write!(out, "- **`{key}`** \u{2014} {name}").unwrap();
819        if let Some(provider) = &def.provider {
820            write!(out, " ({provider}").unwrap();
821            if let Some(model) = &def.model {
822                write!(out, ", {model}").unwrap();
823            }
824            write!(out, ")").unwrap();
825        }
826        writeln!(out).unwrap();
827    }
828    writeln!(out).unwrap();
829}
830
831// ============================================================================
832// Front matter
833// ============================================================================
834
835fn write_step_front_matter(out: &mut String, step: &Step) {
836    writeln!(out, "---").unwrap();
837    writeln!(out, "type: step").unwrap();
838    writeln!(out, "id: {}", step.step.id).unwrap();
839    writeln!(out, "actor: {}", step.step.actor).unwrap();
840    writeln!(out, "timestamp: {}", step.step.timestamp).unwrap();
841    if !step.step.parents.is_empty() {
842        let parents: Vec<&str> = step.step.parents.iter().map(|s| s.as_str()).collect();
843        writeln!(out, "parents: [{}]", parents.join(", ")).unwrap();
844    }
845    let mut artifacts: Vec<&str> = step.change.keys().map(|k| k.as_str()).collect();
846    artifacts.sort();
847    if !artifacts.is_empty() {
848        writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
849    }
850    writeln!(out, "---").unwrap();
851    writeln!(out).unwrap();
852}
853
854fn write_path_front_matter(out: &mut String, path: &Path) {
855    writeln!(out, "---").unwrap();
856    writeln!(out, "type: path").unwrap();
857    writeln!(out, "id: {}", path.path.id).unwrap();
858    writeln!(out, "head: {}", path.path.head).unwrap();
859    if let Some(base) = &path.path.base {
860        writeln!(out, "base: {}", base.uri).unwrap();
861        if let Some(ref_str) = &base.ref_str {
862            writeln!(out, "base_ref: {ref_str}").unwrap();
863        }
864    }
865    writeln!(out, "steps: {}", path.steps.len()).unwrap();
866    let actors = query::all_actors(&path.steps);
867    let mut actor_list: Vec<&str> = actors.iter().copied().collect();
868    actor_list.sort();
869    writeln!(out, "actors: [{}]", actor_list.join(", ")).unwrap();
870    let mut artifacts: Vec<&str> = query::all_artifacts(&path.steps).into_iter().collect();
871    artifacts.sort();
872    writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
873    let dead_ends = query::dead_ends(&path.steps, &path.path.head);
874    writeln!(out, "dead_ends: {}", dead_ends.len()).unwrap();
875    writeln!(out, "---").unwrap();
876    writeln!(out).unwrap();
877}
878
879fn write_graph_front_matter(out: &mut String, graph: &Graph) {
880    writeln!(out, "---").unwrap();
881    writeln!(out, "type: graph").unwrap();
882    writeln!(out, "id: {}", graph.graph.id).unwrap();
883    let inline_count = graph
884        .paths
885        .iter()
886        .filter(|p| matches!(p, PathOrRef::Path(_)))
887        .count();
888    let ref_count = graph
889        .paths
890        .iter()
891        .filter(|p| matches!(p, PathOrRef::Ref(_)))
892        .count();
893    writeln!(out, "paths: {inline_count}").unwrap();
894    if ref_count > 0 {
895        writeln!(out, "external_refs: {ref_count}").unwrap();
896    }
897    writeln!(out, "---").unwrap();
898    writeln!(out).unwrap();
899}
900
901// ============================================================================
902// Utilities
903// ============================================================================
904
905/// Format an actor string for display: `"agent:claude-code/session-abc"` -> `"agent:claude-code/session-abc"`.
906///
907/// We keep the full actor string — it's the anchor that lets an LLM
908/// reference back into the toolpath document.
909fn actor_display(actor: &str) -> &str {
910    actor
911}
912
913/// Convert artifact URIs to friendlier display names:
914/// - `review://path/to/file.rs#L42` -> `path/to/file.rs:42`
915/// - `ci://checks/test` -> `test`
916/// - `review://conversation` -> `conversation`
917/// - `review://decision` -> `decision`
918fn friendly_artifact_name(artifact: &str) -> String {
919    if let Some(rest) = artifact.strip_prefix("review://") {
920        if let Some(pos) = rest.rfind("#L") {
921            format!("{}:{}", &rest[..pos], &rest[pos + 2..])
922        } else {
923            rest.to_string()
924        }
925    } else if let Some(rest) = artifact.strip_prefix("ci://checks/") {
926        rest.to_string()
927    } else {
928        artifact.to_string()
929    }
930}
931
932/// Truncate a string to a maximum number of characters, adding "..." if truncated.
933fn truncate_str(s: &str, max: usize) -> String {
934    let s = s.lines().collect::<Vec<_>>().join(" ").trim().to_string();
935    if s.len() <= max {
936        s
937    } else {
938        format!("{}...", &s[..max])
939    }
940}
941
942/// Text marker for review states.
943fn review_state_marker(state: &str) -> &'static str {
944    match state {
945        "APPROVED" => "[approved]",
946        "CHANGES_REQUESTED" => "[changes requested]",
947        "COMMENTED" => "[commented]",
948        "DISMISSED" => "[dismissed]",
949        _ => "[review]",
950    }
951}
952
953/// Count total diff lines across all steps (excluding review/CI artifacts).
954fn count_total_diff_lines(steps: &[Step]) -> (u64, u64, Option<u64>) {
955    let mut total_add: u64 = 0;
956    let mut total_del: u64 = 0;
957    let mut files: HashSet<&str> = HashSet::new();
958    for step in steps {
959        for (artifact, change) in &step.change {
960            // Skip review and CI artifacts
961            if artifact.starts_with("review://") || artifact.starts_with("ci://") {
962                continue;
963            }
964            if let Some(raw) = &change.raw {
965                let (a, d) = count_diff_lines(raw);
966                total_add += a as u64;
967                total_del += d as u64;
968                files.insert(artifact.as_str());
969            }
970        }
971    }
972    let file_count = if files.is_empty() {
973        None
974    } else {
975        Some(files.len() as u64)
976    };
977    (total_add, total_del, file_count)
978}
979
980/// Compute a friendly date range string from step timestamps.
981/// Returns empty string if no timestamps found.
982pub(crate) fn friendly_date_range(steps: &[Step]) -> String {
983    if steps.is_empty() {
984        return String::new();
985    }
986
987    let mut first: Option<&str> = None;
988    let mut last: Option<&str> = None;
989
990    for step in steps {
991        let ts = step.step.timestamp.as_str();
992        if ts.is_empty() || ts.starts_with("1970") {
993            continue;
994        }
995        match first {
996            None => {
997                first = Some(ts);
998                last = Some(ts);
999            }
1000            Some(f) => {
1001                if ts < f {
1002                    first = Some(ts);
1003                }
1004                if ts > last.unwrap_or("") {
1005                    last = Some(ts);
1006                }
1007            }
1008        }
1009    }
1010
1011    let Some(first) = first else {
1012        return String::new();
1013    };
1014    let last = last.unwrap_or(first);
1015
1016    // Extract YYYY-MM-DD from ISO 8601
1017    let first_date = &first[..first.len().min(10)];
1018    let last_date = &last[..last.len().min(10)];
1019
1020    let Some(first_fmt) = format_date(first_date) else {
1021        return String::new();
1022    };
1023
1024    if first_date == last_date {
1025        return first_fmt;
1026    }
1027
1028    let Some(last_fmt) = format_date(last_date) else {
1029        return first_fmt;
1030    };
1031
1032    // Same month and year
1033    let first_parts: Vec<&str> = first_date.split('-').collect();
1034    let last_parts: Vec<&str> = last_date.split('-').collect();
1035
1036    if first_parts.len() == 3 && last_parts.len() == 3 {
1037        if first_parts[0] == last_parts[0] && first_parts[1] == last_parts[1] {
1038            // Same month: "Feb 26\u{2013}27, 2026"
1039            let month = month_abbrev(first_parts[1]);
1040            let day1 = first_parts[2].trim_start_matches('0');
1041            let day2 = last_parts[2].trim_start_matches('0');
1042            return format!("{month} {day1}\u{2013}{day2}, {}", first_parts[0]);
1043        }
1044        if first_parts[0] == last_parts[0] {
1045            // Same year: "Feb 26 \u{2013} Mar 1, 2026"
1046            let month1 = month_abbrev(first_parts[1]);
1047            let day1 = first_parts[2].trim_start_matches('0');
1048            let month2 = month_abbrev(last_parts[1]);
1049            let day2 = last_parts[2].trim_start_matches('0');
1050            return format!(
1051                "{month1} {day1} \u{2013} {month2} {day2}, {}",
1052                first_parts[0]
1053            );
1054        }
1055    }
1056
1057    // Different years
1058    format!("{first_fmt} \u{2013} {last_fmt}")
1059}
1060
1061/// Format a YYYY-MM-DD date string to "Mon DD, YYYY".
1062fn format_date(date: &str) -> Option<String> {
1063    let parts: Vec<&str> = date.split('-').collect();
1064    if parts.len() != 3 {
1065        return None;
1066    }
1067    let month = month_abbrev(parts[1]);
1068    let day = parts[2].trim_start_matches('0');
1069    Some(format!("{month} {day}, {}", parts[0]))
1070}
1071
1072fn month_abbrev(month: &str) -> &'static str {
1073    match month {
1074        "01" => "Jan",
1075        "02" => "Feb",
1076        "03" => "Mar",
1077        "04" => "Apr",
1078        "05" => "May",
1079        "06" => "Jun",
1080        "07" => "Jul",
1081        "08" => "Aug",
1082        "09" => "Sep",
1083        "10" => "Oct",
1084        "11" => "Nov",
1085        "12" => "Dec",
1086        _ => "???",
1087    }
1088}
1089
1090/// Text marker for CI conclusions.
1091fn ci_conclusion_marker(conclusion: &str) -> &'static str {
1092    match conclusion {
1093        "success" => "[pass]",
1094        "failure" => "[fail]",
1095        "cancelled" | "timed_out" => "[cancelled]",
1096        "skipped" => "[skip]",
1097        "neutral" => "[neutral]",
1098        _ => "[unknown]",
1099    }
1100}
1101
1102/// Format a set of actors as a compact comma-separated string.
1103fn format_actor_list(actors: &HashSet<&str>) -> String {
1104    let mut list: Vec<&str> = actors.iter().copied().collect();
1105    list.sort();
1106    list.iter()
1107        .map(|a| format!("`{a}`"))
1108        .collect::<Vec<_>>()
1109        .join(", ")
1110}
1111
1112/// Topological sort of steps respecting parent edges.
1113/// Falls back to input order for steps without declared parents.
1114fn topo_sort<'a>(steps: &'a [Step]) -> Vec<&'a Step> {
1115    let index: HashMap<&str, &Step> = steps.iter().map(|s| (s.step.id.as_str(), s)).collect();
1116    let ids: Vec<&str> = steps.iter().map(|s| s.step.id.as_str()).collect();
1117    let id_set: HashSet<&str> = ids.iter().copied().collect();
1118
1119    // Kahn's algorithm
1120    let mut in_degree: HashMap<&str, usize> = HashMap::new();
1121    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1122
1123    for &id in &ids {
1124        in_degree.entry(id).or_insert(0);
1125        children.entry(id).or_default();
1126    }
1127
1128    for step in steps {
1129        for parent in &step.step.parents {
1130            if id_set.contains(parent.as_str()) {
1131                *in_degree.entry(step.step.id.as_str()).or_insert(0) += 1;
1132                children
1133                    .entry(parent.as_str())
1134                    .or_default()
1135                    .push(step.step.id.as_str());
1136            }
1137        }
1138    }
1139
1140    // Seed queue with roots, ordered by position in input
1141    let mut queue: Vec<&str> = ids
1142        .iter()
1143        .copied()
1144        .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
1145        .collect();
1146
1147    let mut result: Vec<&'a Step> = Vec::with_capacity(steps.len());
1148
1149    while let Some(id) = queue.first().copied() {
1150        queue.remove(0);
1151        if let Some(step) = index.get(id) {
1152            result.push(step);
1153        }
1154        if let Some(kids) = children.get(id) {
1155            for &child in kids {
1156                let deg = in_degree.get_mut(child).unwrap();
1157                *deg -= 1;
1158                if *deg == 0 {
1159                    queue.push(child);
1160                }
1161            }
1162        }
1163    }
1164
1165    // Append any remaining (cycle or orphan) steps in original order
1166    let placed: HashSet<&str> = result.iter().map(|s| s.step.id.as_str()).collect();
1167    for step in steps {
1168        if !placed.contains(step.step.id.as_str()) {
1169            result.push(step);
1170        }
1171    }
1172
1173    result
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use super::*;
1179    use toolpath::v1::{
1180        Base, Graph, GraphIdentity, GraphMeta, Path, PathIdentity, PathMeta, PathOrRef, PathRef,
1181        Ref, Step, StructuralChange,
1182    };
1183
1184    fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step {
1185        let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z")
1186            .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new");
1187        for p in parents {
1188            step = step.with_parent(*p);
1189        }
1190        step
1191    }
1192
1193    fn make_step_with_intent(id: &str, actor: &str, parents: &[&str], intent: &str) -> Step {
1194        make_step(id, actor, parents).with_intent(intent)
1195    }
1196
1197    // ── render_step ──────────────────────────────────────────────────────
1198
1199    #[test]
1200    fn test_render_step_basic() {
1201        let step = make_step("s1", "human:alex", &[]);
1202        let opts = RenderOptions::default();
1203        let md = render_step(&step, &opts);
1204
1205        assert!(md.starts_with("# s1"));
1206        assert!(md.contains("human:alex"));
1207        assert!(md.contains("src/main.rs"));
1208    }
1209
1210    #[test]
1211    fn test_render_step_with_intent() {
1212        let step = make_step_with_intent("s1", "human:alex", &[], "Fix the bug");
1213        let opts = RenderOptions::default();
1214        let md = render_step(&step, &opts);
1215
1216        assert!(md.contains("> Fix the bug"));
1217    }
1218
1219    #[test]
1220    fn test_render_step_with_parents() {
1221        let step = make_step("s2", "agent:claude", &["s1"]);
1222        let opts = RenderOptions::default();
1223        let md = render_step(&step, &opts);
1224
1225        assert!(md.contains("`s1`"));
1226    }
1227
1228    #[test]
1229    fn test_render_step_with_front_matter() {
1230        let step = make_step("s1", "human:alex", &[]);
1231        let opts = RenderOptions {
1232            front_matter: true,
1233            ..Default::default()
1234        };
1235        let md = render_step(&step, &opts);
1236
1237        assert!(md.starts_with("---\n"));
1238        assert!(md.contains("type: step"));
1239        assert!(md.contains("id: s1"));
1240        assert!(md.contains("actor: human:alex"));
1241    }
1242
1243    #[test]
1244    fn test_render_step_full_detail() {
1245        let step = make_step("s1", "human:alex", &[]);
1246        let opts = RenderOptions {
1247            detail: Detail::Full,
1248            ..Default::default()
1249        };
1250        let md = render_step(&step, &opts);
1251
1252        assert!(md.contains("```diff"));
1253        assert!(md.contains("-old"));
1254        assert!(md.contains("+new"));
1255    }
1256
1257    #[test]
1258    fn test_render_step_summary_has_diffstat() {
1259        let step = make_step("s1", "human:alex", &[]);
1260        let opts = RenderOptions::default();
1261        let md = render_step(&step, &opts);
1262
1263        assert!(md.contains("+1 -1"));
1264    }
1265
1266    // ── render_path ──────────────────────────────────────────────────────
1267
1268    #[test]
1269    fn test_render_path_basic() {
1270        let s1 = make_step_with_intent("s1", "human:alex", &[], "Start");
1271        let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Continue");
1272        let path = Path {
1273            path: PathIdentity {
1274                id: "p1".into(),
1275                base: Some(Base::vcs("github:org/repo", "abc123")),
1276                head: "s2".into(),
1277            },
1278            steps: vec![s1, s2],
1279            meta: Some(PathMeta {
1280                title: Some("My PR".into()),
1281                ..Default::default()
1282            }),
1283        };
1284        let opts = RenderOptions::default();
1285        let md = render_path(&path, &opts);
1286
1287        assert!(md.starts_with("# My PR"));
1288        assert!(md.contains("github:org/repo"));
1289        assert!(md.contains("## Timeline"));
1290        assert!(md.contains("### s1"));
1291        assert!(md.contains("### s2"));
1292        assert!(md.contains("[head]"));
1293    }
1294
1295    #[test]
1296    fn test_render_path_with_dead_ends() {
1297        let s1 = make_step("s1", "human:alex", &[]);
1298        let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Good approach");
1299        let s2a = make_step_with_intent("s2a", "agent:claude", &["s1"], "Bad approach (abandoned)");
1300        let s3 = make_step("s3", "human:alex", &["s2"]);
1301        let path = Path {
1302            path: PathIdentity {
1303                id: "p1".into(),
1304                base: None,
1305                head: "s3".into(),
1306            },
1307            steps: vec![s1, s2, s2a, s3],
1308            meta: None,
1309        };
1310        let opts = RenderOptions::default();
1311        let md = render_path(&path, &opts);
1312
1313        assert!(md.contains("[dead end]"));
1314        assert!(md.contains("## Dead Ends"));
1315        assert!(md.contains("Bad approach (abandoned)"));
1316    }
1317
1318    #[test]
1319    fn test_render_path_with_front_matter() {
1320        let s1 = make_step("s1", "human:alex", &[]);
1321        let path = Path {
1322            path: PathIdentity {
1323                id: "p1".into(),
1324                base: None,
1325                head: "s1".into(),
1326            },
1327            steps: vec![s1],
1328            meta: None,
1329        };
1330        let opts = RenderOptions {
1331            front_matter: true,
1332            ..Default::default()
1333        };
1334        let md = render_path(&path, &opts);
1335
1336        assert!(md.starts_with("---\n"));
1337        assert!(md.contains("type: path"));
1338        assert!(md.contains("id: p1"));
1339        assert!(md.contains("steps: 1"));
1340        assert!(md.contains("dead_ends: 0"));
1341    }
1342
1343    #[test]
1344    fn test_render_path_stats_line() {
1345        let s1 = make_step("s1", "human:alex", &[]);
1346        let s2 = make_step("s2", "agent:claude", &["s1"]);
1347        let path = Path {
1348            path: PathIdentity {
1349                id: "p1".into(),
1350                base: None,
1351                head: "s2".into(),
1352            },
1353            steps: vec![s1, s2],
1354            meta: None,
1355        };
1356        let md = render_path(&path, &RenderOptions::default());
1357
1358        assert!(md.contains("**Steps:** 2"));
1359        assert!(md.contains("**Artifacts:** 1"));
1360        assert!(md.contains("**Dead ends:** 0"));
1361    }
1362
1363    #[test]
1364    fn test_render_path_with_refs() {
1365        let s1 = make_step("s1", "human:alex", &[]);
1366        let path = Path {
1367            path: PathIdentity {
1368                id: "p1".into(),
1369                base: None,
1370                head: "s1".into(),
1371            },
1372            steps: vec![s1],
1373            meta: Some(PathMeta {
1374                refs: vec![Ref {
1375                    rel: "fixes".into(),
1376                    href: "issue://github/org/repo/issues/42".into(),
1377                }],
1378                ..Default::default()
1379            }),
1380        };
1381        let md = render_path(&path, &RenderOptions::default());
1382
1383        assert!(md.contains("**fixes:**"));
1384        assert!(md.contains("issue://github/org/repo/issues/42"));
1385    }
1386
1387    // ── render_graph ─────────────────────────────────────────────────────
1388
1389    #[test]
1390    fn test_render_graph_basic() {
1391        let s1 = make_step_with_intent("s1", "human:alex", &[], "First");
1392        let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Second");
1393        let path1 = Path {
1394            path: PathIdentity {
1395                id: "p1".into(),
1396                base: Some(Base::vcs("github:org/repo", "abc")),
1397                head: "s2".into(),
1398            },
1399            steps: vec![s1, s2],
1400            meta: Some(PathMeta {
1401                title: Some("PR #42".into()),
1402                ..Default::default()
1403            }),
1404        };
1405
1406        let s3 = make_step("s3", "human:bob", &[]);
1407        let path2 = Path {
1408            path: PathIdentity {
1409                id: "p2".into(),
1410                base: None,
1411                head: "s3".into(),
1412            },
1413            steps: vec![s3],
1414            meta: Some(PathMeta {
1415                title: Some("PR #43".into()),
1416                ..Default::default()
1417            }),
1418        };
1419
1420        let graph = Graph {
1421            graph: GraphIdentity { id: "g1".into() },
1422            paths: vec![
1423                PathOrRef::Path(Box::new(path1)),
1424                PathOrRef::Path(Box::new(path2)),
1425            ],
1426            meta: Some(GraphMeta {
1427                title: Some("Release v2.0".into()),
1428                ..Default::default()
1429            }),
1430        };
1431        let opts = RenderOptions::default();
1432        let md = render_graph(&graph, &opts);
1433
1434        assert!(md.starts_with("# Release v2.0"));
1435        assert!(md.contains("| PR #42"));
1436        assert!(md.contains("| PR #43"));
1437        assert!(md.contains("## PR #42"));
1438        assert!(md.contains("## PR #43"));
1439    }
1440
1441    #[test]
1442    fn test_render_graph_with_refs() {
1443        let graph = Graph {
1444            graph: GraphIdentity { id: "g1".into() },
1445            paths: vec![PathOrRef::Ref(PathRef {
1446                ref_url: "https://example.com/path.json".into(),
1447            })],
1448            meta: None,
1449        };
1450        let md = render_graph(&graph, &RenderOptions::default());
1451
1452        assert!(md.contains("External references"));
1453        assert!(md.contains("example.com/path.json"));
1454    }
1455
1456    #[test]
1457    fn test_render_graph_with_front_matter() {
1458        let s1 = make_step("s1", "human:alex", &[]);
1459        let path1 = Path {
1460            path: PathIdentity {
1461                id: "p1".into(),
1462                base: None,
1463                head: "s1".into(),
1464            },
1465            steps: vec![s1],
1466            meta: None,
1467        };
1468        let graph = Graph {
1469            graph: GraphIdentity { id: "g1".into() },
1470            paths: vec![
1471                PathOrRef::Path(Box::new(path1)),
1472                PathOrRef::Ref(PathRef {
1473                    ref_url: "https://example.com".into(),
1474                }),
1475            ],
1476            meta: None,
1477        };
1478        let opts = RenderOptions {
1479            front_matter: true,
1480            ..Default::default()
1481        };
1482        let md = render_graph(&graph, &opts);
1483
1484        assert!(md.starts_with("---\n"));
1485        assert!(md.contains("type: graph"));
1486        assert!(md.contains("paths: 1"));
1487        assert!(md.contains("external_refs: 1"));
1488    }
1489
1490    #[test]
1491    fn test_render_graph_with_meta_refs() {
1492        let graph = Graph {
1493            graph: GraphIdentity { id: "g1".into() },
1494            paths: vec![],
1495            meta: Some(GraphMeta {
1496                title: Some("Release".into()),
1497                refs: vec![Ref {
1498                    rel: "milestone".into(),
1499                    href: "issue://github/org/repo/milestone/5".into(),
1500                }],
1501                ..Default::default()
1502            }),
1503        };
1504        let md = render_graph(&graph, &RenderOptions::default());
1505
1506        assert!(md.contains("**milestone:**"));
1507    }
1508
1509    // ── render (dispatch) ────────────────────────────────────────────────
1510
1511    #[test]
1512    fn test_render_dispatches_step() {
1513        let step = make_step("s1", "human:alex", &[]);
1514        let doc = Document::Step(step);
1515        let md = render(&doc, &RenderOptions::default());
1516        assert!(md.contains("# s1"));
1517    }
1518
1519    #[test]
1520    fn test_render_dispatches_path() {
1521        let s1 = make_step("s1", "human:alex", &[]);
1522        let path = Path {
1523            path: PathIdentity {
1524                id: "p1".into(),
1525                base: None,
1526                head: "s1".into(),
1527            },
1528            steps: vec![s1],
1529            meta: None,
1530        };
1531        let doc = Document::Path(path);
1532        let md = render(&doc, &RenderOptions::default());
1533        assert!(md.contains("## Timeline"));
1534    }
1535
1536    #[test]
1537    fn test_render_dispatches_graph() {
1538        let graph = Graph {
1539            graph: GraphIdentity { id: "g1".into() },
1540            paths: vec![],
1541            meta: Some(GraphMeta {
1542                title: Some("My Graph".into()),
1543                ..Default::default()
1544            }),
1545        };
1546        let doc = Document::Graph(graph);
1547        let md = render(&doc, &RenderOptions::default());
1548        assert!(md.contains("# My Graph"));
1549    }
1550
1551    // ── topo_sort ────────────────────────────────────────────────────────
1552
1553    #[test]
1554    fn test_topo_sort_linear() {
1555        let s1 = make_step("s1", "human:alex", &[]);
1556        let s2 = make_step("s2", "agent:claude", &["s1"]);
1557        let s3 = make_step("s3", "human:alex", &["s2"]);
1558        let steps = vec![s3.clone(), s1.clone(), s2.clone()]; // scrambled input
1559        let sorted = topo_sort(&steps);
1560        let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1561        assert_eq!(ids, vec!["s1", "s2", "s3"]);
1562    }
1563
1564    #[test]
1565    fn test_topo_sort_branching() {
1566        let s1 = make_step("s1", "human:alex", &[]);
1567        let s2a = make_step("s2a", "agent:claude", &["s1"]);
1568        let s2b = make_step("s2b", "agent:claude", &["s1"]);
1569        let s3 = make_step("s3", "human:alex", &["s2a", "s2b"]);
1570        let steps = vec![s1, s2a, s2b, s3];
1571        let sorted = topo_sort(&steps);
1572        let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1573
1574        // s1 must come first, s3 must come last
1575        assert_eq!(ids[0], "s1");
1576        assert_eq!(ids[3], "s3");
1577    }
1578
1579    #[test]
1580    fn test_topo_sort_preserves_input_order_for_roots() {
1581        let s1 = make_step("s1", "human:alex", &[]);
1582        let s2 = make_step("s2", "human:bob", &[]);
1583        let steps = vec![s1, s2];
1584        let sorted = topo_sort(&steps);
1585        let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1586        assert_eq!(ids, vec!["s1", "s2"]);
1587    }
1588
1589    // ── count_diff_lines ─────────────────────────────────────────────────
1590
1591    #[test]
1592    fn test_count_diff_lines() {
1593        let diff = "@@ -1,3 +1,4 @@\n-old1\n-old2\n+new1\n+new2\n+new3\n context";
1594        let (add, del) = count_diff_lines(diff);
1595        assert_eq!(add, 3);
1596        assert_eq!(del, 2);
1597    }
1598
1599    #[test]
1600    fn test_count_diff_lines_ignores_triple_prefix() {
1601        let diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new";
1602        let (add, del) = count_diff_lines(diff);
1603        assert_eq!(add, 1);
1604        assert_eq!(del, 1);
1605    }
1606
1607    #[test]
1608    fn test_count_diff_lines_empty() {
1609        assert_eq!(count_diff_lines(""), (0, 0));
1610    }
1611
1612    // ── structural changes ───────────────────────────────────────────────
1613
1614    #[test]
1615    fn test_render_structural_change_summary() {
1616        let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
1617        step.change.insert(
1618            "src/main.rs".into(),
1619            toolpath::v1::ArtifactChange {
1620                raw: None,
1621                structural: Some(StructuralChange {
1622                    change_type: "rename_function".into(),
1623                    extra: Default::default(),
1624                }),
1625            },
1626        );
1627        let md = render_step(&step, &RenderOptions::default());
1628        assert!(md.contains("rename_function"));
1629    }
1630
1631    #[test]
1632    fn test_render_structural_change_full() {
1633        let mut extra = std::collections::HashMap::new();
1634        extra.insert("from".to_string(), serde_json::json!("foo"));
1635        extra.insert("to".to_string(), serde_json::json!("bar"));
1636        let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
1637        step.change.insert(
1638            "src/main.rs".into(),
1639            toolpath::v1::ArtifactChange {
1640                raw: None,
1641                structural: Some(StructuralChange {
1642                    change_type: "rename_function".into(),
1643                    extra,
1644                }),
1645            },
1646        );
1647        let md = render_step(
1648            &step,
1649            &RenderOptions {
1650                detail: Detail::Full,
1651                ..Default::default()
1652            },
1653        );
1654        assert!(md.contains("Structural: `rename_function`"));
1655    }
1656
1657    // ── actors section ───────────────────────────────────────────────────
1658
1659    #[test]
1660    fn test_render_path_with_actors() {
1661        let s1 = make_step("s1", "human:alex", &[]);
1662        let mut actors = std::collections::HashMap::new();
1663        actors.insert(
1664            "human:alex".into(),
1665            toolpath::v1::ActorDefinition {
1666                name: Some("Alex".into()),
1667                provider: None,
1668                model: None,
1669                identities: vec![],
1670                keys: vec![],
1671            },
1672        );
1673        actors.insert(
1674            "agent:claude-code".into(),
1675            toolpath::v1::ActorDefinition {
1676                name: Some("Claude Code".into()),
1677                provider: Some("Anthropic".into()),
1678                model: Some("claude-sonnet-4-20250514".into()),
1679                identities: vec![],
1680                keys: vec![],
1681            },
1682        );
1683        let path = Path {
1684            path: PathIdentity {
1685                id: "p1".into(),
1686                base: None,
1687                head: "s1".into(),
1688            },
1689            steps: vec![s1],
1690            meta: Some(PathMeta {
1691                actors: Some(actors),
1692                ..Default::default()
1693            }),
1694        };
1695        let md = render_path(&path, &RenderOptions::default());
1696
1697        assert!(md.contains("## Actors"));
1698        assert!(md.contains("Alex"));
1699        assert!(md.contains("Claude Code"));
1700        assert!(md.contains("Anthropic"));
1701    }
1702
1703    // ── full detail mode ─────────────────────────────────────────────────
1704
1705    #[test]
1706    fn test_render_path_full_detail() {
1707        let s1 = make_step("s1", "human:alex", &[]);
1708        let path = Path {
1709            path: PathIdentity {
1710                id: "p1".into(),
1711                base: None,
1712                head: "s1".into(),
1713            },
1714            steps: vec![s1],
1715            meta: None,
1716        };
1717        let opts = RenderOptions {
1718            detail: Detail::Full,
1719            ..Default::default()
1720        };
1721        let md = render_path(&path, &opts);
1722
1723        assert!(md.contains("```diff"));
1724        assert!(md.contains("-old"));
1725        assert!(md.contains("+new"));
1726    }
1727
1728    // ── edge cases ───────────────────────────────────────────────────────
1729
1730    #[test]
1731    fn test_render_path_no_title() {
1732        let s1 = make_step("s1", "human:alex", &[]);
1733        let path = Path {
1734            path: PathIdentity {
1735                id: "path-42".into(),
1736                base: None,
1737                head: "s1".into(),
1738            },
1739            steps: vec![s1],
1740            meta: None,
1741        };
1742        let md = render_path(&path, &RenderOptions::default());
1743        assert!(md.starts_with("# path-42"));
1744    }
1745
1746    #[test]
1747    fn test_render_step_no_changes() {
1748        let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
1749        let md = render_step(&step, &RenderOptions::default());
1750        assert!(md.contains("# s1"));
1751        assert!(!md.contains("## Changes"));
1752    }
1753
1754    #[test]
1755    fn test_render_graph_empty_paths() {
1756        let graph = Graph {
1757            graph: GraphIdentity { id: "g1".into() },
1758            paths: vec![],
1759            meta: None,
1760        };
1761        let md = render_graph(&graph, &RenderOptions::default());
1762        assert!(md.contains("# g1"));
1763    }
1764
1765    // ── review/CI rendering ───────────────────────────────────────────
1766
1767    fn make_review_comment_step(id: &str, actor: &str, artifact: &str, body: &str) -> Step {
1768        let mut extra = std::collections::HashMap::new();
1769        extra.insert("body".to_string(), serde_json::json!(body));
1770        let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z");
1771        step.change.insert(
1772            artifact.to_string(),
1773            ArtifactChange {
1774                raw: Some("@@ -1,3 +1,4 @@\n fn example() {\n+    let x = 42;\n }".to_string()),
1775                structural: Some(StructuralChange {
1776                    change_type: "review.comment".into(),
1777                    extra,
1778                }),
1779            },
1780        );
1781        step
1782    }
1783
1784    fn make_review_decision_step(id: &str, actor: &str, state: &str, body: &str) -> Step {
1785        let mut extra = std::collections::HashMap::new();
1786        extra.insert("state".to_string(), serde_json::json!(state));
1787        let mut step = Step::new(id, actor, "2026-01-29T11:00:00Z");
1788        step.change.insert(
1789            "review://decision".to_string(),
1790            ArtifactChange {
1791                raw: if body.is_empty() {
1792                    None
1793                } else {
1794                    Some(body.to_string())
1795                },
1796                structural: Some(StructuralChange {
1797                    change_type: "review.decision".into(),
1798                    extra,
1799                }),
1800            },
1801        );
1802        step
1803    }
1804
1805    fn make_ci_step(id: &str, name: &str, conclusion: &str) -> Step {
1806        let mut extra = std::collections::HashMap::new();
1807        extra.insert("conclusion".to_string(), serde_json::json!(conclusion));
1808        extra.insert(
1809            "url".to_string(),
1810            serde_json::json!("https://github.com/acme/widgets/actions/runs/123"),
1811        );
1812        let mut step = Step::new(id, "ci:github-actions", "2026-01-29T12:00:00Z");
1813        step.change.insert(
1814            format!("ci://checks/{}", name),
1815            ArtifactChange {
1816                raw: None,
1817                structural: Some(StructuralChange {
1818                    change_type: "ci.run".into(),
1819                    extra,
1820                }),
1821            },
1822        );
1823        step
1824    }
1825
1826    #[test]
1827    fn test_render_review_comment_summary() {
1828        let step = make_review_comment_step(
1829            "s1",
1830            "human:bob",
1831            "review://src/main.rs#L42",
1832            "Consider using a constant here.",
1833        );
1834        let md = render_step(&step, &RenderOptions::default());
1835
1836        // Should show friendly artifact name and body
1837        assert!(md.contains("src/main.rs:42"));
1838        assert!(md.contains("Consider using a constant here."));
1839        // Should NOT show the opaque review:// URI
1840        assert!(!md.contains("review://"));
1841    }
1842
1843    #[test]
1844    fn test_render_review_comment_full() {
1845        let step = make_review_comment_step(
1846            "s1",
1847            "human:bob",
1848            "review://src/main.rs#L42",
1849            "Consider using a constant here.",
1850        );
1851        let md = render_step(
1852            &step,
1853            &RenderOptions {
1854                detail: Detail::Full,
1855                ..Default::default()
1856            },
1857        );
1858
1859        // Should show body as blockquote
1860        assert!(md.contains("> Consider using a constant here."));
1861        // Should show diff_hunk
1862        assert!(md.contains("```diff"));
1863        assert!(md.contains("let x = 42"));
1864    }
1865
1866    #[test]
1867    fn test_render_review_decision_summary() {
1868        let step = make_review_decision_step("s1", "human:dave", "APPROVED", "LGTM!");
1869        let md = render_step(&step, &RenderOptions::default());
1870
1871        assert!(md.contains("[approved]"));
1872        assert!(md.contains("APPROVED"));
1873        assert!(md.contains("LGTM!"));
1874    }
1875
1876    #[test]
1877    fn test_render_ci_summary() {
1878        let step = make_ci_step("s1", "test", "success");
1879        let md = render_step(&step, &RenderOptions::default());
1880
1881        assert!(md.contains("test"));
1882        assert!(md.contains("[pass]"));
1883        assert!(md.contains("success"));
1884        // Should NOT show ci://checks/ prefix
1885        assert!(!md.contains("ci://checks/"));
1886    }
1887
1888    #[test]
1889    fn test_render_ci_failure() {
1890        let step = make_ci_step("s1", "lint", "failure");
1891        let md = render_step(&step, &RenderOptions::default());
1892
1893        assert!(md.contains("lint"));
1894        assert!(md.contains("[fail]"));
1895        assert!(md.contains("failure"));
1896    }
1897
1898    #[test]
1899    fn test_render_ci_full_with_url() {
1900        let step = make_ci_step("s1", "test", "success");
1901        let md = render_step(
1902            &step,
1903            &RenderOptions {
1904                detail: Detail::Full,
1905                ..Default::default()
1906            },
1907        );
1908
1909        assert!(md.contains("details"));
1910        assert!(md.contains("actions/runs/123"));
1911    }
1912
1913    #[test]
1914    fn test_render_review_section() {
1915        let s1 = make_step("s1", "human:alice", &[]);
1916        let s2 = make_review_comment_step(
1917            "s2",
1918            "human:bob",
1919            "review://src/main.rs#L42",
1920            "Consider using a constant.",
1921        );
1922        let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
1923        let mut s2 = s2;
1924        s2 = s2.with_parent("s1");
1925        let mut s3 = s3;
1926        s3 = s3.with_parent("s2");
1927        let path = Path {
1928            path: PathIdentity {
1929                id: "p1".into(),
1930                base: None,
1931                head: "s3".into(),
1932            },
1933            steps: vec![s1, s2, s3],
1934            meta: None,
1935        };
1936        let md = render_path(&path, &RenderOptions::default());
1937
1938        assert!(md.contains("## Review"));
1939        assert!(md.contains("APPROVED"));
1940        assert!(md.contains("Ship it!"));
1941        assert!(md.contains("### Inline comments"));
1942        assert!(md.contains("src/main.rs:42"));
1943        assert!(md.contains("Consider using a constant."));
1944    }
1945
1946    #[test]
1947    fn test_render_no_review_section_without_reviews() {
1948        let s1 = make_step("s1", "human:alex", &[]);
1949        let path = Path {
1950            path: PathIdentity {
1951                id: "p1".into(),
1952                base: None,
1953                head: "s1".into(),
1954            },
1955            steps: vec![s1],
1956            meta: None,
1957        };
1958        let md = render_path(&path, &RenderOptions::default());
1959
1960        assert!(!md.contains("## Review"));
1961    }
1962
1963    // ── PR identity and diffstat ──────────────────────────────────────
1964
1965    #[test]
1966    fn test_render_pr_identity() {
1967        let s1 = make_step("s1", "human:alice", &[]);
1968        let mut extra = std::collections::HashMap::new();
1969        let github = serde_json::json!({
1970            "number": 42,
1971            "author": "alice",
1972            "state": "open",
1973            "draft": false,
1974            "merged": false,
1975            "additions": 150,
1976            "deletions": 30,
1977            "changed_files": 5
1978        });
1979        extra.insert("github".to_string(), github);
1980        let path = Path {
1981            path: PathIdentity {
1982                id: "pr-42".into(),
1983                base: None,
1984                head: "s1".into(),
1985            },
1986            steps: vec![s1],
1987            meta: Some(PathMeta {
1988                title: Some("Add feature".into()),
1989                extra,
1990                ..Default::default()
1991            }),
1992        };
1993        let md = render_path(&path, &RenderOptions::default());
1994
1995        assert!(md.contains("**PR #42**"));
1996        assert!(md.contains("by alice"));
1997        assert!(md.contains("open"));
1998        assert!(md.contains("+150"));
1999        assert!(md.contains("\u{2212}30"));
2000        assert!(md.contains("5 files"));
2001        // Should NOT show opaque head ID
2002        assert!(!md.contains("**Head:**"));
2003    }
2004
2005    #[test]
2006    fn test_render_no_pr_identity_without_github_meta() {
2007        let s1 = make_step("s1", "human:alex", &[]);
2008        let path = Path {
2009            path: PathIdentity {
2010                id: "p1".into(),
2011                base: None,
2012                head: "s1".into(),
2013            },
2014            steps: vec![s1],
2015            meta: None,
2016        };
2017        let md = render_path(&path, &RenderOptions::default());
2018
2019        // Should show Head when no GitHub meta
2020        assert!(md.contains("**Head:**"));
2021        assert!(!md.contains("**PR #"));
2022    }
2023
2024    // ── friendly helpers ──────────────────────────────────────────────
2025
2026    #[test]
2027    fn test_friendly_artifact_name() {
2028        assert_eq!(
2029            friendly_artifact_name("review://src/main.rs#L42"),
2030            "src/main.rs:42"
2031        );
2032        assert_eq!(friendly_artifact_name("ci://checks/test"), "test");
2033        assert_eq!(friendly_artifact_name("review://decision"), "decision");
2034        assert_eq!(friendly_artifact_name("src/main.rs"), "src/main.rs");
2035    }
2036
2037    #[test]
2038    fn test_friendly_date_range_same_day() {
2039        let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2040        let s2 = Step::new("s2", "human:alex", "2026-02-26T14:00:00Z");
2041        assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26, 2026");
2042    }
2043
2044    #[test]
2045    fn test_friendly_date_range_same_month() {
2046        let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2047        let s2 = Step::new("s2", "human:alex", "2026-02-27T14:00:00Z");
2048        assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26\u{2013}27, 2026");
2049    }
2050
2051    #[test]
2052    fn test_friendly_date_range_different_months() {
2053        let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2054        let s2 = Step::new("s2", "human:alex", "2026-03-01T14:00:00Z");
2055        assert_eq!(
2056            friendly_date_range(&[s1, s2]),
2057            "Feb 26 \u{2013} Mar 1, 2026"
2058        );
2059    }
2060
2061    #[test]
2062    fn test_friendly_date_range_empty() {
2063        assert_eq!(friendly_date_range(&[]), "");
2064    }
2065
2066    #[test]
2067    fn test_truncate_str() {
2068        assert_eq!(truncate_str("hello", 10), "hello");
2069        assert_eq!(
2070            truncate_str("hello world this is long", 10),
2071            "hello worl..."
2072        );
2073        assert_eq!(truncate_str("line1\nline2", 20), "line1 line2");
2074    }
2075
2076    // ── PR conversation comments ────────────────────────────────────
2077
2078    fn make_conversation_step(id: &str, actor: &str, body: &str) -> Step {
2079        let mut extra = std::collections::HashMap::new();
2080        extra.insert("body".to_string(), serde_json::json!(body));
2081        let mut step = Step::new(id, actor, "2026-01-29T15:00:00Z");
2082        step.change.insert(
2083            "review://conversation".to_string(),
2084            ArtifactChange {
2085                raw: None,
2086                structural: Some(StructuralChange {
2087                    change_type: "review.conversation".into(),
2088                    extra,
2089                }),
2090            },
2091        );
2092        step
2093    }
2094
2095    #[test]
2096    fn test_render_conversation_summary() {
2097        let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2098        let md = render_step(&step, &RenderOptions::default());
2099
2100        assert!(md.contains("conversation"));
2101        assert!(md.contains("Looks good overall!"));
2102        // Should NOT show review:// prefix
2103        assert!(!md.contains("review://"));
2104    }
2105
2106    #[test]
2107    fn test_render_conversation_full() {
2108        let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2109        let md = render_step(
2110            &step,
2111            &RenderOptions {
2112                detail: Detail::Full,
2113                ..Default::default()
2114            },
2115        );
2116
2117        assert!(md.contains("> Looks good overall!"));
2118        assert!(!md.contains("review://"));
2119    }
2120
2121    #[test]
2122    fn test_review_section_includes_conversations() {
2123        let s1 = make_step("s1", "human:alice", &[]);
2124        let s2 = make_conversation_step("s2", "human:carol", "Looks good overall!");
2125        let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
2126        let s2 = s2.with_parent("s1");
2127        let s3 = s3.with_parent("s2");
2128        let path = Path {
2129            path: PathIdentity {
2130                id: "p1".into(),
2131                base: None,
2132                head: "s3".into(),
2133            },
2134            steps: vec![s1, s2, s3],
2135            meta: None,
2136        };
2137        let md = render_path(&path, &RenderOptions::default());
2138
2139        assert!(md.contains("## Review"));
2140        assert!(md.contains("### Discussion"));
2141        assert!(md.contains("carol"));
2142        assert!(md.contains("Looks good overall!"));
2143        assert!(md.contains("APPROVED"));
2144    }
2145
2146    #[test]
2147    fn test_render_merged_pr() {
2148        let s1 = make_step("s1", "human:alice", &[]);
2149        let mut extra = std::collections::HashMap::new();
2150        let github = serde_json::json!({
2151            "number": 7,
2152            "author": "alice",
2153            "state": "closed",
2154            "draft": false,
2155            "merged": true,
2156            "additions": 42,
2157            "deletions": 10,
2158            "changed_files": 3
2159        });
2160        extra.insert("github".to_string(), github);
2161        let path = Path {
2162            path: PathIdentity {
2163                id: "pr-7".into(),
2164                base: None,
2165                head: "s1".into(),
2166            },
2167            steps: vec![s1],
2168            meta: Some(PathMeta {
2169                title: Some("Fix the thing".into()),
2170                extra,
2171                ..Default::default()
2172            }),
2173        };
2174        let md = render_path(&path, &RenderOptions::default());
2175
2176        assert!(md.contains("**PR #7**"));
2177        assert!(md.contains("by alice"));
2178        // merged overrides state=closed
2179        assert!(md.contains("merged"));
2180        assert!(!md.contains("closed"));
2181    }
2182
2183    #[test]
2184    fn test_catch_all_uses_friendly_name() {
2185        // An artifact with an unknown structural type should still get a friendly name
2186        let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2187        step.change.insert(
2188            "review://some/path#L5".to_string(),
2189            ArtifactChange {
2190                raw: None,
2191                structural: Some(StructuralChange {
2192                    change_type: "review.custom".into(),
2193                    extra: Default::default(),
2194                }),
2195            },
2196        );
2197        let md = render_step(&step, &RenderOptions::default());
2198
2199        // Should use friendly name (some/path:5), not raw review:// URI
2200        assert!(md.contains("some/path:5"));
2201        assert!(!md.contains("review://"));
2202    }
2203}