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        "took_from" => "#8A2BE2", // Blue violet: a borrow, usually across branches
100        _ => "#333333",           // Dark gray
101    }
102}
103
104/// Escape a string for DOT labels
105fn escape_dot(s: &str) -> String {
106    s.replace('\\', "\\\\")
107        .replace('"', "\\\"")
108        .replace('\n', "\\n")
109}
110
111/// Truncate a string to max length (Unicode-safe)
112fn 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
122/// Extract confidence from metadata_json
123fn 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
132/// Extract commit hash from metadata_json
133fn 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
144/// Convert a decision graph to DOT format
145pub fn graph_to_dot(graph: &DecisionGraph, config: &DotConfig) -> String {
146    let mut dot = String::new();
147
148    // Graph header
149    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    // Nodes
162    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    // Edges
190    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
217/// Filter a graph to only include nodes reachable from given root IDs
218pub 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    // Build adjacency map
223    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    // BFS to find all reachable nodes
232    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    // Filter nodes and edges
241    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
265/// Filter a graph to only include specific node IDs (no traversal)
266pub 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/// A set of node ids written as `"1-11"` or `"1,2,5-10,15"`, kept as
294/// ranges. Expanding `0-2147483647` into a Vec of every id in it took 8.6 GB
295/// before the graph was even looked at; a range is only ever compared
296/// against the ids the graph has.
297#[derive(Debug, Clone, PartialEq)]
298pub struct NodeSpec {
299    /// Sorted by start, overlapping ranges merged.
300    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    /// The ids in `graph` this spec names, in graph order.
310    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
320/// Parse a node range specification (e.g., "1-11" or "1,2,5-10,15").
321///
322/// A part that is not an id or a range is an error that names it. Skipping
323/// it exported a different subgraph than the one asked for, and `"abc"`
324/// gave an empty graph with no error at all.
325pub 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
361/// `"1,4,9"` into root ids. A part that is not an id is an error, and so is
362/// a spec with no ids in it: both used to export an empty graph, silently.
363pub 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/// Configuration for PR writeup generation
380#[derive(Debug, Clone)]
381pub struct WriteupConfig {
382    /// PR title
383    pub title: String,
384    /// Root node IDs to include in writeup
385    pub root_ids: Vec<i32>,
386    /// Include DOT graph section
387    pub include_dot: bool,
388    /// Include test plan section
389    pub include_test_plan: bool,
390    /// PNG filename (will auto-detect GitHub repo/branch for URL)
391    pub png_filename: Option<String>,
392    /// GitHub repo in format "owner/repo" (auto-detected if not provided)
393    pub github_repo: Option<String>,
394    /// Git branch name (auto-detected if not provided)
395    pub git_branch: Option<String>,
396}
397
398/// Directions Graphviz understands for `rankdir`. Anything else is refused:
399/// the value is written into the DOT source as-is, so
400/// `LR; injected_node [label="x"]` used to add a node to the graph.
401pub 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
415/// A title as one markdown line. Titles are single-line by intent, but
416/// nothing stops a newline in one, and in a list item or heading it ends
417/// the line: the rest became a heading or a checked checkbox of its own.
418fn one_line(s: &str) -> String {
419    s.split_whitespace().collect::<Vec<_>>().join(" ")
420}
421
422/// A description as a block quote. It is markdown the writeup did not
423/// write: an unclosed ``` in it swallowed every goal after it, and the
424/// ```dot fence further down then opened where it should have closed. Inside
425/// a block quote a fence ends with the quote (CommonMark 4.5), and so does a
426/// heading, so nothing in it reaches the rest of the document. Escaping it
427/// instead would be the obvious fix and would also mangle the markdown the
428/// author meant to write.
429fn 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
442/// Generate a PR writeup from a decision graph
443pub 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    // Title
453    wln!(writeup, "## Summary\n");
454
455    // Goals section
456    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    // Decisions section
473    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            // Find options for this decision
486            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            // Find observations related to this decision
516            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    // Actions section
539    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                // Seven characters, not seven bytes: a byte slice panicked
553                // on a multi-byte commit value and killed the MCP server.
554                .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    // Outcomes section
563    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    // DOT graph section
584    if config.include_dot {
585        wln!(writeup, "## Decision Graph\n");
586
587        // Build image URL if PNG filename provided
588        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                // Fallback to relative path (won't work in PR descriptions but OK for files)
596                filename.clone()
597            }
598        });
599
600        // If image URL available, show the PNG image
601        if let Some(url) = &image_url {
602            wln!(writeup, "![Decision Graph]({})\n", url);
603
604            // Put DOT source in collapsible details
605            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, // Keep DOT compact in writeup
614            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    // Test plan section
631    if config.include_test_plan {
632        wln!(writeup, "## Test Plan\n");
633
634        // Generate test plan from outcomes
635        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    // Decision graph reference
653    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\"")); // goal shape
748        assert!(dot.contains("shape=\"diamond\"")); // decision shape
749    }
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    // === Additional Helper Function Tests ===
815
816    #[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"); // default
825    }
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"); // default: white smoke
836    }
837
838    #[test]
839    fn test_edge_style() {
840        assert_eq!(edge_style("leads_to"), "solid"); // default
841        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"); // default
845    }
846
847    #[test]
848    fn test_edge_color() {
849        assert_eq!(edge_color("leads_to"), "#333333"); // default
850        assert_eq!(edge_color("chosen"), "#228B22"); // forest green
851        assert_eq!(edge_color("rejected"), "#DC143C"); // crimson
852        assert_eq!(edge_color("blocks"), "#FF4500"); // orange red
853        assert_eq!(edge_color("enables"), "#4169E1"); // royal blue
854        assert_eq!(edge_color("took_from"), "#8A2BE2"); // blue violet
855        assert_eq!(edge_color("unknown"), "#333333"); // default
856    }
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        // Unicode-safe truncation
877        assert_eq!(truncate("🎉🎊🎁", 10), "🎉🎊🎁");
878        let result = truncate("🎉🎊🎁🎄🎅🎆", 5);
879        assert!(result.ends_with("...") || result.chars().count() <= 5);
880    }
881
882    // === DOT Config Tests ===
883
884    #[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    // === Filter Tests ===
920
921    #[test]
922    fn test_filter_graph_empty_roots() {
923        let graph = sample_graph();
924        let filtered = filter_graph_from_roots(&graph, &[]);
925
926        // Empty roots should return empty graph
927        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        // Filter starting from node 3 (leaf)
935        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    // === Extract Tests ===
950
951    #[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    // === Writeup Config Tests ===
970
971    #[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        // Note: "## Decision Graph Reference" is always present, but "## Decision Graph\n" is not
987        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>")); // DOT in collapsible
1024    }
1025
1026    // === Empty Graph Tests ===
1027
1028    #[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        // Should still produce valid output
1067        assert!(writeup.contains("## Summary"));
1068    }
1069}