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", "took_from" => "#8A2BE2", _ => "#333333", }
102}
103
104fn escape_dot(s: &str) -> String {
106 s.replace('\\', "\\\\")
107 .replace('"', "\\\"")
108 .replace('\n', "\\n")
109}
110
111fn truncate(s: &str, max_len: usize) -> String {
113 if s.chars().count() <= max_len {
114 s.to_string()
115 } else {
116 let char_len = max_len.saturating_sub(3);
117 let truncated: String = s.chars().take(char_len).collect();
118 format!("{}...", truncated)
119 }
120}
121
122fn extract_confidence(metadata: &Option<String>) -> Option<u8> {
124 metadata.as_ref().and_then(|m| {
125 serde_json::from_str::<serde_json::Value>(m)
126 .ok()
127 .and_then(|v| v.get("confidence").and_then(|c| c.as_u64()))
128 .map(|c| c as u8)
129 })
130}
131
132fn extract_commit(metadata: &Option<String>) -> Option<String> {
134 metadata.as_ref().and_then(|m| {
135 serde_json::from_str::<serde_json::Value>(m)
136 .ok()
137 .and_then(|v| {
138 v.get("commit")
139 .and_then(|c| c.as_str().map(|s| s.to_string()))
140 })
141 })
142}
143
144pub fn graph_to_dot(graph: &DecisionGraph, config: &DotConfig) -> String {
146 let mut dot = String::new();
147
148 wln!(dot, "digraph DecisionGraph {{");
150 wln!(dot, " rankdir={};", config.rankdir);
151 wln!(dot, " node [fontname=\"Arial\" fontsize=10];");
152 wln!(dot, " edge [fontname=\"Arial\" fontsize=9];");
153
154 if let Some(title) = &config.title {
155 wln!(dot, " label=\"{}\";", escape_dot(title));
156 wln!(dot, " labelloc=t;");
157 wln!(dot, " fontsize=14;");
158 }
159 wln!(dot);
160
161 for node in &graph.nodes {
163 let mut label = String::new();
164
165 if config.show_ids {
166 w!(label, "[{}] ", node.id);
167 }
168
169 label.push_str(&truncate(&node.title, 40));
170
171 if config.show_confidence {
172 if let Some(conf) = extract_confidence(&node.metadata_json) {
173 w!(label, "\\n({}%)", conf);
174 }
175 }
176
177 wln!(
178 dot,
179 " {} [label=\"{}\" shape=\"{}\" fillcolor=\"{}\" style=\"filled\"];",
180 node.id,
181 escape_dot(&label),
182 node_shape(&node.node_type),
183 node_color(&node.node_type)
184 );
185 }
186
187 wln!(dot);
188
189 for edge in &graph.edges {
191 let mut attrs = vec![
192 format!("style=\"{}\"", edge_style(&edge.edge_type)),
193 format!("color=\"{}\"", edge_color(&edge.edge_type)),
194 ];
195
196 if config.show_rationale {
197 if let Some(rationale) = &edge.rationale {
198 let truncated = truncate(rationale, 30);
199 attrs.push(format!("label=\"{}\"", escape_dot(&truncated)));
200 }
201 }
202
203 wln!(
204 dot,
205 " {} -> {} [{}];",
206 edge.from_node_id,
207 edge.to_node_id,
208 attrs.join(" ")
209 );
210 }
211
212 wln!(dot, "}}");
213
214 dot
215}
216
217pub fn filter_graph_from_roots(graph: &DecisionGraph, root_ids: &[i32]) -> DecisionGraph {
219 let mut reachable: HashSet<i32> = HashSet::new();
220 let mut to_visit: Vec<i32> = root_ids.to_vec();
221
222 let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
224 for edge in &graph.edges {
225 children
226 .entry(edge.from_node_id)
227 .or_default()
228 .push(edge.to_node_id);
229 }
230
231 while let Some(node_id) = to_visit.pop() {
233 if reachable.insert(node_id) {
234 if let Some(kids) = children.get(&node_id) {
235 to_visit.extend(kids);
236 }
237 }
238 }
239
240 let nodes: Vec<DecisionNode> = graph
242 .nodes
243 .iter()
244 .filter(|n| reachable.contains(&n.id))
245 .cloned()
246 .collect();
247
248 let edges: Vec<DecisionEdge> = graph
249 .edges
250 .iter()
251 .filter(|e| reachable.contains(&e.from_node_id) && reachable.contains(&e.to_node_id))
252 .cloned()
253 .collect();
254
255 DecisionGraph {
256 nodes,
257 edges,
258 config: graph.config.clone(),
259 themes: graph.themes.clone(),
260 node_themes: graph.node_themes.clone(),
261 documents: graph.documents.clone(),
262 }
263}
264
265pub fn filter_graph_by_ids(graph: &DecisionGraph, node_ids: &[i32]) -> DecisionGraph {
267 let id_set: HashSet<i32> = node_ids.iter().cloned().collect();
268
269 let nodes: Vec<DecisionNode> = graph
270 .nodes
271 .iter()
272 .filter(|n| id_set.contains(&n.id))
273 .cloned()
274 .collect();
275
276 let edges: Vec<DecisionEdge> = graph
277 .edges
278 .iter()
279 .filter(|e| id_set.contains(&e.from_node_id) && id_set.contains(&e.to_node_id))
280 .cloned()
281 .collect();
282
283 DecisionGraph {
284 nodes,
285 edges,
286 config: graph.config.clone(),
287 themes: graph.themes.clone(),
288 node_themes: graph.node_themes.clone(),
289 documents: graph.documents.clone(),
290 }
291}
292
293#[derive(Debug, Clone, PartialEq)]
298pub struct NodeSpec {
299 ranges: Vec<(i32, i32)>,
301}
302
303impl NodeSpec {
304 pub fn contains(&self, id: i32) -> bool {
305 let i = self.ranges.partition_point(|&(start, _)| start <= id);
306 i > 0 && id <= self.ranges[i - 1].1
307 }
308
309 pub fn select(&self, graph: &DecisionGraph) -> Vec<i32> {
311 graph
312 .nodes
313 .iter()
314 .map(|n| n.id)
315 .filter(|&id| self.contains(id))
316 .collect()
317 }
318}
319
320pub fn parse_node_range(spec: &str) -> Result<NodeSpec, String> {
326 let id = |s: &str, part: &str| {
327 s.trim()
328 .parse::<i32>()
329 .map_err(|_| format!("nodes: {part:?} is not a node id or a range like 3-7"))
330 };
331 let mut ranges = Vec::new();
332 for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
333 let range = match part.split_once('-') {
334 Some((a, b)) => (id(a, part)?, id(b, part)?),
335 None => {
336 let n = id(part, part)?;
337 (n, n)
338 }
339 };
340 if range.0 > range.1 {
341 return Err(format!(
342 "nodes: {part:?} is an empty range (it runs backwards)"
343 ));
344 }
345 ranges.push(range);
346 }
347 if ranges.is_empty() {
348 return Err(format!("nodes: {spec:?} names no node ids"));
349 }
350 ranges.sort_unstable();
351 let mut merged: Vec<(i32, i32)> = Vec::with_capacity(ranges.len());
352 for (start, end) in ranges {
353 match merged.last_mut() {
354 Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end),
355 _ => merged.push((start, end)),
356 }
357 }
358 Ok(NodeSpec { ranges: merged })
359}
360
361pub fn parse_root_ids(spec: &str) -> Result<Vec<i32>, String> {
364 let ids = spec
365 .split(',')
366 .map(str::trim)
367 .filter(|s| !s.is_empty())
368 .map(|s| {
369 s.parse::<i32>()
370 .map_err(|_| format!("roots: {s:?} is not a node id"))
371 })
372 .collect::<Result<Vec<_>, _>>()?;
373 if ids.is_empty() {
374 return Err(format!("roots: {spec:?} names no node ids"));
375 }
376 Ok(ids)
377}
378
379#[derive(Debug, Clone)]
381pub struct WriteupConfig {
382 pub title: String,
384 pub root_ids: Vec<i32>,
386 pub include_dot: bool,
388 pub include_test_plan: bool,
390 pub png_filename: Option<String>,
392 pub github_repo: Option<String>,
394 pub git_branch: Option<String>,
396}
397
398pub const RANKDIRS: &[&str] = &["TB", "LR", "BT", "RL"];
402
403pub fn validate_rankdir(rankdir: &str) -> Result<(), String> {
404 if RANKDIRS.contains(&rankdir) {
405 Ok(())
406 } else {
407 Err(format!(
408 "rankdir must be one of {}, got {:?}",
409 RANKDIRS.join(", "),
410 rankdir
411 ))
412 }
413}
414
415fn one_line(s: &str) -> String {
419 s.split_whitespace().collect::<Vec<_>>().join(" ")
420}
421
422fn quote_block(s: &str) -> String {
430 s.lines()
431 .map(|l| {
432 if l.trim().is_empty() {
433 ">".to_string()
434 } else {
435 format!("> {l}")
436 }
437 })
438 .collect::<Vec<_>>()
439 .join("\n")
440}
441
442pub fn generate_pr_writeup(graph: &DecisionGraph, config: &WriteupConfig) -> String {
444 let filtered = if config.root_ids.is_empty() {
445 graph.clone()
446 } else {
447 filter_graph_from_roots(graph, &config.root_ids)
448 };
449
450 let mut writeup = String::new();
451
452 wln!(writeup, "## Summary\n");
454
455 let goals: Vec<&DecisionNode> = filtered
457 .nodes
458 .iter()
459 .filter(|n| n.node_type == "goal")
460 .collect();
461
462 if !goals.is_empty() {
463 for goal in &goals {
464 wln!(writeup, "**Goal:** {}", one_line(&goal.title));
465 if let Some(desc) = &goal.description {
466 wln!(writeup, "\n{}\n", quote_block(desc));
467 }
468 }
469 wln!(writeup);
470 }
471
472 let decisions: Vec<&DecisionNode> = filtered
474 .nodes
475 .iter()
476 .filter(|n| n.node_type == "decision")
477 .collect();
478
479 if !decisions.is_empty() {
480 wln!(writeup, "## Key Decisions\n");
481
482 for decision in &decisions {
483 wln!(writeup, "### {}\n", one_line(&decision.title));
484
485 let decision_options: Vec<&DecisionNode> = filtered
487 .nodes
488 .iter()
489 .filter(|n| {
490 n.node_type == "option"
491 && filtered
492 .edges
493 .iter()
494 .any(|e| e.from_node_id == decision.id && e.to_node_id == n.id)
495 })
496 .collect();
497
498 if !decision_options.is_empty() {
499 wln!(writeup, "**Options considered:**\n");
500 for opt in &decision_options {
501 let marker = if filtered.edges.iter().any(|e| {
502 e.from_node_id == decision.id
503 && e.to_node_id == opt.id
504 && e.edge_type == "chosen"
505 }) {
506 "[x]"
507 } else {
508 "[ ]"
509 };
510 wln!(writeup, "- {} {}", marker, one_line(&opt.title));
511 }
512 wln!(writeup);
513 }
514
515 let observations: Vec<&DecisionNode> = filtered
517 .nodes
518 .iter()
519 .filter(|n| {
520 n.node_type == "observation"
521 && filtered.edges.iter().any(|e| {
522 (e.from_node_id == decision.id && e.to_node_id == n.id)
523 || (e.from_node_id == n.id && e.to_node_id == decision.id)
524 })
525 })
526 .collect();
527
528 if !observations.is_empty() {
529 wln!(writeup, "**Observations:**\n");
530 for obs in &observations {
531 wln!(writeup, "- {}", one_line(&obs.title));
532 }
533 wln!(writeup);
534 }
535 }
536 }
537
538 let actions: Vec<&DecisionNode> = filtered
540 .nodes
541 .iter()
542 .filter(|n| n.node_type == "action")
543 .collect();
544
545 if !actions.is_empty() {
546 wln!(writeup, "## Implementation\n");
547
548 for action in &actions {
549 let commit = extract_commit(&action.metadata_json);
550 let commit_badge = commit
551 .as_ref()
552 .map(|c| format!(" `{}`", c.chars().take(7).collect::<String>()))
555 .unwrap_or_default();
556
557 wln!(writeup, "- {}{}", one_line(&action.title), commit_badge);
558 }
559 wln!(writeup);
560 }
561
562 let outcomes: Vec<&DecisionNode> = filtered
564 .nodes
565 .iter()
566 .filter(|n| n.node_type == "outcome")
567 .collect();
568
569 if !outcomes.is_empty() {
570 wln!(writeup, "## Outcomes\n");
571
572 for outcome in &outcomes {
573 let confidence = extract_confidence(&outcome.metadata_json);
574 let conf_badge = confidence
575 .map(|c| format!(" ({}% confidence)", c))
576 .unwrap_or_default();
577
578 wln!(writeup, "- {}{}", one_line(&outcome.title), conf_badge);
579 }
580 wln!(writeup);
581 }
582
583 if config.include_dot {
585 wln!(writeup, "## Decision Graph\n");
586
587 let image_url = config.png_filename.as_ref().map(|filename| {
589 if let (Some(repo), Some(branch)) = (&config.github_repo, &config.git_branch) {
590 format!(
591 "https://raw.githubusercontent.com/{}/{}/{}",
592 repo, branch, filename
593 )
594 } else {
595 filename.clone()
597 }
598 });
599
600 if let Some(url) = &image_url {
602 wln!(writeup, "\n", url);
603
604 wln!(writeup, "<details>");
606 wln!(writeup, "<summary>DOT source (click to expand)</summary>\n");
607 }
608
609 wln!(writeup, "```dot");
610 let dot_config = DotConfig {
611 title: Some(config.title.clone()),
612 show_ids: true,
613 show_rationale: false, show_confidence: true,
615 rankdir: "TB".to_string(),
616 };
617 w!(writeup, "{}", graph_to_dot(&filtered, &dot_config));
618 wln!(writeup, "```\n");
619
620 if image_url.is_some() {
621 wln!(writeup, "</details>\n");
622 } else {
623 wln!(
624 writeup,
625 "*Render with: `dot -Tpng graph.dot -o graph.png`*\n"
626 );
627 }
628 }
629
630 if config.include_test_plan {
632 wln!(writeup, "## Test Plan\n");
633
634 let test_items: Vec<String> = outcomes
636 .iter()
637 .filter(|o| o.status == "completed")
638 .map(|o| format!("- [x] {}", one_line(&o.title)))
639 .collect();
640
641 if test_items.is_empty() {
642 wln!(writeup, "- [ ] Verify implementation");
643 wln!(writeup, "- [ ] Run test suite");
644 } else {
645 for item in test_items {
646 wln!(writeup, "{}", item);
647 }
648 }
649 wln!(writeup);
650 }
651
652 if !filtered.nodes.is_empty() {
654 let node_ids: Vec<String> = filtered.nodes.iter().map(|n| n.id.to_string()).collect();
655 wln!(writeup, "## Decision Graph Reference\n");
656 wln!(
657 writeup,
658 "This PR corresponds to deciduous nodes: {}\n",
659 node_ids.join(", ")
660 );
661 }
662
663 writeup
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 fn sample_graph() -> DecisionGraph {
671 DecisionGraph {
672 nodes: vec![
673 DecisionNode {
674 id: 1,
675 change_id: "change-id-1".to_string(),
676 node_type: "goal".to_string(),
677 title: "Build feature X".to_string(),
678 description: None,
679 status: "pending".to_string(),
680 created_at: "2025-01-01T00:00:00Z".to_string(),
681 updated_at: "2025-01-01T00:00:00Z".to_string(),
682 metadata_json: Some(r#"{"confidence":90}"#.to_string()),
683 },
684 DecisionNode {
685 id: 2,
686 change_id: "change-id-2".to_string(),
687 node_type: "decision".to_string(),
688 title: "Choose approach".to_string(),
689 description: None,
690 status: "pending".to_string(),
691 created_at: "2025-01-01T00:00:00Z".to_string(),
692 updated_at: "2025-01-01T00:00:00Z".to_string(),
693 metadata_json: None,
694 },
695 DecisionNode {
696 id: 3,
697 change_id: "change-id-3".to_string(),
698 node_type: "action".to_string(),
699 title: "Implement solution".to_string(),
700 description: None,
701 status: "completed".to_string(),
702 created_at: "2025-01-01T00:00:00Z".to_string(),
703 updated_at: "2025-01-01T00:00:00Z".to_string(),
704 metadata_json: Some(r#"{"commit":"abc1234"}"#.to_string()),
705 },
706 ],
707 edges: vec![
708 DecisionEdge {
709 id: 1,
710 from_node_id: 1,
711 to_node_id: 2,
712 from_change_id: Some("change-id-1".to_string()),
713 to_change_id: Some("change-id-2".to_string()),
714 edge_type: "leads_to".to_string(),
715 weight: Some(1.0),
716 rationale: Some("Goal requires decision".to_string()),
717 created_at: "2025-01-01T00:00:00Z".to_string(),
718 },
719 DecisionEdge {
720 id: 2,
721 from_node_id: 2,
722 to_node_id: 3,
723 from_change_id: Some("change-id-2".to_string()),
724 to_change_id: Some("change-id-3".to_string()),
725 edge_type: "leads_to".to_string(),
726 weight: Some(1.0),
727 rationale: None,
728 created_at: "2025-01-01T00:00:00Z".to_string(),
729 },
730 ],
731 config: None,
732 themes: vec![],
733 node_themes: vec![],
734 documents: vec![],
735 }
736 }
737
738 #[test]
739 fn test_graph_to_dot() {
740 let graph = sample_graph();
741 let config = DotConfig::default();
742 let dot = graph_to_dot(&graph, &config);
743
744 assert!(dot.contains("digraph DecisionGraph"));
745 assert!(dot.contains("1 [label="));
746 assert!(dot.contains("1 -> 2"));
747 assert!(dot.contains("shape=\"house\"")); assert!(dot.contains("shape=\"diamond\"")); }
750
751 #[test]
752 fn test_filter_graph() {
753 let graph = sample_graph();
754 let filtered = filter_graph_from_roots(&graph, &[1]);
755
756 assert_eq!(filtered.nodes.len(), 3);
757 assert_eq!(filtered.edges.len(), 2);
758 }
759
760 #[test]
761 fn test_generate_writeup() {
762 let graph = sample_graph();
763 let config = WriteupConfig {
764 title: "Test PR".to_string(),
765 root_ids: vec![],
766 include_dot: true,
767 include_test_plan: true,
768 png_filename: None,
769 github_repo: None,
770 git_branch: None,
771 };
772 let writeup = generate_pr_writeup(&graph, &config);
773
774 assert!(writeup.contains("## Summary"));
775 assert!(writeup.contains("Build feature X"));
776 assert!(writeup.contains("## Decision Graph"));
777 assert!(writeup.contains("```dot"));
778 }
779
780 #[test]
781 fn node_spec_keeps_ranges_and_refuses_what_it_cannot_read() {
782 let spec = parse_node_range("1-3, 5, 2-4, 2147483640-2147483647").unwrap();
783 for id in [1, 2, 3, 4, 5, 2147483647] {
784 assert!(spec.contains(id), "{id}");
785 }
786 for id in [0, 6, -1, 2147483639] {
787 assert!(!spec.contains(id), "{id}");
788 }
789 assert_eq!(spec.ranges, vec![(1, 5), (2147483640, 2147483647)]);
790 for bad in ["abc", "1,abc", "5-1", "", " , ", "1-", "-3", "1-2-3"] {
791 assert!(parse_node_range(bad).is_err(), "{bad:?}");
792 }
793 assert!(parse_root_ids("1, 2").is_ok());
794 for bad in ["", "x", "1,x"] {
795 assert!(parse_root_ids(bad).is_err(), "{bad:?}");
796 }
797 }
798
799 #[test]
800 fn test_extract_confidence() {
801 let meta = Some(r#"{"confidence":85}"#.to_string());
802 assert_eq!(extract_confidence(&meta), Some(85));
803
804 let no_meta: Option<String> = None;
805 assert_eq!(extract_confidence(&no_meta), None);
806 }
807
808 #[test]
809 fn test_extract_commit() {
810 let meta = Some(r#"{"commit":"abc1234"}"#.to_string());
811 assert_eq!(extract_commit(&meta), Some("abc1234".to_string()));
812 }
813
814 #[test]
817 fn test_node_shape() {
818 assert_eq!(node_shape("goal"), "house");
819 assert_eq!(node_shape("decision"), "diamond");
820 assert_eq!(node_shape("option"), "parallelogram");
821 assert_eq!(node_shape("action"), "box");
822 assert_eq!(node_shape("outcome"), "ellipse");
823 assert_eq!(node_shape("observation"), "note");
824 assert_eq!(node_shape("unknown"), "box"); }
826
827 #[test]
828 fn test_node_color() {
829 assert_eq!(node_color("goal"), "#FFE4B5");
830 assert_eq!(node_color("decision"), "#E6E6FA");
831 assert_eq!(node_color("option"), "#E0FFFF");
832 assert_eq!(node_color("action"), "#90EE90");
833 assert_eq!(node_color("outcome"), "#87CEEB");
834 assert_eq!(node_color("observation"), "#DDA0DD");
835 assert_eq!(node_color("unknown"), "#F5F5F5"); }
837
838 #[test]
839 fn test_edge_style() {
840 assert_eq!(edge_style("leads_to"), "solid"); assert_eq!(edge_style("chosen"), "bold");
842 assert_eq!(edge_style("rejected"), "dashed");
843 assert_eq!(edge_style("blocks"), "dotted");
844 assert_eq!(edge_style("unknown"), "solid"); }
846
847 #[test]
848 fn test_edge_color() {
849 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("took_from"), "#8A2BE2"); assert_eq!(edge_color("unknown"), "#333333"); }
857
858 #[test]
859 fn test_escape_dot() {
860 assert_eq!(escape_dot("hello"), "hello");
861 assert_eq!(escape_dot("hello \"world\""), "hello \\\"world\\\"");
862 assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
863 assert_eq!(escape_dot("back\\slash"), "back\\\\slash");
864 }
865
866 #[test]
867 fn test_truncate() {
868 assert_eq!(truncate("hello", 10), "hello");
869 assert_eq!(truncate("hello world", 8), "hello...");
870 assert_eq!(truncate("hi", 2), "hi");
871 assert_eq!(truncate("hello", 5), "hello");
872 }
873
874 #[test]
875 fn test_truncate_unicode() {
876 assert_eq!(truncate("🎉🎊🎁", 10), "🎉🎊🎁");
878 let result = truncate("🎉🎊🎁🎄🎅🎆", 5);
879 assert!(result.ends_with("...") || result.chars().count() <= 5);
880 }
881
882 #[test]
885 fn test_dot_config_default() {
886 let config = DotConfig::default();
887 assert!(config.show_rationale);
888 assert!(config.show_confidence);
889 assert!(config.show_ids);
890 assert_eq!(config.rankdir, "TB");
891 assert!(config.title.is_none());
892 }
893
894 #[test]
895 fn test_dot_with_title() {
896 let graph = sample_graph();
897 let config = DotConfig {
898 title: Some("My Graph".to_string()),
899 ..Default::default()
900 };
901 let dot = graph_to_dot(&graph, &config);
902
903 assert!(dot.contains("label=\"My Graph\""));
904 assert!(dot.contains("labelloc=t"));
905 }
906
907 #[test]
908 fn test_dot_with_custom_rankdir() {
909 let graph = sample_graph();
910 let config = DotConfig {
911 rankdir: "LR".to_string(),
912 ..Default::default()
913 };
914 let dot = graph_to_dot(&graph, &config);
915
916 assert!(dot.contains("rankdir=LR"));
917 }
918
919 #[test]
922 fn test_filter_graph_empty_roots() {
923 let graph = sample_graph();
924 let filtered = filter_graph_from_roots(&graph, &[]);
925
926 assert!(filtered.nodes.is_empty());
928 assert!(filtered.edges.is_empty());
929 }
930
931 #[test]
932 fn test_filter_graph_single_node() {
933 let graph = sample_graph();
934 let filtered = filter_graph_from_roots(&graph, &[3]);
936
937 assert_eq!(filtered.nodes.len(), 1);
938 assert_eq!(filtered.edges.len(), 0);
939 }
940
941 #[test]
942 fn test_filter_graph_nonexistent_root() {
943 let graph = sample_graph();
944 let filtered = filter_graph_from_roots(&graph, &[999]);
945
946 assert!(filtered.nodes.is_empty());
947 }
948
949 #[test]
952 fn test_extract_confidence_invalid_json() {
953 let meta = Some("not json".to_string());
954 assert_eq!(extract_confidence(&meta), None);
955 }
956
957 #[test]
958 fn test_extract_confidence_missing_field() {
959 let meta = Some(r#"{"branch":"main"}"#.to_string());
960 assert_eq!(extract_confidence(&meta), None);
961 }
962
963 #[test]
964 fn test_extract_commit_invalid_json() {
965 let meta = Some("not json".to_string());
966 assert_eq!(extract_commit(&meta), None);
967 }
968
969 #[test]
972 fn test_writeup_without_dot() {
973 let graph = sample_graph();
974 let config = WriteupConfig {
975 title: "No DOT".to_string(),
976 root_ids: vec![],
977 include_dot: false,
978 include_test_plan: true,
979 png_filename: None,
980 github_repo: None,
981 git_branch: None,
982 };
983 let writeup = generate_pr_writeup(&graph, &config);
984
985 assert!(!writeup.contains("```dot"));
986 assert!(!writeup.contains("## Decision Graph\n"));
988 }
989
990 #[test]
991 fn test_writeup_without_test_plan() {
992 let graph = sample_graph();
993 let config = WriteupConfig {
994 title: "No Test Plan".to_string(),
995 root_ids: vec![],
996 include_dot: false,
997 include_test_plan: false,
998 png_filename: None,
999 github_repo: None,
1000 git_branch: None,
1001 };
1002 let writeup = generate_pr_writeup(&graph, &config);
1003
1004 assert!(!writeup.contains("## Test Plan"));
1005 }
1006
1007 #[test]
1008 fn test_writeup_with_png() {
1009 let graph = sample_graph();
1010 let config = WriteupConfig {
1011 title: "With PNG".to_string(),
1012 root_ids: vec![],
1013 include_dot: true,
1014 include_test_plan: false,
1015 png_filename: Some("docs/graph.png".to_string()),
1016 github_repo: Some("owner/repo".to_string()),
1017 git_branch: Some("main".to_string()),
1018 };
1019 let writeup = generate_pr_writeup(&graph, &config);
1020
1021 assert!(writeup.contains("![Decision Graph]"));
1022 assert!(writeup.contains("raw.githubusercontent.com"));
1023 assert!(writeup.contains("<details>")); }
1025
1026 #[test]
1029 fn test_dot_empty_graph() {
1030 let graph = DecisionGraph {
1031 nodes: vec![],
1032 edges: vec![],
1033 config: None,
1034 themes: vec![],
1035 node_themes: vec![],
1036 documents: vec![],
1037 };
1038 let config = DotConfig::default();
1039 let dot = graph_to_dot(&graph, &config);
1040
1041 assert!(dot.contains("digraph DecisionGraph"));
1042 assert!(dot.contains("}"));
1043 }
1044
1045 #[test]
1046 fn test_writeup_empty_graph() {
1047 let graph = DecisionGraph {
1048 nodes: vec![],
1049 edges: vec![],
1050 config: None,
1051 themes: vec![],
1052 node_themes: vec![],
1053 documents: vec![],
1054 };
1055 let config = WriteupConfig {
1056 title: "Empty".to_string(),
1057 root_ids: vec![],
1058 include_dot: false,
1059 include_test_plan: false,
1060 png_filename: None,
1061 github_repo: None,
1062 git_branch: None,
1063 };
1064 let writeup = generate_pr_writeup(&graph, &config);
1065
1066 assert!(writeup.contains("## Summary"));
1068 }
1069}