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