Skip to main content

toolpath_dot/
lib.rs

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