Skip to main content

toolpath_dot/
lib.rs

1use std::collections::{HashMap, HashSet};
2
3use toolpath::v1::{Document, Graph, Path, PathOrRef, Step, query};
4
5/// Options controlling what information is rendered in the DOT output.
6pub struct RenderOptions {
7    pub show_files: bool,
8    pub show_timestamps: bool,
9    pub highlight_dead_ends: bool,
10}
11
12impl Default for RenderOptions {
13    fn default() -> Self {
14        Self {
15            show_files: false,
16            show_timestamps: false,
17            highlight_dead_ends: true,
18        }
19    }
20}
21
22/// Render any Toolpath [`Document`] variant to a Graphviz DOT string.
23pub fn render(doc: &Document, options: &RenderOptions) -> String {
24    match doc {
25        Document::Graph(g) => render_graph(g, options),
26        Document::Path(p) => render_path(p, options),
27        Document::Step(s) => render_step(s, options),
28    }
29}
30
31/// Render a single [`Step`] as a DOT digraph.
32pub fn render_step(step: &Step, options: &RenderOptions) -> String {
33    let mut dot = String::new();
34    dot.push_str("digraph toolpath {\n");
35    dot.push_str("  rankdir=TB;\n");
36    dot.push_str("  node [shape=box, style=rounded, fontname=\"Helvetica\"];\n\n");
37
38    let label = format_step_label_html(step, options);
39    let color = actor_color(&step.step.actor);
40    dot.push_str(&format!(
41        "  \"{}\" [label={}, fillcolor=\"{}\", style=\"rounded,filled\"];\n",
42        step.step.id, label, color
43    ));
44
45    for parent in &step.step.parents {
46        dot.push_str(&format!("  \"{}\" -> \"{}\";\n", parent, step.step.id));
47    }
48
49    dot.push_str("}\n");
50    dot
51}
52
53/// Render a [`Path`] as a DOT digraph.
54pub fn render_path(path: &Path, options: &RenderOptions) -> String {
55    let mut dot = String::new();
56    dot.push_str("digraph toolpath {\n");
57    dot.push_str("  rankdir=TB;\n");
58    dot.push_str("  node [shape=box, style=rounded, fontname=\"Helvetica\"];\n");
59    dot.push_str("  edge [color=\"#666666\"];\n");
60    dot.push_str("  splines=ortho;\n\n");
61
62    // Add title
63    if let Some(meta) = &path.meta
64        && let Some(title) = &meta.title
65    {
66        dot.push_str("  labelloc=\"t\";\n");
67        dot.push_str(&format!("  label=\"{}\";\n", escape_dot(title)));
68        dot.push_str("  fontsize=16;\n");
69        dot.push_str("  fontname=\"Helvetica-Bold\";\n\n");
70    }
71
72    // Find ancestors of head (active path)
73    let active_steps = query::ancestors(&path.steps, &path.path.head);
74
75    // Add base node
76    if let Some(base) = &path.path.base {
77        let short_commit = safe_prefix(base.ref_str.as_deref().unwrap_or(""), 8);
78        let base_label = format!(
79            "<<b>BASE</b><br/><font point-size=\"10\">{}</font><br/><font point-size=\"9\" color=\"#666666\">{}</font>>",
80            escape_html(&base.uri),
81            escape_html(&short_commit)
82        );
83        dot.push_str(&format!(
84            "  \"__base__\" [label={}, shape=ellipse, style=filled, fillcolor=\"#e0e0e0\"];\n",
85            base_label
86        ));
87    }
88
89    // Add step nodes
90    for step in &path.steps {
91        let label = format_step_label_html(step, options);
92        let color = actor_color(&step.step.actor);
93        let is_head = step.step.id == path.path.head;
94        let is_active = active_steps.contains(&step.step.id);
95        let is_dead_end = !is_active && options.highlight_dead_ends;
96
97        let mut style = "rounded,filled".to_string();
98        let mut penwidth = "1";
99        let mut fillcolor = color.to_string();
100
101        if is_head {
102            style = "rounded,filled,bold".to_string();
103            penwidth = "3";
104        } else if is_dead_end {
105            fillcolor = "#ffcccc".to_string(); // Light red for dead ends
106            style = "rounded,filled,dashed".to_string();
107        }
108
109        dot.push_str(&format!(
110            "  \"{}\" [label={}, fillcolor=\"{}\", style=\"{}\", penwidth={}];\n",
111            step.step.id, label, fillcolor, style, penwidth
112        ));
113    }
114
115    dot.push('\n');
116
117    // Add edges
118    for step in &path.steps {
119        if step.step.parents.is_empty() {
120            // Root step - connect to base
121            if path.path.base.is_some() {
122                dot.push_str(&format!("  \"__base__\" -> \"{}\";\n", step.step.id));
123            }
124        } else {
125            for parent in &step.step.parents {
126                let is_active_edge =
127                    active_steps.contains(&step.step.id) && active_steps.contains(parent);
128                let edge_style = if is_active_edge {
129                    "color=\"#333333\", penwidth=2"
130                } else {
131                    "color=\"#cccccc\", style=dashed"
132                };
133                dot.push_str(&format!(
134                    "  \"{}\" -> \"{}\" [{}];\n",
135                    parent, step.step.id, edge_style
136                ));
137            }
138        }
139    }
140
141    // Add legend
142    dot.push_str("\n  // Legend\n");
143    dot.push_str("  subgraph cluster_legend {\n");
144    dot.push_str("    label=\"Legend\";\n");
145    dot.push_str("    fontname=\"Helvetica-Bold\";\n");
146    dot.push_str("    style=filled;\n");
147    dot.push_str("    fillcolor=\"#f8f8f8\";\n");
148    dot.push_str("    node [shape=box, style=\"rounded,filled\", width=0.9, fontname=\"Helvetica\", fontsize=10];\n");
149    dot.push_str(&format!(
150        "    leg_human [label=\"human\", fillcolor=\"{}\"];\n",
151        actor_color("human:x")
152    ));
153    dot.push_str(&format!(
154        "    leg_agent [label=\"agent\", fillcolor=\"{}\"];\n",
155        actor_color("agent:x")
156    ));
157    dot.push_str(&format!(
158        "    leg_tool [label=\"tool\", fillcolor=\"{}\"];\n",
159        actor_color("tool:x")
160    ));
161    if options.highlight_dead_ends {
162        dot.push_str(
163            "    leg_dead [label=\"dead end\", fillcolor=\"#ffcccc\", style=\"rounded,filled,dashed\"];\n",
164        );
165    }
166    dot.push_str("    leg_human -> leg_agent -> leg_tool [style=invis];\n");
167    if options.highlight_dead_ends {
168        dot.push_str("    leg_tool -> leg_dead [style=invis];\n");
169    }
170    dot.push_str("  }\n");
171
172    dot.push_str("}\n");
173    dot
174}
175
176/// Render a [`Graph`] as a DOT digraph.
177pub fn render_graph(graph: &Graph, options: &RenderOptions) -> String {
178    let mut dot = String::new();
179    dot.push_str("digraph toolpath {\n");
180    dot.push_str("  rankdir=TB;\n");
181    dot.push_str("  compound=true;\n");
182    dot.push_str("  newrank=true;\n");
183    dot.push_str("  node [shape=box, style=rounded, fontname=\"Helvetica\"];\n");
184    dot.push_str("  edge [color=\"#333333\"];\n");
185    dot.push_str("  splines=ortho;\n\n");
186
187    // Add title
188    if let Some(meta) = &graph.meta
189        && let Some(title) = &meta.title
190    {
191        dot.push_str("  labelloc=\"t\";\n");
192        dot.push_str(&format!("  label=\"{}\";\n", escape_dot(title)));
193        dot.push_str("  fontsize=18;\n");
194        dot.push_str("  fontname=\"Helvetica-Bold\";\n\n");
195    }
196
197    // Build a map of commit hashes to step IDs across all paths
198    let mut commit_to_step: HashMap<String, String> = HashMap::new();
199
200    for path_or_ref in &graph.paths {
201        if let PathOrRef::Path(path) = path_or_ref {
202            for step in &path.steps {
203                if let Some(meta) = &step.meta
204                    && let Some(source) = &meta.source
205                {
206                    commit_to_step.insert(source.revision.clone(), step.step.id.clone());
207                    if source.revision.len() >= 8 {
208                        commit_to_step
209                            .insert(safe_prefix(&source.revision, 8), step.step.id.clone());
210                    }
211                }
212            }
213        }
214    }
215
216    // No BASE nodes - commits without parents in the graph are simply root nodes
217
218    // Collect all heads and all step IDs
219    let mut heads: HashSet<String> = HashSet::new();
220    let mut all_step_ids: HashSet<String> = HashSet::new();
221    for path_or_ref in &graph.paths {
222        if let PathOrRef::Path(path) = path_or_ref {
223            heads.insert(path.path.head.clone());
224            for step in &path.steps {
225                all_step_ids.insert(step.step.id.clone());
226            }
227        }
228    }
229
230    // Track root steps for base connections
231    let mut root_steps: Vec<(String, Option<String>)> = Vec::new();
232
233    // Assign colors to paths
234    let path_colors = [
235        "#e3f2fd", "#e8f5e9", "#fff3e0", "#f3e5f5", "#e0f7fa", "#fce4ec",
236    ];
237
238    // Add step nodes inside clusters
239    for (i, path_or_ref) in graph.paths.iter().enumerate() {
240        if let PathOrRef::Path(path) = path_or_ref {
241            let path_name = path
242                .meta
243                .as_ref()
244                .and_then(|m| m.title.as_ref())
245                .map(|t| t.as_str())
246                .unwrap_or(&path.path.id);
247
248            let cluster_color = path_colors[i % path_colors.len()];
249
250            dot.push_str(&format!("  subgraph cluster_{} {{\n", i));
251            dot.push_str(&format!("    label=\"{}\";\n", escape_dot(path_name)));
252            dot.push_str("    fontname=\"Helvetica-Bold\";\n");
253            dot.push_str("    style=filled;\n");
254            dot.push_str(&format!("    fillcolor=\"{}\";\n", cluster_color));
255            dot.push_str("    margin=12;\n\n");
256
257            let active_steps = query::ancestors(&path.steps, &path.path.head);
258
259            for step in &path.steps {
260                let label = format_step_label_html(step, options);
261                let color = actor_color(&step.step.actor);
262                let is_head = heads.contains(&step.step.id);
263                let is_active = active_steps.contains(&step.step.id);
264                let is_dead_end = !is_active && options.highlight_dead_ends;
265
266                let mut style = "rounded,filled".to_string();
267                let mut penwidth = "1";
268                let mut fillcolor = color.to_string();
269
270                if is_head {
271                    style = "rounded,filled,bold".to_string();
272                    penwidth = "3";
273                } else if is_dead_end {
274                    fillcolor = "#ffcccc".to_string();
275                    style = "rounded,filled,dashed".to_string();
276                }
277
278                dot.push_str(&format!(
279                    "    \"{}\" [label={}, fillcolor=\"{}\", style=\"{}\", penwidth={}];\n",
280                    step.step.id, label, fillcolor, style, penwidth
281                ));
282
283                // Track root steps
284                let is_root = step.step.parents.is_empty()
285                    || step.step.parents.iter().all(|p| !all_step_ids.contains(p));
286                if is_root {
287                    root_steps.push((
288                        step.step.id.clone(),
289                        path.path.base.as_ref().and_then(|b| b.ref_str.clone()),
290                    ));
291                }
292            }
293
294            dot.push_str("  }\n\n");
295        }
296    }
297
298    // Add all edges (outside clusters for cross-cluster edges)
299    for path_or_ref in &graph.paths {
300        if let PathOrRef::Path(path) = path_or_ref {
301            let active_steps = query::ancestors(&path.steps, &path.path.head);
302
303            for step in &path.steps {
304                for parent in &step.step.parents {
305                    if all_step_ids.contains(parent) {
306                        let is_active_edge =
307                            active_steps.contains(&step.step.id) && active_steps.contains(parent);
308                        let edge_style = if is_active_edge {
309                            "color=\"#333333\", penwidth=2"
310                        } else {
311                            "color=\"#cccccc\", style=dashed"
312                        };
313                        dot.push_str(&format!(
314                            "  \"{}\" -> \"{}\" [{}];\n",
315                            parent, step.step.id, edge_style
316                        ));
317                    }
318                }
319            }
320        }
321    }
322
323    // Add edges from base commits to root steps (cross-cluster edges)
324    dot.push_str("\n  // Cross-cluster edges (where branches diverge)\n");
325    for (step_id, base_commit) in &root_steps {
326        if let Some(commit) = base_commit {
327            let short_commit = safe_prefix(commit, 8);
328            // Only create edge if the base commit exists as a step in another path
329            if let Some(parent_step_id) = commit_to_step
330                .get(commit)
331                .or_else(|| commit_to_step.get(&short_commit))
332            {
333                dot.push_str(&format!(
334                    "  \"{}\" -> \"{}\" [color=\"#333333\", penwidth=2];\n",
335                    parent_step_id, step_id
336                ));
337            }
338            // Otherwise, this is just a root node with no parent - that's fine
339        }
340    }
341
342    // Add external refs
343    for (i, path_or_ref) in graph.paths.iter().enumerate() {
344        if let PathOrRef::Ref(path_ref) = path_or_ref {
345            let ref_id = format!("ref_{}", i);
346            let ref_label = format!(
347                "<<b>$ref</b><br/><font point-size=\"9\">{}</font>>",
348                escape_html(&path_ref.ref_url)
349            );
350            dot.push_str(&format!(
351                "  \"{}\" [label={}, shape=note, style=filled, fillcolor=\"#ffffcc\"];\n",
352                ref_id, ref_label
353            ));
354        }
355    }
356
357    dot.push_str("}\n");
358    dot
359}
360
361fn format_step_label_html(step: &Step, options: &RenderOptions) -> String {
362    let mut rows = vec![];
363
364    // Commit hash (from VCS source) or step ID
365    let header = if let Some(meta) = &step.meta {
366        if let Some(source) = &meta.source {
367            // Show short commit hash
368            let short_rev = safe_prefix(&source.revision, 8);
369            format!("<b>{}</b>", escape_html(&short_rev))
370        } else {
371            format!("<b>{}</b>", escape_html(&step.step.id))
372        }
373    } else {
374        format!("<b>{}</b>", escape_html(&step.step.id))
375    };
376    rows.push(header);
377
378    // Actor (shortened)
379    let actor_short = step
380        .step
381        .actor
382        .split(':')
383        .next_back()
384        .unwrap_or(&step.step.actor);
385    rows.push(format!(
386        "<font point-size=\"10\">{}</font>",
387        escape_html(actor_short)
388    ));
389
390    // Intent if available
391    if let Some(meta) = &step.meta
392        && let Some(intent) = &meta.intent
393    {
394        let short_intent = if intent.chars().count() > 40 {
395            let truncated: String = intent.chars().take(37).collect();
396            format!("{}\u{2026}", truncated)
397        } else {
398            intent.clone()
399        };
400        rows.push(format!(
401            "<font point-size=\"9\"><i>{}</i></font>",
402            escape_html(&short_intent)
403        ));
404    }
405
406    // Timestamp if requested
407    if options.show_timestamps {
408        let ts = &step.step.timestamp;
409        // Show just time portion
410        if let Some(time_part) = ts.split('T').nth(1) {
411            rows.push(format!(
412                "<font point-size=\"8\" color=\"gray\">{}</font>",
413                escape_html(time_part.trim_end_matches('Z'))
414            ));
415        }
416    }
417
418    // Files if requested
419    if options.show_files {
420        let files: Vec<_> = step.change.keys().collect();
421        if !files.is_empty() {
422            let files_str = if files.len() <= 2 {
423                files
424                    .iter()
425                    .map(|f| f.split('/').next_back().unwrap_or(f))
426                    .collect::<Vec<_>>()
427                    .join(", ")
428            } else {
429                format!("{} files", files.len())
430            };
431            rows.push(format!(
432                "<font point-size=\"8\" color=\"#666666\">{}</font>",
433                escape_html(&files_str)
434            ));
435        }
436    }
437
438    format!("<{}>", rows.join("<br/>"))
439}
440
441/// Return a fill color for a given actor string.
442pub fn actor_color(actor: &str) -> &'static str {
443    if actor.starts_with("human:") {
444        "#cce5ff" // Light blue
445    } else if actor.starts_with("agent:") {
446        "#d4edda" // Light green
447    } else if actor.starts_with("tool:") {
448        "#fff3cd" // Light yellow
449    } else if actor.starts_with("ci:") {
450        "#e2d5f1" // Light purple
451    } else {
452        "#f8f9fa" // Light gray
453    }
454}
455
456/// Return the first `n` characters of a string, safe for any UTF-8 content.
457fn safe_prefix(s: &str, n: usize) -> String {
458    s.chars().take(n).collect()
459}
460
461/// Escape a string for use in DOT label attributes (double-quoted context).
462pub fn escape_dot(s: &str) -> String {
463    s.replace('\\', "\\\\")
464        .replace('"', "\\\"")
465        .replace('\n', "\\n")
466}
467
468/// Escape a string for use inside HTML-like DOT labels.
469pub fn escape_html(s: &str) -> String {
470    s.replace('&', "&amp;")
471        .replace('<', "&lt;")
472        .replace('>', "&gt;")
473        .replace('"', "&quot;")
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use toolpath::v1::{
480        Base, Graph, GraphIdentity, GraphMeta, Path, PathIdentity, PathMeta, PathOrRef, PathRef,
481        Step,
482    };
483
484    fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step {
485        let mut step = Step::new(id, actor, "2026-01-01T12:00:00Z")
486            .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new");
487        for p in parents {
488            step = step.with_parent(*p);
489        }
490        step
491    }
492
493    fn make_step_with_intent(id: &str, actor: &str, parents: &[&str], intent: &str) -> Step {
494        make_step(id, actor, parents).with_intent(intent)
495    }
496
497    fn make_step_with_source(id: &str, actor: &str, parents: &[&str], revision: &str) -> Step {
498        make_step(id, actor, parents).with_vcs_source("git", revision)
499    }
500
501    // ── escape_dot ─────────────────────────────────────────────────────
502
503    #[test]
504    fn test_escape_dot_quotes() {
505        assert_eq!(escape_dot(r#"say "hello""#), r#"say \"hello\""#);
506    }
507
508    #[test]
509    fn test_escape_dot_backslash() {
510        assert_eq!(escape_dot(r"path\to\file"), r"path\\to\\file");
511    }
512
513    #[test]
514    fn test_escape_dot_newline() {
515        assert_eq!(escape_dot("line1\nline2"), r"line1\nline2");
516    }
517
518    #[test]
519    fn test_escape_dot_passthrough() {
520        assert_eq!(escape_dot("simple text"), "simple text");
521    }
522
523    // ── escape_html ────────────────────────────────────────────────────
524
525    #[test]
526    fn test_escape_html_ampersand() {
527        assert_eq!(escape_html("a & b"), "a &amp; b");
528    }
529
530    #[test]
531    fn test_escape_html_angle_brackets() {
532        assert_eq!(escape_html("<tag>"), "&lt;tag&gt;");
533    }
534
535    #[test]
536    fn test_escape_html_quotes() {
537        assert_eq!(escape_html(r#"a "b""#), "a &quot;b&quot;");
538    }
539
540    #[test]
541    fn test_escape_html_combined() {
542        assert_eq!(
543            escape_html(r#"<a href="url">&</a>"#),
544            "&lt;a href=&quot;url&quot;&gt;&amp;&lt;/a&gt;"
545        );
546    }
547
548    // ── actor_color ────────────────────────────────────────────────────
549
550    #[test]
551    fn test_actor_color_human() {
552        assert_eq!(actor_color("human:alex"), "#cce5ff");
553    }
554
555    #[test]
556    fn test_actor_color_agent() {
557        assert_eq!(actor_color("agent:claude"), "#d4edda");
558    }
559
560    #[test]
561    fn test_actor_color_tool() {
562        assert_eq!(actor_color("tool:rustfmt"), "#fff3cd");
563    }
564
565    #[test]
566    fn test_actor_color_ci() {
567        assert_eq!(actor_color("ci:github-actions"), "#e2d5f1");
568    }
569
570    #[test]
571    fn test_actor_color_unknown() {
572        assert_eq!(actor_color("other:thing"), "#f8f9fa");
573    }
574
575    // ── safe_prefix ────────────────────────────────────────────────────
576
577    #[test]
578    fn test_safe_prefix_normal() {
579        assert_eq!(safe_prefix("abcdef1234", 8), "abcdef12");
580    }
581
582    #[test]
583    fn test_safe_prefix_shorter_than_n() {
584        assert_eq!(safe_prefix("abc", 8), "abc");
585    }
586
587    #[test]
588    fn test_safe_prefix_multibyte() {
589        assert_eq!(safe_prefix("日本語", 2), "日本");
590    }
591
592    // ── render_step ────────────────────────────────────────────────────
593
594    #[test]
595    fn test_render_step_basic() {
596        let step = make_step("s1", "human:alex", &[]);
597        let opts = RenderOptions::default();
598        let dot = render_step(&step, &opts);
599
600        assert!(dot.starts_with("digraph toolpath {"));
601        assert!(dot.contains("\"s1\""));
602        assert!(dot.contains("#cce5ff")); // human color
603        assert!(dot.ends_with("}\n"));
604    }
605
606    #[test]
607    fn test_render_step_with_parents() {
608        let step = make_step("s2", "agent:claude", &["s1"]);
609        let opts = RenderOptions::default();
610        let dot = render_step(&step, &opts);
611
612        assert!(dot.contains("\"s1\" -> \"s2\""));
613    }
614
615    #[test]
616    fn test_render_step_with_intent() {
617        let step = make_step_with_intent("s1", "human:alex", &[], "Fix the bug");
618        let opts = RenderOptions::default();
619        let dot = render_step(&step, &opts);
620
621        assert!(dot.contains("Fix the bug"));
622    }
623
624    #[test]
625    fn test_render_step_truncates_long_intent() {
626        let long_intent = "A".repeat(50);
627        let step = make_step_with_intent("s1", "human:alex", &[], &long_intent);
628        let opts = RenderOptions::default();
629        let dot = render_step(&step, &opts);
630
631        // Intent > 40 chars should be truncated with ellipsis
632        assert!(dot.contains("\u{2026}")); // unicode ellipsis
633    }
634
635    #[test]
636    fn test_render_step_with_vcs_source() {
637        let step = make_step_with_source("s1", "human:alex", &[], "abcdef1234567890");
638        let opts = RenderOptions::default();
639        let dot = render_step(&step, &opts);
640
641        // Should show short commit hash
642        assert!(dot.contains("abcdef12"));
643    }
644
645    // ── render_path ────────────────────────────────────────────────────
646
647    #[test]
648    fn test_render_path_basic() {
649        let s1 = make_step("s1", "human:alex", &[]);
650        let s2 = make_step("s2", "agent:claude", &["s1"]);
651        let path = Path {
652            path: PathIdentity {
653                id: "p1".into(),
654                base: Some(Base::vcs("github:org/repo", "abc123")),
655                head: "s2".into(),
656            },
657            steps: vec![s1, s2],
658            meta: Some(PathMeta {
659                title: Some("Test Path".into()),
660                ..Default::default()
661            }),
662        };
663        let opts = RenderOptions::default();
664        let dot = render_path(&path, &opts);
665
666        assert!(dot.contains("digraph toolpath"));
667        assert!(dot.contains("Test Path"));
668        assert!(dot.contains("__base__"));
669        assert!(dot.contains("\"s1\""));
670        assert!(dot.contains("\"s2\""));
671        // s2 is head, should be bold
672        assert!(dot.contains("penwidth=3"));
673        // Legend
674        assert!(dot.contains("cluster_legend"));
675    }
676
677    #[test]
678    fn test_render_path_dead_end_highlighting() {
679        let s1 = make_step("s1", "human:alex", &[]);
680        let s2 = make_step("s2", "agent:claude", &["s1"]);
681        let s2a = make_step("s2a", "agent:claude", &["s1"]); // dead end
682        let s3 = make_step("s3", "human:alex", &["s2"]);
683        let path = Path {
684            path: PathIdentity {
685                id: "p1".into(),
686                base: None,
687                head: "s3".into(),
688            },
689            steps: vec![s1, s2, s2a, s3],
690            meta: None,
691        };
692        let opts = RenderOptions {
693            highlight_dead_ends: true,
694            ..Default::default()
695        };
696        let dot = render_path(&path, &opts);
697
698        assert!(dot.contains("#ffcccc")); // dead end color
699        assert!(dot.contains("dashed"));
700    }
701
702    #[test]
703    fn test_render_path_with_timestamps() {
704        let s1 = make_step("s1", "human:alex", &[]);
705        let path = Path {
706            path: PathIdentity {
707                id: "p1".into(),
708                base: None,
709                head: "s1".into(),
710            },
711            steps: vec![s1],
712            meta: None,
713        };
714        let opts = RenderOptions {
715            show_timestamps: true,
716            ..Default::default()
717        };
718        let dot = render_path(&path, &opts);
719
720        assert!(dot.contains("12:00:00")); // time portion
721    }
722
723    #[test]
724    fn test_render_path_with_files() {
725        let s1 = make_step("s1", "human:alex", &[]);
726        let path = Path {
727            path: PathIdentity {
728                id: "p1".into(),
729                base: None,
730                head: "s1".into(),
731            },
732            steps: vec![s1],
733            meta: None,
734        };
735        let opts = RenderOptions {
736            show_files: true,
737            ..Default::default()
738        };
739        let dot = render_path(&path, &opts);
740
741        assert!(dot.contains("main.rs"));
742    }
743
744    // ── render_graph ───────────────────────────────────────────────────
745
746    #[test]
747    fn test_render_graph_basic() {
748        let s1 = make_step("s1", "human:alex", &[]);
749        let s2 = make_step("s2", "agent:claude", &["s1"]);
750        let path1 = Path {
751            path: PathIdentity {
752                id: "p1".into(),
753                base: Some(Base::vcs("github:org/repo", "abc123")),
754                head: "s2".into(),
755            },
756            steps: vec![s1, s2],
757            meta: Some(PathMeta {
758                title: Some("Branch: main".into()),
759                ..Default::default()
760            }),
761        };
762
763        let s3 = make_step("s3", "human:bob", &[]);
764        let path2 = Path {
765            path: PathIdentity {
766                id: "p2".into(),
767                base: Some(Base::vcs("github:org/repo", "abc123")),
768                head: "s3".into(),
769            },
770            steps: vec![s3],
771            meta: Some(PathMeta {
772                title: Some("Branch: feature".into()),
773                ..Default::default()
774            }),
775        };
776
777        let graph = Graph {
778            graph: GraphIdentity { id: "g1".into() },
779            paths: vec![
780                PathOrRef::Path(Box::new(path1)),
781                PathOrRef::Path(Box::new(path2)),
782            ],
783            meta: Some(GraphMeta {
784                title: Some("Test Graph".into()),
785                ..Default::default()
786            }),
787        };
788
789        let opts = RenderOptions::default();
790        let dot = render_graph(&graph, &opts);
791
792        assert!(dot.contains("digraph toolpath"));
793        assert!(dot.contains("compound=true"));
794        assert!(dot.contains("Test Graph"));
795        assert!(dot.contains("cluster_0"));
796        assert!(dot.contains("cluster_1"));
797        assert!(dot.contains("Branch: main"));
798        assert!(dot.contains("Branch: feature"));
799    }
800
801    #[test]
802    fn test_render_graph_with_refs() {
803        let graph = Graph {
804            graph: GraphIdentity { id: "g1".into() },
805            paths: vec![PathOrRef::Ref(PathRef {
806                ref_url: "https://example.com/path.json".to_string(),
807            })],
808            meta: None,
809        };
810
811        let opts = RenderOptions::default();
812        let dot = render_graph(&graph, &opts);
813
814        assert!(dot.contains("$ref"));
815        assert!(dot.contains("example.com/path.json"));
816        assert!(dot.contains("#ffffcc")); // ref note color
817    }
818
819    // ── render (dispatch) ──────────────────────────────────────────────
820
821    #[test]
822    fn test_render_dispatches_step() {
823        let step = make_step("s1", "human:alex", &[]);
824        let doc = Document::Step(step);
825        let opts = RenderOptions::default();
826        let dot = render(&doc, &opts);
827        assert!(dot.contains("\"s1\""));
828    }
829
830    #[test]
831    fn test_render_dispatches_path() {
832        let path = Path {
833            path: PathIdentity {
834                id: "p1".into(),
835                base: None,
836                head: "s1".into(),
837            },
838            steps: vec![make_step("s1", "human:alex", &[])],
839            meta: None,
840        };
841        let doc = Document::Path(path);
842        let opts = RenderOptions::default();
843        let dot = render(&doc, &opts);
844        assert!(dot.contains("cluster_legend"));
845    }
846
847    #[test]
848    fn test_render_dispatches_graph() {
849        let graph = Graph {
850            graph: GraphIdentity { id: "g1".into() },
851            paths: vec![],
852            meta: None,
853        };
854        let doc = Document::Graph(graph);
855        let opts = RenderOptions::default();
856        let dot = render(&doc, &opts);
857        assert!(dot.contains("compound=true"));
858    }
859}