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 themes: graph.themes.clone(),
259 node_themes: graph.node_themes.clone(),
260 documents: graph.documents.clone(),
261 }
262}
263
264pub fn filter_graph_by_ids(graph: &DecisionGraph, node_ids: &[i32]) -> DecisionGraph {
266 let id_set: HashSet<i32> = node_ids.iter().cloned().collect();
267
268 let nodes: Vec<DecisionNode> = graph
269 .nodes
270 .iter()
271 .filter(|n| id_set.contains(&n.id))
272 .cloned()
273 .collect();
274
275 let edges: Vec<DecisionEdge> = graph
276 .edges
277 .iter()
278 .filter(|e| id_set.contains(&e.from_node_id) && id_set.contains(&e.to_node_id))
279 .cloned()
280 .collect();
281
282 DecisionGraph {
283 nodes,
284 edges,
285 config: graph.config.clone(),
286 themes: graph.themes.clone(),
287 node_themes: graph.node_themes.clone(),
288 documents: graph.documents.clone(),
289 }
290}
291
292pub fn parse_node_range(spec: &str) -> Vec<i32> {
294 let mut ids = Vec::new();
295
296 for part in spec.split(',') {
297 let part = part.trim();
298 if part.contains('-') {
299 let parts: Vec<&str> = part.split('-').collect();
300 if parts.len() == 2 {
301 if let (Ok(start), Ok(end)) = (
302 parts[0].trim().parse::<i32>(),
303 parts[1].trim().parse::<i32>(),
304 ) {
305 for id in start..=end {
306 ids.push(id);
307 }
308 }
309 }
310 } else if let Ok(id) = part.parse::<i32>() {
311 ids.push(id);
312 }
313 }
314
315 ids
316}
317
318#[derive(Debug, Clone)]
320pub struct WriteupConfig {
321 pub title: String,
323 pub root_ids: Vec<i32>,
325 pub include_dot: bool,
327 pub include_test_plan: bool,
329 pub png_filename: Option<String>,
331 pub github_repo: Option<String>,
333 pub git_branch: Option<String>,
335}
336
337pub fn generate_pr_writeup(graph: &DecisionGraph, config: &WriteupConfig) -> String {
339 let filtered = if config.root_ids.is_empty() {
340 graph.clone()
341 } else {
342 filter_graph_from_roots(graph, &config.root_ids)
343 };
344
345 let mut writeup = String::new();
346
347 wln!(writeup, "## Summary\n");
349
350 let goals: Vec<&DecisionNode> = filtered
352 .nodes
353 .iter()
354 .filter(|n| n.node_type == "goal")
355 .collect();
356
357 if !goals.is_empty() {
358 for goal in &goals {
359 wln!(writeup, "**Goal:** {}", goal.title);
360 if let Some(desc) = &goal.description {
361 wln!(writeup, "\n{}\n", desc);
362 }
363 }
364 wln!(writeup);
365 }
366
367 let decisions: Vec<&DecisionNode> = filtered
369 .nodes
370 .iter()
371 .filter(|n| n.node_type == "decision")
372 .collect();
373
374 if !decisions.is_empty() {
375 wln!(writeup, "## Key Decisions\n");
376
377 for decision in &decisions {
378 wln!(writeup, "### {}\n", decision.title);
379
380 let decision_options: Vec<&DecisionNode> = filtered
382 .nodes
383 .iter()
384 .filter(|n| {
385 n.node_type == "option"
386 && filtered
387 .edges
388 .iter()
389 .any(|e| e.from_node_id == decision.id && e.to_node_id == n.id)
390 })
391 .collect();
392
393 if !decision_options.is_empty() {
394 wln!(writeup, "**Options considered:**\n");
395 for opt in &decision_options {
396 let marker = if filtered.edges.iter().any(|e| {
397 e.from_node_id == decision.id
398 && e.to_node_id == opt.id
399 && e.edge_type == "chosen"
400 }) {
401 "[x]"
402 } else {
403 "[ ]"
404 };
405 wln!(writeup, "- {} {}", marker, opt.title);
406 }
407 wln!(writeup);
408 }
409
410 let observations: Vec<&DecisionNode> = filtered
412 .nodes
413 .iter()
414 .filter(|n| {
415 n.node_type == "observation"
416 && filtered.edges.iter().any(|e| {
417 (e.from_node_id == decision.id && e.to_node_id == n.id)
418 || (e.from_node_id == n.id && e.to_node_id == decision.id)
419 })
420 })
421 .collect();
422
423 if !observations.is_empty() {
424 wln!(writeup, "**Observations:**\n");
425 for obs in &observations {
426 wln!(writeup, "- {}", obs.title);
427 }
428 wln!(writeup);
429 }
430 }
431 }
432
433 let actions: Vec<&DecisionNode> = filtered
435 .nodes
436 .iter()
437 .filter(|n| n.node_type == "action")
438 .collect();
439
440 if !actions.is_empty() {
441 wln!(writeup, "## Implementation\n");
442
443 for action in &actions {
444 let commit = extract_commit(&action.metadata_json);
445 let commit_badge = commit
446 .as_ref()
447 .map(|c| format!(" `{}`", &c[..7.min(c.len())]))
448 .unwrap_or_default();
449
450 wln!(writeup, "- {}{}", action.title, commit_badge);
451 }
452 wln!(writeup);
453 }
454
455 let outcomes: Vec<&DecisionNode> = filtered
457 .nodes
458 .iter()
459 .filter(|n| n.node_type == "outcome")
460 .collect();
461
462 if !outcomes.is_empty() {
463 wln!(writeup, "## Outcomes\n");
464
465 for outcome in &outcomes {
466 let confidence = extract_confidence(&outcome.metadata_json);
467 let conf_badge = confidence
468 .map(|c| format!(" ({}% confidence)", c))
469 .unwrap_or_default();
470
471 wln!(writeup, "- {}{}", outcome.title, conf_badge);
472 }
473 wln!(writeup);
474 }
475
476 if config.include_dot {
478 wln!(writeup, "## Decision Graph\n");
479
480 let image_url = config.png_filename.as_ref().map(|filename| {
482 if let (Some(repo), Some(branch)) = (&config.github_repo, &config.git_branch) {
483 format!(
484 "https://raw.githubusercontent.com/{}/{}/{}",
485 repo, branch, filename
486 )
487 } else {
488 filename.clone()
490 }
491 });
492
493 if let Some(url) = &image_url {
495 wln!(writeup, "\n", url);
496
497 wln!(writeup, "<details>");
499 wln!(writeup, "<summary>DOT source (click to expand)</summary>\n");
500 }
501
502 wln!(writeup, "```dot");
503 let dot_config = DotConfig {
504 title: Some(config.title.clone()),
505 show_ids: true,
506 show_rationale: false, show_confidence: true,
508 rankdir: "TB".to_string(),
509 };
510 w!(writeup, "{}", graph_to_dot(&filtered, &dot_config));
511 wln!(writeup, "```\n");
512
513 if image_url.is_some() {
514 wln!(writeup, "</details>\n");
515 } else {
516 wln!(
517 writeup,
518 "*Render with: `dot -Tpng graph.dot -o graph.png`*\n"
519 );
520 }
521 }
522
523 if config.include_test_plan {
525 wln!(writeup, "## Test Plan\n");
526
527 let test_items: Vec<String> = outcomes
529 .iter()
530 .filter(|o| o.status == "completed")
531 .map(|o| format!("- [x] {}", o.title))
532 .collect();
533
534 if test_items.is_empty() {
535 wln!(writeup, "- [ ] Verify implementation");
536 wln!(writeup, "- [ ] Run test suite");
537 } else {
538 for item in test_items {
539 wln!(writeup, "{}", item);
540 }
541 }
542 wln!(writeup);
543 }
544
545 if !filtered.nodes.is_empty() {
547 let node_ids: Vec<String> = filtered.nodes.iter().map(|n| n.id.to_string()).collect();
548 wln!(writeup, "## Decision Graph Reference\n");
549 wln!(
550 writeup,
551 "This PR corresponds to deciduous nodes: {}\n",
552 node_ids.join(", ")
553 );
554 }
555
556 writeup
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn sample_graph() -> DecisionGraph {
564 DecisionGraph {
565 nodes: vec![
566 DecisionNode {
567 id: 1,
568 change_id: "change-id-1".to_string(),
569 node_type: "goal".to_string(),
570 title: "Build feature X".to_string(),
571 description: None,
572 status: "pending".to_string(),
573 created_at: "2025-01-01T00:00:00Z".to_string(),
574 updated_at: "2025-01-01T00:00:00Z".to_string(),
575 metadata_json: Some(r#"{"confidence":90}"#.to_string()),
576 },
577 DecisionNode {
578 id: 2,
579 change_id: "change-id-2".to_string(),
580 node_type: "decision".to_string(),
581 title: "Choose approach".to_string(),
582 description: None,
583 status: "pending".to_string(),
584 created_at: "2025-01-01T00:00:00Z".to_string(),
585 updated_at: "2025-01-01T00:00:00Z".to_string(),
586 metadata_json: None,
587 },
588 DecisionNode {
589 id: 3,
590 change_id: "change-id-3".to_string(),
591 node_type: "action".to_string(),
592 title: "Implement solution".to_string(),
593 description: None,
594 status: "completed".to_string(),
595 created_at: "2025-01-01T00:00:00Z".to_string(),
596 updated_at: "2025-01-01T00:00:00Z".to_string(),
597 metadata_json: Some(r#"{"commit":"abc1234"}"#.to_string()),
598 },
599 ],
600 edges: vec![
601 DecisionEdge {
602 id: 1,
603 from_node_id: 1,
604 to_node_id: 2,
605 from_change_id: Some("change-id-1".to_string()),
606 to_change_id: Some("change-id-2".to_string()),
607 edge_type: "leads_to".to_string(),
608 weight: Some(1.0),
609 rationale: Some("Goal requires decision".to_string()),
610 created_at: "2025-01-01T00:00:00Z".to_string(),
611 },
612 DecisionEdge {
613 id: 2,
614 from_node_id: 2,
615 to_node_id: 3,
616 from_change_id: Some("change-id-2".to_string()),
617 to_change_id: Some("change-id-3".to_string()),
618 edge_type: "leads_to".to_string(),
619 weight: Some(1.0),
620 rationale: None,
621 created_at: "2025-01-01T00:00:00Z".to_string(),
622 },
623 ],
624 config: None,
625 themes: vec![],
626 node_themes: vec![],
627 documents: vec![],
628 }
629 }
630
631 #[test]
632 fn test_graph_to_dot() {
633 let graph = sample_graph();
634 let config = DotConfig::default();
635 let dot = graph_to_dot(&graph, &config);
636
637 assert!(dot.contains("digraph DecisionGraph"));
638 assert!(dot.contains("1 [label="));
639 assert!(dot.contains("1 -> 2"));
640 assert!(dot.contains("shape=\"house\"")); assert!(dot.contains("shape=\"diamond\"")); }
643
644 #[test]
645 fn test_filter_graph() {
646 let graph = sample_graph();
647 let filtered = filter_graph_from_roots(&graph, &[1]);
648
649 assert_eq!(filtered.nodes.len(), 3);
650 assert_eq!(filtered.edges.len(), 2);
651 }
652
653 #[test]
654 fn test_generate_writeup() {
655 let graph = sample_graph();
656 let config = WriteupConfig {
657 title: "Test PR".to_string(),
658 root_ids: vec![],
659 include_dot: true,
660 include_test_plan: true,
661 png_filename: None,
662 github_repo: None,
663 git_branch: None,
664 };
665 let writeup = generate_pr_writeup(&graph, &config);
666
667 assert!(writeup.contains("## Summary"));
668 assert!(writeup.contains("Build feature X"));
669 assert!(writeup.contains("## Decision Graph"));
670 assert!(writeup.contains("```dot"));
671 }
672
673 #[test]
674 fn test_extract_confidence() {
675 let meta = Some(r#"{"confidence":85}"#.to_string());
676 assert_eq!(extract_confidence(&meta), Some(85));
677
678 let no_meta: Option<String> = None;
679 assert_eq!(extract_confidence(&no_meta), None);
680 }
681
682 #[test]
683 fn test_extract_commit() {
684 let meta = Some(r#"{"commit":"abc1234"}"#.to_string());
685 assert_eq!(extract_commit(&meta), Some("abc1234".to_string()));
686 }
687
688 #[test]
691 fn test_node_shape() {
692 assert_eq!(node_shape("goal"), "house");
693 assert_eq!(node_shape("decision"), "diamond");
694 assert_eq!(node_shape("option"), "parallelogram");
695 assert_eq!(node_shape("action"), "box");
696 assert_eq!(node_shape("outcome"), "ellipse");
697 assert_eq!(node_shape("observation"), "note");
698 assert_eq!(node_shape("unknown"), "box"); }
700
701 #[test]
702 fn test_node_color() {
703 assert_eq!(node_color("goal"), "#FFE4B5");
704 assert_eq!(node_color("decision"), "#E6E6FA");
705 assert_eq!(node_color("option"), "#E0FFFF");
706 assert_eq!(node_color("action"), "#90EE90");
707 assert_eq!(node_color("outcome"), "#87CEEB");
708 assert_eq!(node_color("observation"), "#DDA0DD");
709 assert_eq!(node_color("unknown"), "#F5F5F5"); }
711
712 #[test]
713 fn test_edge_style() {
714 assert_eq!(edge_style("leads_to"), "solid"); assert_eq!(edge_style("chosen"), "bold");
716 assert_eq!(edge_style("rejected"), "dashed");
717 assert_eq!(edge_style("blocks"), "dotted");
718 assert_eq!(edge_style("unknown"), "solid"); }
720
721 #[test]
722 fn test_edge_color() {
723 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"); }
730
731 #[test]
732 fn test_escape_dot() {
733 assert_eq!(escape_dot("hello"), "hello");
734 assert_eq!(escape_dot("hello \"world\""), "hello \\\"world\\\"");
735 assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
736 assert_eq!(escape_dot("back\\slash"), "back\\\\slash");
737 }
738
739 #[test]
740 fn test_truncate() {
741 assert_eq!(truncate("hello", 10), "hello");
742 assert_eq!(truncate("hello world", 8), "hello...");
743 assert_eq!(truncate("hi", 2), "hi");
744 assert_eq!(truncate("hello", 5), "hello");
745 }
746
747 #[test]
748 fn test_truncate_unicode() {
749 assert_eq!(truncate("🎉🎊🎁", 10), "🎉🎊🎁");
751 let result = truncate("🎉🎊🎁🎄🎅🎆", 5);
752 assert!(result.ends_with("...") || result.chars().count() <= 5);
753 }
754
755 #[test]
758 fn test_dot_config_default() {
759 let config = DotConfig::default();
760 assert!(config.show_rationale);
761 assert!(config.show_confidence);
762 assert!(config.show_ids);
763 assert_eq!(config.rankdir, "TB");
764 assert!(config.title.is_none());
765 }
766
767 #[test]
768 fn test_dot_with_title() {
769 let graph = sample_graph();
770 let config = DotConfig {
771 title: Some("My Graph".to_string()),
772 ..Default::default()
773 };
774 let dot = graph_to_dot(&graph, &config);
775
776 assert!(dot.contains("label=\"My Graph\""));
777 assert!(dot.contains("labelloc=t"));
778 }
779
780 #[test]
781 fn test_dot_with_custom_rankdir() {
782 let graph = sample_graph();
783 let config = DotConfig {
784 rankdir: "LR".to_string(),
785 ..Default::default()
786 };
787 let dot = graph_to_dot(&graph, &config);
788
789 assert!(dot.contains("rankdir=LR"));
790 }
791
792 #[test]
795 fn test_filter_graph_empty_roots() {
796 let graph = sample_graph();
797 let filtered = filter_graph_from_roots(&graph, &[]);
798
799 assert!(filtered.nodes.is_empty());
801 assert!(filtered.edges.is_empty());
802 }
803
804 #[test]
805 fn test_filter_graph_single_node() {
806 let graph = sample_graph();
807 let filtered = filter_graph_from_roots(&graph, &[3]);
809
810 assert_eq!(filtered.nodes.len(), 1);
811 assert_eq!(filtered.edges.len(), 0);
812 }
813
814 #[test]
815 fn test_filter_graph_nonexistent_root() {
816 let graph = sample_graph();
817 let filtered = filter_graph_from_roots(&graph, &[999]);
818
819 assert!(filtered.nodes.is_empty());
820 }
821
822 #[test]
825 fn test_extract_confidence_invalid_json() {
826 let meta = Some("not json".to_string());
827 assert_eq!(extract_confidence(&meta), None);
828 }
829
830 #[test]
831 fn test_extract_confidence_missing_field() {
832 let meta = Some(r#"{"branch":"main"}"#.to_string());
833 assert_eq!(extract_confidence(&meta), None);
834 }
835
836 #[test]
837 fn test_extract_commit_invalid_json() {
838 let meta = Some("not json".to_string());
839 assert_eq!(extract_commit(&meta), None);
840 }
841
842 #[test]
845 fn test_writeup_without_dot() {
846 let graph = sample_graph();
847 let config = WriteupConfig {
848 title: "No DOT".to_string(),
849 root_ids: vec![],
850 include_dot: false,
851 include_test_plan: true,
852 png_filename: None,
853 github_repo: None,
854 git_branch: None,
855 };
856 let writeup = generate_pr_writeup(&graph, &config);
857
858 assert!(!writeup.contains("```dot"));
859 assert!(!writeup.contains("## Decision Graph\n"));
861 }
862
863 #[test]
864 fn test_writeup_without_test_plan() {
865 let graph = sample_graph();
866 let config = WriteupConfig {
867 title: "No Test Plan".to_string(),
868 root_ids: vec![],
869 include_dot: false,
870 include_test_plan: false,
871 png_filename: None,
872 github_repo: None,
873 git_branch: None,
874 };
875 let writeup = generate_pr_writeup(&graph, &config);
876
877 assert!(!writeup.contains("## Test Plan"));
878 }
879
880 #[test]
881 fn test_writeup_with_png() {
882 let graph = sample_graph();
883 let config = WriteupConfig {
884 title: "With PNG".to_string(),
885 root_ids: vec![],
886 include_dot: true,
887 include_test_plan: false,
888 png_filename: Some("docs/graph.png".to_string()),
889 github_repo: Some("owner/repo".to_string()),
890 git_branch: Some("main".to_string()),
891 };
892 let writeup = generate_pr_writeup(&graph, &config);
893
894 assert!(writeup.contains("![Decision Graph]"));
895 assert!(writeup.contains("raw.githubusercontent.com"));
896 assert!(writeup.contains("<details>")); }
898
899 #[test]
902 fn test_dot_empty_graph() {
903 let graph = DecisionGraph {
904 nodes: vec![],
905 edges: vec![],
906 config: None,
907 themes: vec![],
908 node_themes: vec![],
909 documents: vec![],
910 };
911 let config = DotConfig::default();
912 let dot = graph_to_dot(&graph, &config);
913
914 assert!(dot.contains("digraph DecisionGraph"));
915 assert!(dot.contains("}"));
916 }
917
918 #[test]
919 fn test_writeup_empty_graph() {
920 let graph = DecisionGraph {
921 nodes: vec![],
922 edges: vec![],
923 config: None,
924 themes: vec![],
925 node_themes: vec![],
926 documents: vec![],
927 };
928 let config = WriteupConfig {
929 title: "Empty".to_string(),
930 root_ids: vec![],
931 include_dot: false,
932 include_test_plan: false,
933 png_filename: None,
934 github_repo: None,
935 git_branch: None,
936 };
937 let writeup = generate_pr_writeup(&graph, &config);
938
939 assert!(writeup.contains("## Summary"));
941 }
942}