Skip to main content

deciduous/
export.rs

1//! Export utilities for decision graphs
2//!
3//! Provides DOT graph export and PR writeup generation.
4
5use crate::db::{DecisionEdge, DecisionGraph, DecisionNode};
6use std::collections::{HashMap, HashSet};
7use std::fmt::Write;
8
9// Helper macro for infallible String writes
10// Writing to String never fails, but write! returns Result
11// This macro makes intent clear and silences the warning
12macro_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/// Configuration for DOT export
28#[derive(Debug, Clone)]
29pub struct DotConfig {
30    /// Title for the graph
31    pub title: Option<String>,
32    /// Include rationale text on edges
33    pub show_rationale: bool,
34    /// Include confidence values
35    pub show_confidence: bool,
36    /// Include node IDs in labels
37    pub show_ids: bool,
38    /// Orientation: "TB" (top-bottom), "LR" (left-right)
39    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
54/// Get the shape for a node type
55fn 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", // Distinctive shape for pivot points
64        _ => "box",
65    }
66}
67
68/// Get the fill color for a node type
69fn node_color(node_type: &str) -> &'static str {
70    match node_type {
71        "goal" => "#FFE4B5",        // Moccasin (warm yellow)
72        "decision" => "#E6E6FA",    // Lavender
73        "option" => "#E0FFFF",      // Light cyan
74        "action" => "#90EE90",      // Light green
75        "outcome" => "#87CEEB",     // Sky blue
76        "observation" => "#DDA0DD", // Plum
77        "revisit" => "#FFDAB9",     // Peach puff (orange-ish) - pivot point
78        _ => "#F5F5F5",             // White smoke
79    }
80}
81
82/// Get the edge style based on edge type
83fn 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
92/// Get the edge color based on edge type
93fn edge_color(edge_type: &str) -> &'static str {
94    match edge_type {
95        "chosen" => "#228B22",   // Forest green
96        "rejected" => "#DC143C", // Crimson
97        "blocks" => "#FF4500",   // Orange red
98        "enables" => "#4169E1",  // Royal blue
99        _ => "#333333",          // Dark gray
100    }
101}
102
103/// Escape a string for DOT labels
104fn escape_dot(s: &str) -> String {
105    s.replace('\\', "\\\\")
106        .replace('"', "\\\"")
107        .replace('\n', "\\n")
108}
109
110/// Truncate a string to max length (Unicode-safe)
111fn 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
121/// Extract confidence from metadata_json
122fn 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
131/// Extract commit hash from metadata_json
132fn 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
143/// Convert a decision graph to DOT format
144pub fn graph_to_dot(graph: &DecisionGraph, config: &DotConfig) -> String {
145    let mut dot = String::new();
146
147    // Graph header
148    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    // Nodes
161    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    // Edges
189    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
216/// Filter a graph to only include nodes reachable from given root IDs
217pub 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    // Build adjacency map
222    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    // BFS to find all reachable nodes
231    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    // Filter nodes and edges
240    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
264/// Filter a graph to only include specific node IDs (no traversal)
265pub 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
292/// Parse a node range specification (e.g., "1-11" or "1,2,5-10,15")
293pub 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/// Configuration for PR writeup generation
319#[derive(Debug, Clone)]
320pub struct WriteupConfig {
321    /// PR title
322    pub title: String,
323    /// Root node IDs to include in writeup
324    pub root_ids: Vec<i32>,
325    /// Include DOT graph section
326    pub include_dot: bool,
327    /// Include test plan section
328    pub include_test_plan: bool,
329    /// PNG filename (will auto-detect GitHub repo/branch for URL)
330    pub png_filename: Option<String>,
331    /// GitHub repo in format "owner/repo" (auto-detected if not provided)
332    pub github_repo: Option<String>,
333    /// Git branch name (auto-detected if not provided)
334    pub git_branch: Option<String>,
335}
336
337/// Generate a PR writeup from a decision graph
338pub 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    // Title
348    wln!(writeup, "## Summary\n");
349
350    // Goals section
351    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    // Decisions section
368    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            // Find options for this decision
381            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            // Find observations related to this decision
411            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    // Actions section
434    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    // Outcomes section
456    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    // DOT graph section
477    if config.include_dot {
478        wln!(writeup, "## Decision Graph\n");
479
480        // Build image URL if PNG filename provided
481        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                // Fallback to relative path (won't work in PR descriptions but OK for files)
489                filename.clone()
490            }
491        });
492
493        // If image URL available, show the PNG image
494        if let Some(url) = &image_url {
495            wln!(writeup, "![Decision Graph]({})\n", url);
496
497            // Put DOT source in collapsible details
498            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, // Keep DOT compact in writeup
507            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    // Test plan section
524    if config.include_test_plan {
525        wln!(writeup, "## Test Plan\n");
526
527        // Generate test plan from outcomes
528        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    // Decision graph reference
546    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\"")); // goal shape
641        assert!(dot.contains("shape=\"diamond\"")); // decision shape
642    }
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    // === Additional Helper Function Tests ===
689
690    #[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"); // default
699    }
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"); // default: white smoke
710    }
711
712    #[test]
713    fn test_edge_style() {
714        assert_eq!(edge_style("leads_to"), "solid"); // default
715        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"); // default
719    }
720
721    #[test]
722    fn test_edge_color() {
723        assert_eq!(edge_color("leads_to"), "#333333"); // default
724        assert_eq!(edge_color("chosen"), "#228B22"); // forest green
725        assert_eq!(edge_color("rejected"), "#DC143C"); // crimson
726        assert_eq!(edge_color("blocks"), "#FF4500"); // orange red
727        assert_eq!(edge_color("enables"), "#4169E1"); // royal blue
728        assert_eq!(edge_color("unknown"), "#333333"); // default
729    }
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        // Unicode-safe truncation
750        assert_eq!(truncate("🎉🎊🎁", 10), "🎉🎊🎁");
751        let result = truncate("🎉🎊🎁🎄🎅🎆", 5);
752        assert!(result.ends_with("...") || result.chars().count() <= 5);
753    }
754
755    // === DOT Config Tests ===
756
757    #[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    // === Filter Tests ===
793
794    #[test]
795    fn test_filter_graph_empty_roots() {
796        let graph = sample_graph();
797        let filtered = filter_graph_from_roots(&graph, &[]);
798
799        // Empty roots should return empty graph
800        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        // Filter starting from node 3 (leaf)
808        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    // === Extract Tests ===
823
824    #[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    // === Writeup Config Tests ===
843
844    #[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        // Note: "## Decision Graph Reference" is always present, but "## Decision Graph\n" is not
860        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>")); // DOT in collapsible
897    }
898
899    // === Empty Graph Tests ===
900
901    #[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        // Should still produce valid output
940        assert!(writeup.contains("## Summary"));
941    }
942}