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