Skip to main content

toolpath_dot/
lib.rs

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