1#![doc = include_str!("../README.md")]
2
3use std::collections::{HashMap, HashSet};
4
5use toolpath::v1::{Graph, Path, PathOrRef, Step, query};
6
7pub struct RenderOptions {
9 pub show_files: bool,
11 pub show_timestamps: bool,
13 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
27pub 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
40pub 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
62pub 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 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 let active_steps = query::ancestors(&path.steps, &path.path.head);
83
84 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 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(); 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 for step in &path.steps {
128 if step.step.parents.is_empty() {
129 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 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
185pub 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 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 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 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 let mut root_steps: Vec<(String, Option<String>)> = Vec::new();
241
242 let path_colors = [
244 "#e3f2fd", "#e8f5e9", "#fff3e0", "#f3e5f5", "#e0f7fa", "#fce4ec",
245 ];
246
247 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 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 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 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 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 }
349 }
350
351 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 let header = if let Some(meta) = &step.meta {
375 if let Some(source) = &meta.source {
376 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 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 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 if options.show_timestamps {
417 let ts = &step.step.timestamp;
418 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 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
450pub fn actor_color(actor: &str) -> &'static str {
452 if actor.starts_with("human:") {
453 "#cce5ff" } else if actor.starts_with("agent:") {
455 "#d4edda" } else if actor.starts_with("tool:") {
457 "#fff3cd" } else if actor.starts_with("ci:") {
459 "#e2d5f1" } else {
461 "#f8f9fa" }
463}
464
465fn safe_prefix(s: &str, n: usize) -> String {
467 s.chars().take(n).collect()
468}
469
470pub fn escape_dot(s: &str) -> String {
472 s.replace('\\', "\\\\")
473 .replace('"', "\\\"")
474 .replace('\n', "\\n")
475}
476
477pub fn escape_html(s: &str) -> String {
479 s.replace('&', "&")
480 .replace('<', "<")
481 .replace('>', ">")
482 .replace('"', """)
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 #[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 #[test]
535 fn test_escape_html_ampersand() {
536 assert_eq!(escape_html("a & b"), "a & b");
537 }
538
539 #[test]
540 fn test_escape_html_angle_brackets() {
541 assert_eq!(escape_html("<tag>"), "<tag>");
542 }
543
544 #[test]
545 fn test_escape_html_quotes() {
546 assert_eq!(escape_html(r#"a "b""#), "a "b"");
547 }
548
549 #[test]
550 fn test_escape_html_combined() {
551 assert_eq!(
552 escape_html(r#"<a href="url">&</a>"#),
553 "<a href="url">&</a>"
554 );
555 }
556
557 #[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 #[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 #[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")); 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 assert!(dot.contains("\u{2026}")); }
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 assert!(dot.contains("abcdef12"));
652 }
653
654 #[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 assert!(dot.contains("penwidth=3"));
683 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"]); 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")); 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")); }
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 #[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")); }
833
834 #[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}