Skip to main content

toolpath_dot/
lib.rs

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