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