1use std::collections::{HashMap, HashSet};
35
36use toolpath::v1::{Document, Graph, Path, PathOrRef, Step, query};
37
38pub struct RenderOptions {
40 pub show_files: bool,
42 pub show_timestamps: bool,
44 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
58pub 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
67pub 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
89pub 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 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 let active_steps = query::ancestors(&path.steps, &path.path.head);
110
111 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 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(); 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 for step in &path.steps {
155 if step.step.parents.is_empty() {
156 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 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
212pub 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 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 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 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 let mut root_steps: Vec<(String, Option<String>)> = Vec::new();
268
269 let path_colors = [
271 "#e3f2fd", "#e8f5e9", "#fff3e0", "#f3e5f5", "#e0f7fa", "#fce4ec",
272 ];
273
274 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 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 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 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 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 }
376 }
377
378 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 let header = if let Some(meta) = &step.meta {
402 if let Some(source) = &meta.source {
403 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 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 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 if options.show_timestamps {
444 let ts = &step.step.timestamp;
445 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 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
477pub fn actor_color(actor: &str) -> &'static str {
479 if actor.starts_with("human:") {
480 "#cce5ff" } else if actor.starts_with("agent:") {
482 "#d4edda" } else if actor.starts_with("tool:") {
484 "#fff3cd" } else if actor.starts_with("ci:") {
486 "#e2d5f1" } else {
488 "#f8f9fa" }
490}
491
492fn safe_prefix(s: &str, n: usize) -> String {
494 s.chars().take(n).collect()
495}
496
497pub fn escape_dot(s: &str) -> String {
499 s.replace('\\', "\\\\")
500 .replace('"', "\\\"")
501 .replace('\n', "\\n")
502}
503
504pub fn escape_html(s: &str) -> String {
506 s.replace('&', "&")
507 .replace('<', "<")
508 .replace('>', ">")
509 .replace('"', """)
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 #[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 #[test]
562 fn test_escape_html_ampersand() {
563 assert_eq!(escape_html("a & b"), "a & b");
564 }
565
566 #[test]
567 fn test_escape_html_angle_brackets() {
568 assert_eq!(escape_html("<tag>"), "<tag>");
569 }
570
571 #[test]
572 fn test_escape_html_quotes() {
573 assert_eq!(escape_html(r#"a "b""#), "a "b"");
574 }
575
576 #[test]
577 fn test_escape_html_combined() {
578 assert_eq!(
579 escape_html(r#"<a href="url">&</a>"#),
580 "<a href="url">&</a>"
581 );
582 }
583
584 #[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 #[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 #[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")); 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 assert!(dot.contains("\u{2026}")); }
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 assert!(dot.contains("abcdef12"));
679 }
680
681 #[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 assert!(dot.contains("penwidth=3"));
709 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"]); 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")); 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")); }
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 #[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")); }
854
855 #[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}