1use crate::db::{DecisionEdge, DecisionGraph, DecisionNode};
6use std::collections::{HashMap, HashSet};
7use std::fmt::Write;
8
9macro_rules! w {
13 ($dst:expr, $($arg:tt)*) => {
14 let _ = write!($dst, $($arg)*);
15 };
16}
17
18macro_rules! wln {
19 ($dst:expr) => {
20 let _ = writeln!($dst);
21 };
22 ($dst:expr, $($arg:tt)*) => {
23 let _ = writeln!($dst, $($arg)*);
24 };
25}
26
27#[derive(Debug, Clone)]
29pub struct DotConfig {
30 pub title: Option<String>,
32 pub show_rationale: bool,
34 pub show_confidence: bool,
36 pub show_ids: bool,
38 pub rankdir: String,
40}
41
42impl Default for DotConfig {
43 fn default() -> Self {
44 Self {
45 title: None,
46 show_rationale: true,
47 show_confidence: true,
48 show_ids: true,
49 rankdir: "TB".to_string(),
50 }
51 }
52}
53
54fn node_shape(node_type: &str) -> &'static str {
56 match node_type {
57 "goal" => "house",
58 "decision" => "diamond",
59 "option" => "parallelogram",
60 "action" => "box",
61 "outcome" => "ellipse",
62 "observation" => "note",
63 "revisit" => "doubleoctagon", _ => "box",
65 }
66}
67
68fn node_color(node_type: &str) -> &'static str {
70 match node_type {
71 "goal" => "#FFE4B5", "decision" => "#E6E6FA", "option" => "#E0FFFF", "action" => "#90EE90", "outcome" => "#87CEEB", "observation" => "#DDA0DD", "revisit" => "#FFDAB9", _ => "#F5F5F5", }
80}
81
82fn edge_style(edge_type: &str) -> &'static str {
84 match edge_type {
85 "chosen" => "bold",
86 "rejected" => "dashed",
87 "blocks" => "dotted",
88 _ => "solid",
89 }
90}
91
92fn edge_color(edge_type: &str) -> &'static str {
94 match edge_type {
95 "chosen" => "#228B22", "rejected" => "#DC143C", "blocks" => "#FF4500", "enables" => "#4169E1", _ => "#333333", }
101}
102
103fn escape_dot(s: &str) -> String {
105 s.replace('\\', "\\\\")
106 .replace('"', "\\\"")
107 .replace('\n', "\\n")
108}
109
110fn truncate(s: &str, max_len: usize) -> String {
112 if s.chars().count() <= max_len {
113 s.to_string()
114 } else {
115 let char_len = max_len.saturating_sub(3);
116 let truncated: String = s.chars().take(char_len).collect();
117 format!("{}...", truncated)
118 }
119}
120
121fn extract_confidence(metadata: &Option<String>) -> Option<u8> {
123 metadata.as_ref().and_then(|m| {
124 serde_json::from_str::<serde_json::Value>(m)
125 .ok()
126 .and_then(|v| v.get("confidence").and_then(|c| c.as_u64()))
127 .map(|c| c as u8)
128 })
129}
130
131fn extract_commit(metadata: &Option<String>) -> Option<String> {
133 metadata.as_ref().and_then(|m| {
134 serde_json::from_str::<serde_json::Value>(m)
135 .ok()
136 .and_then(|v| {
137 v.get("commit")
138 .and_then(|c| c.as_str().map(|s| s.to_string()))
139 })
140 })
141}
142
143pub fn graph_to_dot(graph: &DecisionGraph, config: &DotConfig) -> String {
145 let mut dot = String::new();
146
147 wln!(dot, "digraph DecisionGraph {{");
149 wln!(dot, " rankdir={};", config.rankdir);
150 wln!(dot, " node [fontname=\"Arial\" fontsize=10];");
151 wln!(dot, " edge [fontname=\"Arial\" fontsize=9];");
152
153 if let Some(title) = &config.title {
154 wln!(dot, " label=\"{}\";", escape_dot(title));
155 wln!(dot, " labelloc=t;");
156 wln!(dot, " fontsize=14;");
157 }
158 wln!(dot);
159
160 for node in &graph.nodes {
162 let mut label = String::new();
163
164 if config.show_ids {
165 w!(label, "[{}] ", node.id);
166 }
167
168 label.push_str(&truncate(&node.title, 40));
169
170 if config.show_confidence {
171 if let Some(conf) = extract_confidence(&node.metadata_json) {
172 w!(label, "\\n({}%)", conf);
173 }
174 }
175
176 wln!(
177 dot,
178 " {} [label=\"{}\" shape=\"{}\" fillcolor=\"{}\" style=\"filled\"];",
179 node.id,
180 escape_dot(&label),
181 node_shape(&node.node_type),
182 node_color(&node.node_type)
183 );
184 }
185
186 wln!(dot);
187
188 for edge in &graph.edges {
190 let mut attrs = vec![
191 format!("style=\"{}\"", edge_style(&edge.edge_type)),
192 format!("color=\"{}\"", edge_color(&edge.edge_type)),
193 ];
194
195 if config.show_rationale {
196 if let Some(rationale) = &edge.rationale {
197 let truncated = truncate(rationale, 30);
198 attrs.push(format!("label=\"{}\"", escape_dot(&truncated)));
199 }
200 }
201
202 wln!(
203 dot,
204 " {} -> {} [{}];",
205 edge.from_node_id,
206 edge.to_node_id,
207 attrs.join(" ")
208 );
209 }
210
211 wln!(dot, "}}");
212
213 dot
214}
215
216pub fn filter_graph_from_roots(graph: &DecisionGraph, root_ids: &[i32]) -> DecisionGraph {
218 let mut reachable: HashSet<i32> = HashSet::new();
219 let mut to_visit: Vec<i32> = root_ids.to_vec();
220
221 let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
223 for edge in &graph.edges {
224 children
225 .entry(edge.from_node_id)
226 .or_default()
227 .push(edge.to_node_id);
228 }
229
230 while let Some(node_id) = to_visit.pop() {
232 if reachable.insert(node_id) {
233 if let Some(kids) = children.get(&node_id) {
234 to_visit.extend(kids);
235 }
236 }
237 }
238
239 let nodes: Vec<DecisionNode> = graph
241 .nodes
242 .iter()
243 .filter(|n| reachable.contains(&n.id))
244 .cloned()
245 .collect();
246
247 let edges: Vec<DecisionEdge> = graph
248 .edges
249 .iter()
250 .filter(|e| reachable.contains(&e.from_node_id) && reachable.contains(&e.to_node_id))
251 .cloned()
252 .collect();
253
254 DecisionGraph {
255 nodes,
256 edges,
257 config: graph.config.clone(),
258 }
259}
260
261pub fn filter_graph_by_ids(graph: &DecisionGraph, node_ids: &[i32]) -> DecisionGraph {
263 let id_set: HashSet<i32> = node_ids.iter().cloned().collect();
264
265 let nodes: Vec<DecisionNode> = graph
266 .nodes
267 .iter()
268 .filter(|n| id_set.contains(&n.id))
269 .cloned()
270 .collect();
271
272 let edges: Vec<DecisionEdge> = graph
273 .edges
274 .iter()
275 .filter(|e| id_set.contains(&e.from_node_id) && id_set.contains(&e.to_node_id))
276 .cloned()
277 .collect();
278
279 DecisionGraph {
280 nodes,
281 edges,
282 config: graph.config.clone(),
283 }
284}
285
286pub fn parse_node_range(spec: &str) -> Vec<i32> {
288 let mut ids = Vec::new();
289
290 for part in spec.split(',') {
291 let part = part.trim();
292 if part.contains('-') {
293 let parts: Vec<&str> = part.split('-').collect();
294 if parts.len() == 2 {
295 if let (Ok(start), Ok(end)) = (
296 parts[0].trim().parse::<i32>(),
297 parts[1].trim().parse::<i32>(),
298 ) {
299 for id in start..=end {
300 ids.push(id);
301 }
302 }
303 }
304 } else if let Ok(id) = part.parse::<i32>() {
305 ids.push(id);
306 }
307 }
308
309 ids
310}
311
312#[derive(Debug, Clone)]
314pub struct WriteupConfig {
315 pub title: String,
317 pub root_ids: Vec<i32>,
319 pub include_dot: bool,
321 pub include_test_plan: bool,
323 pub png_filename: Option<String>,
325 pub github_repo: Option<String>,
327 pub git_branch: Option<String>,
329}
330
331pub fn generate_pr_writeup(graph: &DecisionGraph, config: &WriteupConfig) -> String {
333 let filtered = if config.root_ids.is_empty() {
334 graph.clone()
335 } else {
336 filter_graph_from_roots(graph, &config.root_ids)
337 };
338
339 let mut writeup = String::new();
340
341 wln!(writeup, "## Summary\n");
343
344 let goals: Vec<&DecisionNode> = filtered
346 .nodes
347 .iter()
348 .filter(|n| n.node_type == "goal")
349 .collect();
350
351 if !goals.is_empty() {
352 for goal in &goals {
353 wln!(writeup, "**Goal:** {}", goal.title);
354 if let Some(desc) = &goal.description {
355 wln!(writeup, "\n{}\n", desc);
356 }
357 }
358 wln!(writeup);
359 }
360
361 let decisions: Vec<&DecisionNode> = filtered
363 .nodes
364 .iter()
365 .filter(|n| n.node_type == "decision")
366 .collect();
367
368 if !decisions.is_empty() {
369 wln!(writeup, "## Key Decisions\n");
370
371 for decision in &decisions {
372 wln!(writeup, "### {}\n", decision.title);
373
374 let decision_options: Vec<&DecisionNode> = filtered
376 .nodes
377 .iter()
378 .filter(|n| {
379 n.node_type == "option"
380 && filtered
381 .edges
382 .iter()
383 .any(|e| e.from_node_id == decision.id && e.to_node_id == n.id)
384 })
385 .collect();
386
387 if !decision_options.is_empty() {
388 wln!(writeup, "**Options considered:**\n");
389 for opt in &decision_options {
390 let marker = if filtered.edges.iter().any(|e| {
391 e.from_node_id == decision.id
392 && e.to_node_id == opt.id
393 && e.edge_type == "chosen"
394 }) {
395 "[x]"
396 } else {
397 "[ ]"
398 };
399 wln!(writeup, "- {} {}", marker, opt.title);
400 }
401 wln!(writeup);
402 }
403
404 let observations: Vec<&DecisionNode> = filtered
406 .nodes
407 .iter()
408 .filter(|n| {
409 n.node_type == "observation"
410 && filtered.edges.iter().any(|e| {
411 (e.from_node_id == decision.id && e.to_node_id == n.id)
412 || (e.from_node_id == n.id && e.to_node_id == decision.id)
413 })
414 })
415 .collect();
416
417 if !observations.is_empty() {
418 wln!(writeup, "**Observations:**\n");
419 for obs in &observations {
420 wln!(writeup, "- {}", obs.title);
421 }
422 wln!(writeup);
423 }
424 }
425 }
426
427 let actions: Vec<&DecisionNode> = filtered
429 .nodes
430 .iter()
431 .filter(|n| n.node_type == "action")
432 .collect();
433
434 if !actions.is_empty() {
435 wln!(writeup, "## Implementation\n");
436
437 for action in &actions {
438 let commit = extract_commit(&action.metadata_json);
439 let commit_badge = commit
440 .as_ref()
441 .map(|c| format!(" `{}`", &c[..7.min(c.len())]))
442 .unwrap_or_default();
443
444 wln!(writeup, "- {}{}", action.title, commit_badge);
445 }
446 wln!(writeup);
447 }
448
449 let outcomes: Vec<&DecisionNode> = filtered
451 .nodes
452 .iter()
453 .filter(|n| n.node_type == "outcome")
454 .collect();
455
456 if !outcomes.is_empty() {
457 wln!(writeup, "## Outcomes\n");
458
459 for outcome in &outcomes {
460 let confidence = extract_confidence(&outcome.metadata_json);
461 let conf_badge = confidence
462 .map(|c| format!(" ({}% confidence)", c))
463 .unwrap_or_default();
464
465 wln!(writeup, "- {}{}", outcome.title, conf_badge);
466 }
467 wln!(writeup);
468 }
469
470 if config.include_dot {
472 wln!(writeup, "## Decision Graph\n");
473
474 let image_url = config.png_filename.as_ref().map(|filename| {
476 if let (Some(repo), Some(branch)) = (&config.github_repo, &config.git_branch) {
477 format!(
478 "https://raw.githubusercontent.com/{}/{}/{}",
479 repo, branch, filename
480 )
481 } else {
482 filename.clone()
484 }
485 });
486
487 if let Some(url) = &image_url {
489 wln!(writeup, "\n", url);
490
491 wln!(writeup, "<details>");
493 wln!(writeup, "<summary>DOT source (click to expand)</summary>\n");
494 }
495
496 wln!(writeup, "```dot");
497 let dot_config = DotConfig {
498 title: Some(config.title.clone()),
499 show_ids: true,
500 show_rationale: false, show_confidence: true,
502 rankdir: "TB".to_string(),
503 };
504 w!(writeup, "{}", graph_to_dot(&filtered, &dot_config));
505 wln!(writeup, "```\n");
506
507 if image_url.is_some() {
508 wln!(writeup, "</details>\n");
509 } else {
510 wln!(
511 writeup,
512 "*Render with: `dot -Tpng graph.dot -o graph.png`*\n"
513 );
514 }
515 }
516
517 if config.include_test_plan {
519 wln!(writeup, "## Test Plan\n");
520
521 let test_items: Vec<String> = outcomes
523 .iter()
524 .filter(|o| o.status == "completed")
525 .map(|o| format!("- [x] {}", o.title))
526 .collect();
527
528 if test_items.is_empty() {
529 wln!(writeup, "- [ ] Verify implementation");
530 wln!(writeup, "- [ ] Run test suite");
531 } else {
532 for item in test_items {
533 wln!(writeup, "{}", item);
534 }
535 }
536 wln!(writeup);
537 }
538
539 if !filtered.nodes.is_empty() {
541 let node_ids: Vec<String> = filtered.nodes.iter().map(|n| n.id.to_string()).collect();
542 wln!(writeup, "## Decision Graph Reference\n");
543 wln!(
544 writeup,
545 "This PR corresponds to deciduous nodes: {}\n",
546 node_ids.join(", ")
547 );
548 }
549
550 writeup
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 fn sample_graph() -> DecisionGraph {
558 DecisionGraph {
559 nodes: vec![
560 DecisionNode {
561 id: 1,
562 change_id: "change-id-1".to_string(),
563 node_type: "goal".to_string(),
564 title: "Build feature X".to_string(),
565 description: None,
566 status: "pending".to_string(),
567 created_at: "2025-01-01T00:00:00Z".to_string(),
568 updated_at: "2025-01-01T00:00:00Z".to_string(),
569 metadata_json: Some(r#"{"confidence":90}"#.to_string()),
570 },
571 DecisionNode {
572 id: 2,
573 change_id: "change-id-2".to_string(),
574 node_type: "decision".to_string(),
575 title: "Choose approach".to_string(),
576 description: None,
577 status: "pending".to_string(),
578 created_at: "2025-01-01T00:00:00Z".to_string(),
579 updated_at: "2025-01-01T00:00:00Z".to_string(),
580 metadata_json: None,
581 },
582 DecisionNode {
583 id: 3,
584 change_id: "change-id-3".to_string(),
585 node_type: "action".to_string(),
586 title: "Implement solution".to_string(),
587 description: None,
588 status: "completed".to_string(),
589 created_at: "2025-01-01T00:00:00Z".to_string(),
590 updated_at: "2025-01-01T00:00:00Z".to_string(),
591 metadata_json: Some(r#"{"commit":"abc1234"}"#.to_string()),
592 },
593 ],
594 edges: vec![
595 DecisionEdge {
596 id: 1,
597 from_node_id: 1,
598 to_node_id: 2,
599 from_change_id: Some("change-id-1".to_string()),
600 to_change_id: Some("change-id-2".to_string()),
601 edge_type: "leads_to".to_string(),
602 weight: Some(1.0),
603 rationale: Some("Goal requires decision".to_string()),
604 created_at: "2025-01-01T00:00:00Z".to_string(),
605 },
606 DecisionEdge {
607 id: 2,
608 from_node_id: 2,
609 to_node_id: 3,
610 from_change_id: Some("change-id-2".to_string()),
611 to_change_id: Some("change-id-3".to_string()),
612 edge_type: "leads_to".to_string(),
613 weight: Some(1.0),
614 rationale: None,
615 created_at: "2025-01-01T00:00:00Z".to_string(),
616 },
617 ],
618 config: None,
619 }
620 }
621
622 #[test]
623 fn test_graph_to_dot() {
624 let graph = sample_graph();
625 let config = DotConfig::default();
626 let dot = graph_to_dot(&graph, &config);
627
628 assert!(dot.contains("digraph DecisionGraph"));
629 assert!(dot.contains("1 [label="));
630 assert!(dot.contains("1 -> 2"));
631 assert!(dot.contains("shape=\"house\"")); assert!(dot.contains("shape=\"diamond\"")); }
634
635 #[test]
636 fn test_filter_graph() {
637 let graph = sample_graph();
638 let filtered = filter_graph_from_roots(&graph, &[1]);
639
640 assert_eq!(filtered.nodes.len(), 3);
641 assert_eq!(filtered.edges.len(), 2);
642 }
643
644 #[test]
645 fn test_generate_writeup() {
646 let graph = sample_graph();
647 let config = WriteupConfig {
648 title: "Test PR".to_string(),
649 root_ids: vec![],
650 include_dot: true,
651 include_test_plan: true,
652 png_filename: None,
653 github_repo: None,
654 git_branch: None,
655 };
656 let writeup = generate_pr_writeup(&graph, &config);
657
658 assert!(writeup.contains("## Summary"));
659 assert!(writeup.contains("Build feature X"));
660 assert!(writeup.contains("## Decision Graph"));
661 assert!(writeup.contains("```dot"));
662 }
663
664 #[test]
665 fn test_extract_confidence() {
666 let meta = Some(r#"{"confidence":85}"#.to_string());
667 assert_eq!(extract_confidence(&meta), Some(85));
668
669 let no_meta: Option<String> = None;
670 assert_eq!(extract_confidence(&no_meta), None);
671 }
672
673 #[test]
674 fn test_extract_commit() {
675 let meta = Some(r#"{"commit":"abc1234"}"#.to_string());
676 assert_eq!(extract_commit(&meta), Some("abc1234".to_string()));
677 }
678
679 #[test]
682 fn test_node_shape() {
683 assert_eq!(node_shape("goal"), "house");
684 assert_eq!(node_shape("decision"), "diamond");
685 assert_eq!(node_shape("option"), "parallelogram");
686 assert_eq!(node_shape("action"), "box");
687 assert_eq!(node_shape("outcome"), "ellipse");
688 assert_eq!(node_shape("observation"), "note");
689 assert_eq!(node_shape("unknown"), "box"); }
691
692 #[test]
693 fn test_node_color() {
694 assert_eq!(node_color("goal"), "#FFE4B5");
695 assert_eq!(node_color("decision"), "#E6E6FA");
696 assert_eq!(node_color("option"), "#E0FFFF");
697 assert_eq!(node_color("action"), "#90EE90");
698 assert_eq!(node_color("outcome"), "#87CEEB");
699 assert_eq!(node_color("observation"), "#DDA0DD");
700 assert_eq!(node_color("unknown"), "#F5F5F5"); }
702
703 #[test]
704 fn test_edge_style() {
705 assert_eq!(edge_style("leads_to"), "solid"); assert_eq!(edge_style("chosen"), "bold");
707 assert_eq!(edge_style("rejected"), "dashed");
708 assert_eq!(edge_style("blocks"), "dotted");
709 assert_eq!(edge_style("unknown"), "solid"); }
711
712 #[test]
713 fn test_edge_color() {
714 assert_eq!(edge_color("leads_to"), "#333333"); assert_eq!(edge_color("chosen"), "#228B22"); assert_eq!(edge_color("rejected"), "#DC143C"); assert_eq!(edge_color("blocks"), "#FF4500"); assert_eq!(edge_color("enables"), "#4169E1"); assert_eq!(edge_color("unknown"), "#333333"); }
721
722 #[test]
723 fn test_escape_dot() {
724 assert_eq!(escape_dot("hello"), "hello");
725 assert_eq!(escape_dot("hello \"world\""), "hello \\\"world\\\"");
726 assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
727 assert_eq!(escape_dot("back\\slash"), "back\\\\slash");
728 }
729
730 #[test]
731 fn test_truncate() {
732 assert_eq!(truncate("hello", 10), "hello");
733 assert_eq!(truncate("hello world", 8), "hello...");
734 assert_eq!(truncate("hi", 2), "hi");
735 assert_eq!(truncate("hello", 5), "hello");
736 }
737
738 #[test]
739 fn test_truncate_unicode() {
740 assert_eq!(truncate("🎉🎊🎁", 10), "🎉🎊🎁");
742 let result = truncate("🎉🎊🎁🎄🎅🎆", 5);
743 assert!(result.ends_with("...") || result.chars().count() <= 5);
744 }
745
746 #[test]
749 fn test_dot_config_default() {
750 let config = DotConfig::default();
751 assert!(config.show_rationale);
752 assert!(config.show_confidence);
753 assert!(config.show_ids);
754 assert_eq!(config.rankdir, "TB");
755 assert!(config.title.is_none());
756 }
757
758 #[test]
759 fn test_dot_with_title() {
760 let graph = sample_graph();
761 let config = DotConfig {
762 title: Some("My Graph".to_string()),
763 ..Default::default()
764 };
765 let dot = graph_to_dot(&graph, &config);
766
767 assert!(dot.contains("label=\"My Graph\""));
768 assert!(dot.contains("labelloc=t"));
769 }
770
771 #[test]
772 fn test_dot_with_custom_rankdir() {
773 let graph = sample_graph();
774 let config = DotConfig {
775 rankdir: "LR".to_string(),
776 ..Default::default()
777 };
778 let dot = graph_to_dot(&graph, &config);
779
780 assert!(dot.contains("rankdir=LR"));
781 }
782
783 #[test]
786 fn test_filter_graph_empty_roots() {
787 let graph = sample_graph();
788 let filtered = filter_graph_from_roots(&graph, &[]);
789
790 assert!(filtered.nodes.is_empty());
792 assert!(filtered.edges.is_empty());
793 }
794
795 #[test]
796 fn test_filter_graph_single_node() {
797 let graph = sample_graph();
798 let filtered = filter_graph_from_roots(&graph, &[3]);
800
801 assert_eq!(filtered.nodes.len(), 1);
802 assert_eq!(filtered.edges.len(), 0);
803 }
804
805 #[test]
806 fn test_filter_graph_nonexistent_root() {
807 let graph = sample_graph();
808 let filtered = filter_graph_from_roots(&graph, &[999]);
809
810 assert!(filtered.nodes.is_empty());
811 }
812
813 #[test]
816 fn test_extract_confidence_invalid_json() {
817 let meta = Some("not json".to_string());
818 assert_eq!(extract_confidence(&meta), None);
819 }
820
821 #[test]
822 fn test_extract_confidence_missing_field() {
823 let meta = Some(r#"{"branch":"main"}"#.to_string());
824 assert_eq!(extract_confidence(&meta), None);
825 }
826
827 #[test]
828 fn test_extract_commit_invalid_json() {
829 let meta = Some("not json".to_string());
830 assert_eq!(extract_commit(&meta), None);
831 }
832
833 #[test]
836 fn test_writeup_without_dot() {
837 let graph = sample_graph();
838 let config = WriteupConfig {
839 title: "No DOT".to_string(),
840 root_ids: vec![],
841 include_dot: false,
842 include_test_plan: true,
843 png_filename: None,
844 github_repo: None,
845 git_branch: None,
846 };
847 let writeup = generate_pr_writeup(&graph, &config);
848
849 assert!(!writeup.contains("```dot"));
850 assert!(!writeup.contains("## Decision Graph\n"));
852 }
853
854 #[test]
855 fn test_writeup_without_test_plan() {
856 let graph = sample_graph();
857 let config = WriteupConfig {
858 title: "No Test Plan".to_string(),
859 root_ids: vec![],
860 include_dot: false,
861 include_test_plan: false,
862 png_filename: None,
863 github_repo: None,
864 git_branch: None,
865 };
866 let writeup = generate_pr_writeup(&graph, &config);
867
868 assert!(!writeup.contains("## Test Plan"));
869 }
870
871 #[test]
872 fn test_writeup_with_png() {
873 let graph = sample_graph();
874 let config = WriteupConfig {
875 title: "With PNG".to_string(),
876 root_ids: vec![],
877 include_dot: true,
878 include_test_plan: false,
879 png_filename: Some("docs/graph.png".to_string()),
880 github_repo: Some("owner/repo".to_string()),
881 git_branch: Some("main".to_string()),
882 };
883 let writeup = generate_pr_writeup(&graph, &config);
884
885 assert!(writeup.contains("![Decision Graph]"));
886 assert!(writeup.contains("raw.githubusercontent.com"));
887 assert!(writeup.contains("<details>")); }
889
890 #[test]
893 fn test_dot_empty_graph() {
894 let graph = DecisionGraph {
895 nodes: vec![],
896 edges: vec![],
897 config: None,
898 };
899 let config = DotConfig::default();
900 let dot = graph_to_dot(&graph, &config);
901
902 assert!(dot.contains("digraph DecisionGraph"));
903 assert!(dot.contains("}"));
904 }
905
906 #[test]
907 fn test_writeup_empty_graph() {
908 let graph = DecisionGraph {
909 nodes: vec![],
910 edges: vec![],
911 config: None,
912 };
913 let config = WriteupConfig {
914 title: "Empty".to_string(),
915 root_ids: vec![],
916 include_dot: false,
917 include_test_plan: false,
918 png_filename: None,
919 github_repo: None,
920 git_branch: None,
921 };
922 let writeup = generate_pr_writeup(&graph, &config);
923
924 assert!(writeup.contains("## Summary"));
926 }
927}