Skip to main content

ralph_workflow/reducer/
xml_renderer.rs

1//! Semantic XML renderers for user-friendly output.
2//!
3//! This module provides pure functions that transform raw XML into
4//! human-readable terminal output. Each XML type gets a dedicated
5//! renderer with semantic understanding of its content.
6//!
7//! # Architecture
8//!
9//! The renderers receive raw XML from `UIEvent::XmlOutput` events and
10//! transform them into formatted strings for terminal display. This keeps
11//! rendering logic at the boundary (event loop) rather than in phase functions.
12//!
13//! # Graceful Degradation
14//!
15//! If XML parsing fails, renderers fall back to displaying the raw XML
16//! with a warning message. This ensures users always see output even if
17//! the format is unexpected.
18
19use super::ui_event::{XmlCodeSnippet, XmlOutputContext, XmlOutputType};
20use crate::files::llm_output_extraction::xsd_validation_plan::{FileAction, Priority, Severity};
21use crate::files::llm_output_extraction::{
22    validate_development_result_xml, validate_fix_result_xml, validate_issues_xml,
23    validate_plan_xml,
24};
25use regex::Regex;
26use std::collections::BTreeMap;
27
28/// Render XML content based on its type.
29///
30/// Returns formatted string for terminal display.
31/// Falls back to raw XML with warning if parsing fails.
32pub fn render_xml(
33    xml_type: &XmlOutputType,
34    content: &str,
35    context: &Option<XmlOutputContext>,
36) -> String {
37    match xml_type {
38        XmlOutputType::DevelopmentResult => render_development_result(content, context),
39        XmlOutputType::DevelopmentPlan => render_plan(content),
40        XmlOutputType::ReviewIssues => render_issues(content, context),
41        XmlOutputType::FixResult => render_fix_result(content, context),
42        XmlOutputType::CommitMessage => render_commit(content),
43    }
44}
45
46/// Render development result XML with semantic formatting.
47///
48/// Shows:
49/// - Header with box-drawing characters
50/// - Status with emoji indicator and label
51/// - Summary description with proper indentation
52/// - Files changed with action type indicators
53/// - Next steps if present
54fn render_development_result(content: &str, context: &Option<XmlOutputContext>) -> String {
55    let mut output = String::new();
56
57    // Header with optional iteration context
58    if let Some(ctx) = context {
59        if let Some(iter) = ctx.iteration {
60            output.push_str(&format!("\n╔═══ Development Iteration {} ═══╗\n\n", iter));
61        }
62    }
63
64    match validate_development_result_xml(content) {
65        Ok(elements) => {
66            // Status with emoji and label
67            let (status_emoji, status_label) = match elements.status.as_str() {
68                "completed" => ("✅", "Completed"),
69                "partial" => ("🔄", "In Progress"),
70                "failed" => ("❌", "Failed"),
71                _ => ("❓", "Unknown"),
72            };
73            output.push_str(&format!("{} Status: {}\n\n", status_emoji, status_label));
74
75            // Summary with proper formatting for multiline
76            output.push_str("📋 Summary:\n");
77            for line in elements.summary.lines() {
78                output.push_str(&format!("   {}\n", line));
79            }
80
81            // Files changed: prefer diff-like rendering when unified diff is present.
82            if let Some(ref files) = elements.files_changed {
83                output.push_str(&render_files_changed_as_diff_like_view(files));
84            }
85
86            // Next steps with proper formatting
87            if let Some(ref next) = elements.next_steps {
88                output.push_str("\n➡️  Next Steps:\n");
89                for line in next.lines() {
90                    output.push_str(&format!("   {}\n", line));
91                }
92            }
93        }
94        Err(_) => {
95            output.push_str("⚠️  Unable to parse development result XML\n\n");
96            output.push_str(content);
97        }
98    }
99
100    output
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum ChangeAction {
105    Create,
106    Modify,
107    Delete,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111struct DiffFileSection {
112    path: String,
113    action: ChangeAction,
114    diff: String,
115}
116
117fn render_files_changed_as_diff_like_view(files_changed: &str) -> String {
118    let trimmed = files_changed.trim();
119    if trimmed.is_empty() {
120        return String::new();
121    }
122
123    if trimmed.contains("diff --git ") {
124        let sections = parse_unified_diff_files(trimmed);
125        return render_diff_sections("📁 Files Changed", &sections);
126    }
127
128    let items = parse_files_changed_list(trimmed);
129    if items.is_empty() {
130        return String::new();
131    }
132
133    let file_list: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
134    let mut output = String::new();
135    output.push_str("\n📁 Files Changed:\n");
136    output.push_str(&format!(
137        "   Modified {} file(s): {}\n",
138        file_list.len(),
139        file_list.join(", ")
140    ));
141
142    for (path, action) in items {
143        output.push_str(&format!("\n   📄 {}\n", path));
144        output.push_str(&format!(
145            "      Action: {}\n",
146            match action {
147                ChangeAction::Create => "created",
148                ChangeAction::Modify => "modified",
149                ChangeAction::Delete => "deleted",
150            }
151        ));
152        output.push_str("      (no diff provided)\n");
153    }
154
155    output
156}
157
158fn parse_unified_diff_files(diff: &str) -> Vec<DiffFileSection> {
159    let mut sections: Vec<Vec<&str>> = Vec::new();
160    let mut current: Vec<&str> = Vec::new();
161
162    for line in diff.lines() {
163        if line.starts_with("diff --git ") {
164            if !current.is_empty() {
165                sections.push(current);
166            }
167            current = vec![line];
168        } else if !current.is_empty() {
169            current.push(line);
170        }
171    }
172    if !current.is_empty() {
173        sections.push(current);
174    }
175
176    sections
177        .into_iter()
178        .filter_map(|lines| parse_diff_section(&lines))
179        .collect()
180}
181
182fn parse_diff_section(lines: &[&str]) -> Option<DiffFileSection> {
183    let header = *lines.first()?;
184    // Example: "diff --git a/src/main.rs b/src/main.rs"
185    let mut parts = header.split_whitespace();
186    let _ = parts.next()?; // diff
187    let _ = parts.next()?; // --git
188    let a_path = parts.next()?.trim();
189    let b_path = parts.next()?.trim();
190
191    let path = if b_path == "/dev/null" {
192        a_path
193    } else {
194        b_path
195    }
196    .trim_start_matches("a/")
197    .trim_start_matches("b/")
198    .to_string();
199
200    let mut action = ChangeAction::Modify;
201    for line in lines {
202        if line.starts_with("new file mode ") {
203            action = ChangeAction::Create;
204            break;
205        }
206        if line.starts_with("deleted file mode ") {
207            action = ChangeAction::Delete;
208            break;
209        }
210    }
211
212    Some(DiffFileSection {
213        path,
214        action,
215        diff: lines.join("\n"),
216    })
217}
218
219fn render_diff_sections(title: &str, sections: &[DiffFileSection]) -> String {
220    if sections.is_empty() {
221        return String::new();
222    }
223
224    let mut output = String::new();
225    output.push_str(&format!("\n{}:\n", title));
226    output.push_str(&format!(
227        "   Modified {} file(s): {}\n",
228        sections.len(),
229        sections
230            .iter()
231            .map(|s| s.path.as_str())
232            .collect::<Vec<&str>>()
233            .join(", ")
234    ));
235
236    for section in sections {
237        output.push_str(&format!("\n   📄 {}\n", section.path));
238        output.push_str(&format!(
239            "      Action: {}\n",
240            match section.action {
241                ChangeAction::Create => "created",
242                ChangeAction::Modify => "modified",
243                ChangeAction::Delete => "deleted",
244            }
245        ));
246        for line in section.diff.lines() {
247            output.push_str(&format!("      {}\n", line));
248        }
249    }
250
251    output
252}
253
254fn parse_files_changed_list(files: &str) -> Vec<(String, ChangeAction)> {
255    files
256        .lines()
257        .map(str::trim)
258        .filter(|l| !l.is_empty())
259        .map(|l| l.trim_start_matches("- ").trim())
260        .map(|l| {
261            let lowered = l.to_ascii_lowercase();
262            let action = if lowered.contains("(created)") || lowered.contains("(new)") {
263                ChangeAction::Create
264            } else if lowered.contains("(deleted)") || lowered.contains("(removed)") {
265                ChangeAction::Delete
266            } else {
267                ChangeAction::Modify
268            };
269            let path = l.split_once(" (").map_or(l, |(p, _)| p).trim().to_string();
270            (path, action)
271        })
272        .collect()
273}
274
275/// Render development plan XML with semantic formatting.
276///
277/// Shows:
278/// - Box-drawing header
279/// - Context description
280/// - Scope items with counts and categories
281/// - Implementation steps with priorities, file targets, rationale, and dependencies
282/// - Risks and mitigations with severity
283/// - Verification strategy
284fn render_plan(content: &str) -> String {
285    let mut output = String::new();
286
287    output.push_str("\n╔════════════════════════════════════╗\n");
288    output.push_str("║      Implementation Plan           ║\n");
289    output.push_str("╚════════════════════════════════════╝\n\n");
290
291    match validate_plan_xml(content) {
292        Ok(elements) => {
293            // Context section
294            output.push_str("📋 Context:\n");
295            output.push_str(&format!("   {}\n\n", elements.summary.context));
296
297            // Scope section with categories
298            output.push_str("📊 Scope:\n");
299            for item in &elements.summary.scope_items {
300                if let Some(ref count) = item.count {
301                    output.push_str(&format!("   • {} {}", count, item.description));
302                } else {
303                    output.push_str(&format!("   • {}", item.description));
304                }
305                if let Some(ref category) = item.category {
306                    output.push_str(&format!(" ({})", category));
307                }
308                output.push('\n');
309            }
310
311            // Steps section with priorities and dependencies
312            output.push_str("\n───────────────────────────────────\n");
313            output.push_str("📝 Implementation Steps:\n\n");
314            for step in &elements.steps {
315                let priority_badge = step.priority.map_or(String::new(), |p| {
316                    format!(
317                        " [{}]",
318                        match p {
319                            Priority::Critical => "🔴 critical",
320                            Priority::High => "🟠 high",
321                            Priority::Medium => "🟡 medium",
322                            Priority::Low => "🟢 low",
323                        }
324                    )
325                });
326                output.push_str(&format!(
327                    "   {}. {}{}\n",
328                    step.number, step.title, priority_badge
329                ));
330
331                for file in &step.target_files {
332                    let action_icon = match file.action {
333                        FileAction::Create => "➕",
334                        FileAction::Modify => "📝",
335                        FileAction::Delete => "🗑️",
336                    };
337                    output.push_str(&format!("      {} {}\n", action_icon, file.path));
338                }
339
340                if let Some(ref rationale) = step.rationale {
341                    output.push_str(&format!("      💡 {}\n", rationale));
342                }
343
344                if !step.depends_on.is_empty() {
345                    let deps: Vec<String> = step
346                        .depends_on
347                        .iter()
348                        .map(|d| format!("Step {}", d))
349                        .collect();
350                    output.push_str(&format!("      🔗 Depends on: {}\n", deps.join(", ")));
351                }
352                output.push('\n');
353            }
354
355            // Risks section with severity
356            if !elements.risks_mitigations.is_empty() {
357                output.push_str("───────────────────────────────────\n");
358                output.push_str("⚠️  Risks & Mitigations:\n\n");
359                for risk in &elements.risks_mitigations {
360                    let severity_icon = risk.severity.map_or("", |s| match s {
361                        Severity::Critical => "🔴",
362                        Severity::High => "🟠",
363                        Severity::Medium => "🟡",
364                        Severity::Low => "🟢",
365                    });
366                    output.push_str(&format!("   {} Risk: {}\n", severity_icon, risk.risk));
367                    output.push_str(&format!("     → Mitigation: {}\n\n", risk.mitigation));
368                }
369            }
370
371            // Verification section
372            if !elements.verification_strategy.is_empty() {
373                output.push_str("───────────────────────────────────\n");
374                output.push_str("✓ Verification Strategy:\n\n");
375                for (i, v) in elements.verification_strategy.iter().enumerate() {
376                    output.push_str(&format!("   {}. {}\n", i + 1, v.method));
377                    output.push_str(&format!("      Expected: {}\n", v.expected_outcome));
378                }
379            }
380        }
381        Err(_) => {
382            output.push_str("⚠️  Unable to parse plan XML\n\n");
383            output.push_str(content);
384        }
385    }
386
387    output
388}
389
390/// Render review issues XML with semantic formatting.
391///
392/// Shows:
393/// - Box-drawing header with pass number
394/// - Issue count or approval celebration
395/// - Each issue as numbered item with file path extraction
396/// - Visual separators between issues
397fn render_issues(content: &str, context: &Option<XmlOutputContext>) -> String {
398    let mut output = String::new();
399
400    // Header with pass context
401    if let Some(ctx) = context {
402        if let Some(pass) = ctx.pass {
403            output.push_str(&format!("\n╔═══ Review Pass {} ═══╗\n\n", pass));
404        } else {
405            output.push_str("\n╔═══ Review Results ═══╗\n\n");
406        }
407    } else {
408        output.push_str("\n╔═══ Review Results ═══╗\n\n");
409    }
410
411    match validate_issues_xml(content) {
412        Ok(elements) => {
413            if elements.issues.is_empty() {
414                // Celebration for no issues
415                if let Some(ref msg) = elements.no_issues_found {
416                    output.push_str("🎉 ✅ Code Approved!\n\n");
417                    output.push_str(&format!("   {}\n", msg));
418                } else {
419                    output.push_str("🎉 ✅ No issues found! Code looks good.\n");
420                }
421            } else {
422                output.push_str(&format!(
423                    "🔍 Found {} issue(s) to address:\n\n",
424                    elements.issues.len()
425                ));
426                output.push_str(&render_issues_grouped_by_file(&elements.issues, context));
427            }
428        }
429        Err(_) => {
430            output.push_str("⚠️  Unable to parse issues XML\n\n");
431            output.push_str(content);
432        }
433    }
434
435    output
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439struct ParsedIssue {
440    original: String,
441    file: Option<String>,
442    line_start: Option<u32>,
443    line_end: Option<u32>,
444    severity: Option<String>,
445    snippet: Option<String>,
446    description: String,
447}
448
449fn render_issues_grouped_by_file(issues: &[String], context: &Option<XmlOutputContext>) -> String {
450    let parsed: Vec<ParsedIssue> = issues.iter().map(|i| parse_issue(i)).collect();
451    let mut grouped: BTreeMap<String, Vec<ParsedIssue>> = BTreeMap::new();
452
453    for issue in parsed {
454        let key = issue
455            .file
456            .clone()
457            .unwrap_or_else(|| "(no file)".to_string());
458        grouped.entry(key).or_default().push(issue);
459    }
460
461    let mut output = String::new();
462    for (file, issues) in grouped {
463        output.push_str(&format!("📄 {}\n", file));
464        for issue in issues {
465            let mut header = String::new();
466            if let Some(sev) = &issue.severity {
467                header.push_str(&format!("[{}] ", sev));
468            }
469            if let Some(start) = issue.line_start {
470                header.push_str(&format!("L{}", start));
471                if let Some(end) = issue.line_end {
472                    if end != start {
473                        header.push_str(&format!("-L{}", end));
474                    }
475                }
476                header.push_str(": ");
477            }
478
479            let desc = issue.description.trim();
480            if header.is_empty() {
481                output.push_str(&format!("   - {}\n", desc));
482            } else {
483                output.push_str(&format!("   - {}{}\n", header, desc));
484            }
485
486            let snippet = issue
487                .snippet
488                .clone()
489                .or_else(|| snippet_from_context(&issue, context));
490            if let Some(snippet) = snippet {
491                for line in snippet.lines() {
492                    output.push_str(&format!("      {}\n", line));
493                }
494            }
495        }
496        output.push('\n');
497    }
498
499    output
500}
501
502fn snippet_from_context(issue: &ParsedIssue, context: &Option<XmlOutputContext>) -> Option<String> {
503    let ctx = context.as_ref()?;
504    let file = issue.file.as_ref()?;
505    let start = issue.line_start?;
506    let end = issue.line_end.unwrap_or(start);
507
508    ctx.snippets
509        .iter()
510        .find(|s| snippet_matches_issue(s, file, start, end))
511        .map(|s| s.content.clone())
512}
513
514fn snippet_matches_issue(snippet: &XmlCodeSnippet, file: &str, start: u32, end: u32) -> bool {
515    file_matches(&snippet.file, file)
516        && ranges_overlap(snippet.line_start, snippet.line_end, start, end)
517}
518
519fn file_matches(snippet_file: &str, issue_file: &str) -> bool {
520    let snippet_norm = normalize_path_for_match(snippet_file);
521    let issue_norm = normalize_path_for_match(issue_file);
522    if snippet_norm == issue_norm {
523        return true;
524    }
525
526    // Be tolerant of differing prefixes (e.g. `./src/lib.rs` vs `src/lib.rs`),
527    // and of callers emitting paths rooted at a sub-crate (`ralph-workflow/src/...`).
528    let snippet_suffix = format!("/{}", issue_norm);
529    if snippet_norm.ends_with(&snippet_suffix) {
530        return true;
531    }
532
533    let issue_suffix = format!("/{}", snippet_norm);
534    issue_norm.ends_with(&issue_suffix)
535}
536
537fn normalize_path_for_match(path: &str) -> String {
538    path.replace('\\', "/").trim_start_matches("./").to_string()
539}
540
541fn ranges_overlap(a_start: u32, a_end: u32, b_start: u32, b_end: u32) -> bool {
542    a_start <= b_end && b_start <= a_end
543}
544
545fn parse_issue(issue: &str) -> ParsedIssue {
546    let original = issue.to_string();
547    let trimmed = issue.trim();
548
549    let severity_re = Regex::new(r"(?i)^\[(critical|high|medium|low)\]\s*").unwrap();
550    let location_re = Regex::new(
551        r"(?m)(?P<file>[-_./A-Za-z0-9]+\.[A-Za-z0-9]+):(?P<start>\d+)(?:[-–—](?P<end>\d+))?(?::(?P<col>\d+))?",
552    )
553    .unwrap();
554    let gh_location_re = Regex::new(
555        r"(?m)(?P<file>[-_./A-Za-z0-9]+\.[A-Za-z0-9]+)#L(?P<start>\d+)(?:-L(?P<end>\d+))?",
556    )
557    .unwrap();
558    let snippet_re = Regex::new(r"(?s)```(?:[A-Za-z0-9_-]+)?\s*(?P<code>.*?)\s*```").unwrap();
559
560    let mut working = trimmed.to_string();
561
562    let severity = severity_re
563        .captures(&working)
564        .and_then(|cap| cap.get(1).map(|m| m.as_str().to_ascii_lowercase()))
565        .map(|s| match s.as_str() {
566            "critical" => "Critical".to_string(),
567            "high" => "High".to_string(),
568            "medium" => "Medium".to_string(),
569            "low" => "Low".to_string(),
570            _ => s,
571        });
572    if severity.is_some() {
573        working = severity_re.replace(&working, "").to_string();
574    }
575
576    let snippet = snippet_re
577        .captures(&working)
578        .and_then(|cap| cap.name("code").map(|m| m.as_str().to_string()));
579    if snippet.is_some() {
580        working = snippet_re.replace(&working, "").to_string();
581    }
582
583    let (file, line_start, line_end) = if let Some(cap) = location_re.captures(&working) {
584        let file = cap.name("file").map(|m| m.as_str().to_string());
585        let start = cap
586            .name("start")
587            .and_then(|m| m.as_str().parse::<u32>().ok());
588        let end = cap
589            .name("end")
590            .and_then(|m| m.as_str().parse::<u32>().ok())
591            .or(start);
592        (file, start, end)
593    } else if let Some(cap) = gh_location_re.captures(&working) {
594        let file = cap.name("file").map(|m| m.as_str().to_string());
595        let start = cap
596            .name("start")
597            .and_then(|m| m.as_str().parse::<u32>().ok());
598        let end = cap
599            .name("end")
600            .and_then(|m| m.as_str().parse::<u32>().ok())
601            .or(start);
602        (file, start, end)
603    } else {
604        (
605            extract_file_from_issue(&working).map(|s| s.to_string()),
606            None,
607            None,
608        )
609    };
610
611    let description = working
612        .lines()
613        .map(str::trim)
614        .filter(|l| !l.is_empty())
615        .collect::<Vec<&str>>()
616        .join(" ");
617
618    ParsedIssue {
619        original,
620        file,
621        line_start,
622        line_end,
623        severity,
624        snippet,
625        description,
626    }
627}
628
629/// Try to extract file path from issue text using common patterns.
630/// Returns None if no clear file path is found.
631fn extract_file_from_issue(issue: &str) -> Option<&str> {
632    // Common patterns: "in src/file.rs", "at src/file.rs:123", "File: src/file.rs"
633    // This is best-effort heuristic parsing
634    for pattern in ["in ", "at ", "File: ", "file "] {
635        if let Some(idx) = issue.find(pattern) {
636            let start = idx + pattern.len();
637            let rest = &issue[start..];
638            // Find end of path (space, comma, colon for line number, or end of string)
639            let end = rest
640                .find(|c: char| c.is_whitespace() || c == ',')
641                .unwrap_or(rest.len());
642            // Handle colon followed by line number (e.g., src/file.rs:123)
643            let path_with_line = &rest[..end];
644            let path = path_with_line
645                .find(':')
646                .map_or(path_with_line, |colon_pos| &path_with_line[..colon_pos]);
647            if path.contains('/') || path.contains('.') {
648                return Some(path);
649            }
650        }
651    }
652    None
653}
654
655/// Render fix result XML with semantic formatting.
656///
657/// Shows:
658/// - Box-drawing header with pass number
659/// - Status with emoji indicator and friendly label
660/// - Summary with proper multiline formatting
661fn render_fix_result(content: &str, context: &Option<XmlOutputContext>) -> String {
662    let mut output = String::new();
663
664    if let Some(ctx) = context {
665        if let Some(pass) = ctx.pass {
666            output.push_str(&format!("\n╔═══ Fix Pass {} ═══╗\n\n", pass));
667        }
668    }
669
670    match validate_fix_result_xml(content) {
671        Ok(elements) => {
672            let (emoji, label): (&str, &str) = match elements.status.as_str() {
673                "all_issues_addressed" => ("✅", "All Issues Addressed"),
674                "issues_remain" => ("🔄", "Issues Remain"),
675                "no_issues_found" => ("✨", "No Issues Found"),
676                _ => ("❓", elements.status.as_str()),
677            };
678            output.push_str(&format!("{} Status: {}\n", emoji, label));
679
680            if let Some(ref summary) = elements.summary {
681                output.push_str("\n📋 Summary:\n");
682                if summary.contains("diff --git ") {
683                    let sections = parse_unified_diff_files(summary);
684                    output.push_str(&render_diff_sections("   Changes", &sections));
685                } else {
686                    for line in summary.lines() {
687                        output.push_str(&format!("   {}\n", line));
688                    }
689                }
690            }
691        }
692        Err(_) => {
693            output.push_str("⚠️  Unable to parse fix result XML\n\n");
694            output.push_str(content);
695        }
696    }
697
698    output
699}
700
701/// Render commit message XML with semantic formatting.
702///
703/// Shows:
704/// - Box-drawing header
705/// - Subject line prominently
706/// - Body text with proper indentation
707fn render_commit(content: &str) -> String {
708    let mut output = String::new();
709
710    output.push_str("\n╔═══ Commit Message ═══╗\n\n");
711
712    // Extract subject and body from commit XML
713    // Note: Commit XML uses ralph-subject and ralph-body tags
714    let subject = extract_tag_content(content, "ralph-subject")
715        .map(|s| s.trim().to_string())
716        .filter(|s| !s.is_empty());
717    let body = extract_tag_content(content, "ralph-body")
718        .map(|s| s.trim().to_string())
719        .filter(|s| !s.is_empty());
720
721    if subject.is_none() && body.is_none() {
722        output.push_str("⚠️  Unable to parse commit message XML\n\n");
723        output.push_str(content);
724        return output;
725    }
726
727    if let Some(subject) = subject {
728        output.push_str(&format!("📝 {}\n", subject));
729    }
730
731    if let Some(body) = body {
732        output.push('\n');
733        for line in wrap_commit_body(&body, 80).lines() {
734            output.push_str(&format!("   {}\n", line));
735        }
736    }
737
738    output
739}
740
741fn wrap_commit_body(body: &str, max_width: usize) -> String {
742    let indent = 3usize;
743    let wrap_width = max_width.saturating_sub(indent);
744
745    body.lines()
746        .map(|line| {
747            let line = line.trim_end();
748            if line.is_empty() {
749                return String::new();
750            }
751            let trimmed = line.trim_start();
752            let is_listish = trimmed.starts_with('-')
753                || trimmed.starts_with('*')
754                || trimmed.chars().next().is_some_and(|c| c.is_ascii_digit());
755            if is_listish || trimmed.len() <= wrap_width {
756                return trimmed.to_string();
757            }
758
759            let mut out_lines: Vec<String> = Vec::new();
760            let mut current = String::new();
761            for word in trimmed.split_whitespace() {
762                if current.is_empty() {
763                    current.push_str(word);
764                    continue;
765                }
766                if current.len() + 1 + word.len() > wrap_width {
767                    out_lines.push(current);
768                    current = word.to_string();
769                } else {
770                    current.push(' ');
771                    current.push_str(word);
772                }
773            }
774            if !current.is_empty() {
775                out_lines.push(current);
776            }
777            out_lines.join("\n")
778        })
779        .collect::<Vec<String>>()
780        .join("\n")
781}
782
783/// Extract text content from an XML tag.
784///
785/// Simple extraction for well-formed tags. Returns None if tag not found.
786fn extract_tag_content(content: &str, tag_name: &str) -> Option<String> {
787    let start_tag = format!("<{}>", tag_name);
788    let end_tag = format!("</{}>", tag_name);
789
790    let start_pos = content.find(&start_tag)?;
791    let content_start = start_pos + start_tag.len();
792    let end_pos = content[content_start..].find(&end_tag)?;
793
794    Some(content[content_start..content_start + end_pos].to_string())
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    // =========================================================================
802    // Development Result Renderer Tests
803    // =========================================================================
804
805    #[test]
806    fn test_render_development_result_completed() {
807        let xml = r#"<ralph-development-result>
808<ralph-status>completed</ralph-status>
809<ralph-summary>Implemented feature X</ralph-summary>
810<ralph-files-changed>src/main.rs
811src/lib.rs</ralph-files-changed>
812</ralph-development-result>"#;
813
814        let output = render_development_result(xml, &None);
815
816        assert!(output.contains("✅"), "Should have completed emoji");
817        assert!(
818            output.contains("Completed"),
819            "Should show friendly status label"
820        );
821        assert!(
822            output.contains("Implemented feature X"),
823            "Should show summary"
824        );
825        assert!(output.contains("src/main.rs"), "Should list files");
826    }
827
828    #[test]
829    fn test_render_development_result_renders_diff_like_view_per_file_when_diff_present() {
830        let xml = r#"<ralph-development-result>
831<ralph-status>completed</ralph-status>
832<ralph-summary>Updated two files</ralph-summary>
833<ralph-files-changed>diff --git a/src/main.rs b/src/main.rs
834index 1111111..2222222 100644
835--- a/src/main.rs
836+++ b/src/main.rs
837@@ -1,2 +1,2 @@
838-fn main() { println!("old"); }
839+fn main() { println!("new"); }
840diff --git a/src/lib.rs b/src/lib.rs
841new file mode 100644
842--- /dev/null
843+++ b/src/lib.rs
844@@ -0,0 +1,1 @@
845+pub fn hello() {}
846</ralph-files-changed>
847</ralph-development-result>"#;
848
849        let output = render_development_result(xml, &None);
850
851        assert!(
852            output.contains("Modified 2 file") || output.contains("2 file"),
853            "Should include file count summary"
854        );
855        assert!(
856            output.contains("src/main.rs") && output.contains("src/lib.rs"),
857            "Should include per-file headers"
858        );
859        assert!(
860            output.contains("--- a/src/main.rs") && output.contains("+++ b/src/main.rs"),
861            "Should include diff markers"
862        );
863        assert!(
864            output.contains("+pub fn hello") || output.contains("pub fn hello"),
865            "Should include diff content"
866        );
867    }
868
869    #[test]
870    fn test_render_development_result_partial() {
871        let xml = r#"<ralph-development-result>
872<ralph-status>partial</ralph-status>
873<ralph-summary>Started work on feature</ralph-summary>
874<ralph-next-steps>Continue with implementation</ralph-next-steps>
875</ralph-development-result>"#;
876
877        let output = render_development_result(xml, &None);
878
879        assert!(output.contains("🔄"), "Should have partial emoji");
880        assert!(
881            output.contains("Continue with implementation"),
882            "Should show next steps"
883        );
884    }
885
886    #[test]
887    fn test_render_development_result_with_iteration() {
888        let xml = r#"<ralph-development-result>
889<ralph-status>completed</ralph-status>
890<ralph-summary>Done</ralph-summary>
891</ralph-development-result>"#;
892
893        let ctx = Some(XmlOutputContext {
894            iteration: Some(2),
895            pass: None,
896            snippets: Vec::new(),
897        });
898        let output = render_development_result(xml, &ctx);
899
900        assert!(
901            output.contains("Development Iteration 2"),
902            "Should show iteration number"
903        );
904    }
905
906    #[test]
907    fn test_render_development_result_malformed_fallback() {
908        let bad_xml = "not valid xml at all";
909        let output = render_development_result(bad_xml, &None);
910
911        assert!(output.contains("⚠️"), "Should show warning");
912        assert!(
913            output.contains("not valid xml"),
914            "Should include raw content"
915        );
916    }
917
918    // =========================================================================
919    // Plan Renderer Tests
920    // =========================================================================
921
922    #[test]
923    fn test_render_plan_basic_structure() {
924        // Use a minimal valid plan structure
925        let xml = r#"<ralph-plan>
926<ralph-summary>
927<context>Adding a new feature to the codebase</context>
928<scope-items>
929<scope-item count="3">files to modify</scope-item>
930<scope-item count="1">new file to create</scope-item>
931<scope-item>documentation updates</scope-item>
932</scope-items>
933</ralph-summary>
934<ralph-implementation-steps>
935<step number="1" type="file-change">
936<title>Add new module</title>
937<target-files>
938<file path="src/new.rs" action="create"/>
939</target-files>
940<content>
941<paragraph>Create the new module with basic structure.</paragraph>
942</content>
943</step>
944</ralph-implementation-steps>
945<ralph-critical-files>
946<primary-files>
947<file path="src/new.rs" action="create"/>
948</primary-files>
949<reference-files>
950<file path="src/lib.rs" purpose="module registration"/>
951</reference-files>
952</ralph-critical-files>
953<ralph-risks-mitigations>
954<risk-pair severity="low">
955<risk>May conflict with existing code</risk>
956<mitigation>Review for conflicts</mitigation>
957</risk-pair>
958</ralph-risks-mitigations>
959<ralph-verification-strategy>
960<verification>
961<method>Run tests</method>
962<expected-outcome>All tests pass</expected-outcome>
963</verification>
964</ralph-verification-strategy>
965</ralph-plan>"#;
966
967        let output = render_plan(xml);
968
969        assert!(
970            output.contains("Implementation Plan"),
971            "Should have plan header"
972        );
973        assert!(output.contains("Context:"), "Should show context section");
974        assert!(
975            output.contains("Adding a new feature"),
976            "Should show context text"
977        );
978        assert!(output.contains("Scope:"), "Should show scope section");
979        assert!(
980            output.contains("3 files to modify"),
981            "Should show scope items"
982        );
983        assert!(
984            output.contains("Implementation Steps"),
985            "Should show steps section"
986        );
987        assert!(
988            output.contains("1. Add new module"),
989            "Should show step title"
990        );
991        assert!(
992            output.contains("Risks & Mitigations"),
993            "Should show risks section"
994        );
995    }
996
997    #[test]
998    fn test_render_plan_malformed_fallback() {
999        let bad_xml = "<ralph-plan><incomplete>";
1000        let output = render_plan(bad_xml);
1001
1002        assert!(output.contains("⚠️"), "Should show warning");
1003        assert!(
1004            output.contains("<ralph-plan>"),
1005            "Should include raw content"
1006        );
1007    }
1008
1009    // =========================================================================
1010    // Issues Renderer Tests
1011    // =========================================================================
1012
1013    #[test]
1014    fn test_render_issues_with_issues() {
1015        let xml = r#"<ralph-issues>
1016<ralph-issue>Variable unused in src/main.rs</ralph-issue>
1017<ralph-issue>Missing error handling</ralph-issue>
1018</ralph-issues>"#;
1019
1020        let ctx = Some(XmlOutputContext {
1021            iteration: None,
1022            pass: Some(1),
1023            snippets: Vec::new(),
1024        });
1025        let output = render_issues(xml, &ctx);
1026
1027        assert!(output.contains("Review Pass 1"), "Should show pass number");
1028        assert!(output.contains("2 issue"), "Should show issue count");
1029        assert!(output.contains("Variable unused"), "Should list issues");
1030        assert!(
1031            output.contains("📄 src/main.rs"),
1032            "Should group issues under extracted file"
1033        );
1034        assert!(
1035            output.contains("Missing error handling"),
1036            "Should include issues without file"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_render_issues_groups_by_file_and_renders_line_ranges_and_snippets() {
1042        let xml = r#"<ralph-issues>
1043<ralph-issue>[High] src/main.rs:12-18 - Avoid unwrap in production code
1044```rust
1045let x = foo().unwrap();
1046```
1047</ralph-issue>
1048<ralph-issue>src/lib.rs:44:3 - Rename variable for clarity</ralph-issue>
1049<ralph-issue>General suggestion with no file</ralph-issue>
1050</ralph-issues>"#;
1051
1052        let output = render_issues(xml, &None);
1053
1054        assert!(
1055            output.contains("📄 src/main.rs") && output.contains("📄 src/lib.rs"),
1056            "Should render grouped file headers"
1057        );
1058        assert!(
1059            output.contains("L12") && output.contains("L18"),
1060            "Should include parsed line range in Lx-Ly form"
1061        );
1062        assert!(output.contains("[High]"), "Should include severity badge");
1063        assert!(
1064            output.contains("let x = foo().unwrap()"),
1065            "Should include extracted snippet"
1066        );
1067        assert!(
1068            output.contains("General suggestion"),
1069            "Should not drop issues without file"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_render_issues_uses_context_snippets_when_issue_has_location_but_no_fenced_code() {
1075        let xml = r#"<ralph-issues>
1076<ralph-issue>./src/lib.rs:44-44 - Rename variable for clarity</ralph-issue>
1077</ralph-issues>"#;
1078
1079        let ctx = Some(XmlOutputContext {
1080            iteration: None,
1081            pass: Some(1),
1082            snippets: vec![XmlCodeSnippet {
1083                file: "src/lib.rs".to_string(),
1084                line_start: 42,
1085                line_end: 46,
1086                content: "42 | let old_name = 1;\n43 | let x = old_name;\n44 | let clearer = old_name;\n45 | println!(\"{}\", clearer);".to_string(),
1087            }],
1088        });
1089
1090        let output = render_issues(xml, &ctx);
1091
1092        assert!(
1093            output.contains("let clearer"),
1094            "Should render snippet from context even when file path differs by prefix"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_render_issues_no_issues() {
1100        let xml = r#"<ralph-issues>
1101<ralph-no-issues-found>The code looks good, no issues detected</ralph-no-issues-found>
1102</ralph-issues>"#;
1103
1104        let output = render_issues(xml, &None);
1105
1106        assert!(output.contains("✅"), "Should show approval emoji");
1107        assert!(
1108            output.contains("no issues detected"),
1109            "Should show no-issues message"
1110        );
1111    }
1112
1113    #[test]
1114    fn test_render_issues_malformed_fallback() {
1115        let bad_xml = "random text";
1116        let output = render_issues(bad_xml, &None);
1117
1118        assert!(output.contains("⚠️"), "Should show warning");
1119    }
1120
1121    // =========================================================================
1122    // Fix Result Renderer Tests
1123    // =========================================================================
1124
1125    #[test]
1126    fn test_render_fix_result_all_addressed() {
1127        let xml = r#"<ralph-fix-result>
1128<ralph-status>all_issues_addressed</ralph-status>
1129<ralph-summary>Fixed all 3 reported issues</ralph-summary>
1130</ralph-fix-result>"#;
1131
1132        let ctx = Some(XmlOutputContext {
1133            iteration: None,
1134            pass: Some(2),
1135            snippets: Vec::new(),
1136        });
1137        let output = render_fix_result(xml, &ctx);
1138
1139        assert!(output.contains("Fix Pass 2"), "Should show pass number");
1140        assert!(output.contains("✅"), "Should show success emoji");
1141        assert!(
1142            output.contains("All Issues Addressed"),
1143            "Should show friendly status label"
1144        );
1145        assert!(output.contains("Fixed all 3"), "Should show summary");
1146    }
1147
1148    #[test]
1149    fn test_render_fix_result_renders_diff_like_view_when_summary_contains_diff() {
1150        let xml = r#"<ralph-fix-result>
1151<ralph-status>all_issues_addressed</ralph-status>
1152<ralph-summary>Applied fix:
1153diff --git a/src/a.rs b/src/a.rs
1154deleted file mode 100644
1155--- a/src/a.rs
1156+++ /dev/null
1157@@ -1 +0,0 @@
1158-fn a() {}
1159</ralph-summary>
1160</ralph-fix-result>"#;
1161
1162        let output = render_fix_result(xml, &None);
1163
1164        assert!(
1165            output.contains("src/a.rs"),
1166            "Should include per-file header derived from diff"
1167        );
1168        assert!(
1169            output.contains("deleted") || output.contains("Deleted"),
1170            "Should include action context for deleted file"
1171        );
1172        assert!(
1173            output.contains("--- a/src/a.rs") && output.contains("+++ /dev/null"),
1174            "Should include diff markers"
1175        );
1176    }
1177
1178    #[test]
1179    fn test_render_fix_result_issues_remain() {
1180        let xml = r#"<ralph-fix-result>
1181<ralph-status>issues_remain</ralph-status>
1182</ralph-fix-result>"#;
1183
1184        let output = render_fix_result(xml, &None);
1185
1186        assert!(output.contains("🔄"), "Should show partial emoji");
1187        assert!(
1188            output.contains("Issues Remain"),
1189            "Should show friendly status label"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_render_fix_result_no_issues() {
1195        let xml = r#"<ralph-fix-result>
1196<ralph-status>no_issues_found</ralph-status>
1197</ralph-fix-result>"#;
1198
1199        let output = render_fix_result(xml, &None);
1200
1201        assert!(output.contains("✨"), "Should show sparkle emoji");
1202    }
1203
1204    // =========================================================================
1205    // Commit Message Renderer Tests
1206    // =========================================================================
1207
1208    #[test]
1209    fn test_render_commit_with_subject_and_body() {
1210        let xml = r#"<ralph-commit>
1211<ralph-subject>feat: add new authentication system</ralph-subject>
1212<ralph-body>This commit introduces a new JWT-based authentication system.
1213
1214- Added auth middleware
1215- Created user session management
1216- Updated API endpoints</ralph-body>
1217</ralph-commit>"#;
1218
1219        let output = render_commit(xml);
1220
1221        assert!(
1222            output.contains("Commit Message"),
1223            "Should have commit header"
1224        );
1225        assert!(
1226            output.contains("feat: add new authentication"),
1227            "Should show subject"
1228        );
1229        assert!(
1230            output.contains("JWT-based authentication"),
1231            "Should show body"
1232        );
1233        assert!(
1234            output.contains("Added auth middleware"),
1235            "Should show body details"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_render_commit_subject_only() {
1241        let xml = r#"<ralph-commit>
1242<ralph-subject>fix: resolve null pointer exception</ralph-subject>
1243</ralph-commit>"#;
1244
1245        let output = render_commit(xml);
1246
1247        assert!(
1248            output.contains("fix: resolve null pointer"),
1249            "Should show subject"
1250        );
1251    }
1252
1253    #[test]
1254    fn test_render_commit_falls_back_to_raw_with_warning_when_subject_is_blank() {
1255        let xml = r#"<ralph-commit>
1256<ralph-subject>   </ralph-subject>
1257</ralph-commit>"#;
1258
1259        let output = render_commit(xml);
1260
1261        assert!(output.contains("⚠️"), "Should warn on parse failure");
1262        assert!(
1263            output.contains("<ralph-commit>"),
1264            "Should include raw XML fallback"
1265        );
1266        assert!(
1267            !output.contains("📝 \n"),
1268            "Should not render an empty subject line"
1269        );
1270    }
1271
1272    // =========================================================================
1273    // Render XML Router Tests
1274    // =========================================================================
1275
1276    #[test]
1277    fn test_render_xml_routes_correctly() {
1278        let dev_result = r#"<ralph-development-result>
1279<ralph-status>completed</ralph-status>
1280<ralph-summary>Done</ralph-summary>
1281</ralph-development-result>"#;
1282
1283        let output = render_xml(&XmlOutputType::DevelopmentResult, dev_result, &None);
1284        assert!(
1285            output.contains("✅"),
1286            "Should route to development result renderer"
1287        );
1288
1289        let issues = r#"<ralph-issues>
1290<ralph-issue>Test issue</ralph-issue>
1291</ralph-issues>"#;
1292
1293        let output = render_xml(&XmlOutputType::ReviewIssues, issues, &None);
1294        assert!(
1295            output.contains("1 issue"),
1296            "Should route to issues renderer"
1297        );
1298    }
1299
1300    // =========================================================================
1301    // Extract Tag Content Tests
1302    // =========================================================================
1303
1304    #[test]
1305    fn test_extract_tag_content_found() {
1306        let xml = "<ralph-subject>Hello World</ralph-subject>";
1307        let result = extract_tag_content(xml, "ralph-subject");
1308        assert_eq!(result, Some("Hello World".to_string()));
1309    }
1310
1311    #[test]
1312    fn test_extract_tag_content_not_found() {
1313        let xml = "<other>content</other>";
1314        let result = extract_tag_content(xml, "ralph-subject");
1315        assert!(result.is_none());
1316    }
1317
1318    #[test]
1319    fn test_extract_tag_content_nested() {
1320        let xml = "<outer><ralph-subject>Nested</ralph-subject></outer>";
1321        let result = extract_tag_content(xml, "ralph-subject");
1322        assert_eq!(result, Some("Nested".to_string()));
1323    }
1324
1325    // =========================================================================
1326    // Enhanced Plan Renderer Tests
1327    // =========================================================================
1328
1329    #[test]
1330    fn test_render_plan_shows_step_priorities() {
1331        let xml = r#"<ralph-plan>
1332<ralph-summary>
1333<context>Test context</context>
1334<scope-items>
1335<scope-item count="1">item 1</scope-item>
1336<scope-item count="2">item 2</scope-item>
1337<scope-item count="3">item 3</scope-item>
1338</scope-items>
1339</ralph-summary>
1340<ralph-implementation-steps>
1341<step number="1" priority="critical" type="file-change">
1342<title>Critical step</title>
1343<target-files><file path="src/main.rs" action="modify"/></target-files>
1344<content><paragraph>Do something critical</paragraph></content>
1345</step>
1346</ralph-implementation-steps>
1347<ralph-critical-files>
1348<primary-files><file path="src/main.rs" action="modify"/></primary-files>
1349</ralph-critical-files>
1350<ralph-risks-mitigations>
1351<risk-pair severity="high"><risk>Test risk</risk><mitigation>Test mitigation</mitigation></risk-pair>
1352</ralph-risks-mitigations>
1353<ralph-verification-strategy>
1354<verification><method>Run tests</method><expected-outcome>All pass</expected-outcome></verification>
1355</ralph-verification-strategy>
1356</ralph-plan>"#;
1357
1358        let output = render_plan(xml);
1359        assert!(output.contains("critical"), "Should show priority badge");
1360        assert!(output.contains("🔴"), "Should show critical icon");
1361    }
1362
1363    #[test]
1364    fn test_render_plan_shows_step_dependencies() {
1365        let xml = r#"<ralph-plan>
1366<ralph-summary>
1367<context>Test context</context>
1368<scope-items>
1369<scope-item count="1">item 1</scope-item>
1370<scope-item count="2">item 2</scope-item>
1371<scope-item count="3">item 3</scope-item>
1372</scope-items>
1373</ralph-summary>
1374<ralph-implementation-steps>
1375<step number="1" type="file-change">
1376<title>First step</title>
1377<target-files><file path="src/a.rs" action="create"/></target-files>
1378<content><paragraph>Create file A</paragraph></content>
1379</step>
1380<step number="2" type="file-change">
1381<title>Second step</title>
1382<target-files><file path="src/b.rs" action="create"/></target-files>
1383<depends-on step="1"/>
1384<content><paragraph>Create file B</paragraph></content>
1385</step>
1386</ralph-implementation-steps>
1387<ralph-critical-files>
1388<primary-files><file path="src/a.rs" action="create"/></primary-files>
1389</ralph-critical-files>
1390<ralph-risks-mitigations>
1391<risk-pair><risk>None</risk><mitigation>N/A</mitigation></risk-pair>
1392</ralph-risks-mitigations>
1393<ralph-verification-strategy>
1394<verification><method>Run tests</method><expected-outcome>Pass</expected-outcome></verification>
1395</ralph-verification-strategy>
1396</ralph-plan>"#;
1397
1398        let output = render_plan(xml);
1399        assert!(output.contains("Depends on"), "Should show dependencies");
1400        assert!(output.contains("Step 1"), "Should list dependent step");
1401    }
1402
1403    #[test]
1404    fn test_render_plan_shows_verification_strategy() {
1405        let xml = r#"<ralph-plan>
1406<ralph-summary>
1407<context>Test context</context>
1408<scope-items>
1409<scope-item count="1">item 1</scope-item>
1410<scope-item count="2">item 2</scope-item>
1411<scope-item count="3">item 3</scope-item>
1412</scope-items>
1413</ralph-summary>
1414<ralph-implementation-steps>
1415<step number="1" type="file-change">
1416<title>Test step</title>
1417<target-files><file path="src/main.rs" action="modify"/></target-files>
1418<content><paragraph>Modify</paragraph></content>
1419</step>
1420</ralph-implementation-steps>
1421<ralph-critical-files>
1422<primary-files><file path="src/main.rs" action="modify"/></primary-files>
1423</ralph-critical-files>
1424<ralph-risks-mitigations>
1425<risk-pair><risk>None</risk><mitigation>N/A</mitigation></risk-pair>
1426</ralph-risks-mitigations>
1427<ralph-verification-strategy>
1428<verification><method>cargo test</method><expected-outcome>All tests pass</expected-outcome></verification>
1429</ralph-verification-strategy>
1430</ralph-plan>"#;
1431
1432        let output = render_plan(xml);
1433        assert!(
1434            output.contains("Verification Strategy"),
1435            "Should show verification section"
1436        );
1437        assert!(output.contains("cargo test"), "Should show method");
1438        assert!(output.contains("Expected"), "Should show expected outcome");
1439    }
1440
1441    // =========================================================================
1442    // Enhanced Issues Renderer Tests
1443    // =========================================================================
1444
1445    #[test]
1446    fn test_extract_file_from_issue_pattern_in() {
1447        let issue = "Unused variable in src/main.rs";
1448        let file = extract_file_from_issue(issue);
1449        assert_eq!(file, Some("src/main.rs"));
1450    }
1451
1452    #[test]
1453    fn test_extract_file_from_issue_pattern_at() {
1454        let issue = "Error at src/lib.rs:42 - missing semicolon";
1455        let file = extract_file_from_issue(issue);
1456        assert_eq!(file, Some("src/lib.rs"));
1457    }
1458
1459    #[test]
1460    fn test_extract_file_from_issue_no_file() {
1461        let issue = "General code quality concern";
1462        let file = extract_file_from_issue(issue);
1463        assert!(file.is_none());
1464    }
1465
1466    #[test]
1467    fn test_render_issues_celebration_on_approval() {
1468        let xml = r#"<ralph-issues>
1469<ralph-no-issues-found>All code looks great!</ralph-no-issues-found>
1470</ralph-issues>"#;
1471
1472        let output = render_issues(xml, &None);
1473        assert!(output.contains("🎉"), "Should celebrate approval");
1474        assert!(
1475            output.contains("Code Approved"),
1476            "Should show approval message"
1477        );
1478    }
1479
1480    #[test]
1481    fn test_render_issues_shows_snippet_from_context_when_not_in_issue_text() {
1482        let xml = r#"<ralph-issues>
1483<ralph-issue>[High] src/lib.rs:2 Missing semicolon</ralph-issue>
1484</ralph-issues>"#;
1485
1486        let ctx = Some(XmlOutputContext {
1487            iteration: None,
1488            pass: Some(1),
1489            snippets: vec![XmlCodeSnippet {
1490                file: "src/lib.rs".to_string(),
1491                line_start: 1,
1492                line_end: 3,
1493                content: "fn example() {\n    let x = 1\n}\n".to_string(),
1494            }],
1495        });
1496
1497        let output = render_issues(xml, &ctx);
1498
1499        assert!(
1500            output.contains("fn example()"),
1501            "Should render snippet content when provided via context: {}",
1502            output
1503        );
1504        assert!(
1505            output.contains("src/lib.rs"),
1506            "Should show file context: {}",
1507            output
1508        );
1509    }
1510
1511    // =========================================================================
1512    // Visual Consistency Tests
1513    // =========================================================================
1514
1515    #[test]
1516    fn test_all_renderers_have_header_boxes() {
1517        // Verify consistent visual structure across all renderers
1518        let plan_output = render_plan("<ralph-plan>invalid</ralph-plan>");
1519        let issues_output = render_issues("<ralph-issues>invalid</ralph-issues>", &None);
1520        let commit_output = render_commit("<ralph-commit>invalid</ralph-commit>");
1521
1522        // All should have box-drawing characters for headers
1523        assert!(plan_output.contains("═"), "Plan should have box header");
1524        assert!(issues_output.contains("═"), "Issues should have box header");
1525        assert!(commit_output.contains("═"), "Commit should have box header");
1526    }
1527
1528    #[test]
1529    fn test_development_result_multiline_summary() {
1530        let xml = r#"<ralph-development-result>
1531<ralph-status>completed</ralph-status>
1532<ralph-summary>First line of summary
1533Second line of summary
1534Third line of summary</ralph-summary>
1535</ralph-development-result>"#;
1536
1537        let output = render_development_result(xml, &None);
1538        assert!(
1539            output.contains("First line"),
1540            "Should show first line of summary"
1541        );
1542        assert!(
1543            output.contains("Second line"),
1544            "Should show second line of summary"
1545        );
1546        assert!(
1547            output.contains("Third line"),
1548            "Should show third line of summary"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_development_result_file_action_icons() {
1554        let xml = r#"<ralph-development-result>
1555<ralph-status>completed</ralph-status>
1556<ralph-summary>Changes made</ralph-summary>
1557<ralph-files-changed>src/new_file.rs (created)
1558src/existing.rs
1559src/old.rs (deleted)</ralph-files-changed>
1560</ralph-development-result>"#;
1561
1562        let output = render_development_result(xml, &None);
1563        assert!(
1564            output.contains("src/new_file.rs") && output.contains("Action: created"),
1565            "Should show created action for new file"
1566        );
1567        assert!(
1568            output.contains("src/old.rs") && output.contains("Action: deleted"),
1569            "Should show deleted action for removed file"
1570        );
1571        assert!(
1572            output.contains("src/existing.rs") && output.contains("Action: modified"),
1573            "Should show modified action for existing file"
1574        );
1575    }
1576
1577    #[test]
1578    fn test_render_plan_file_action_icons() {
1579        let xml = r#"<ralph-plan>
1580<ralph-summary>
1581<context>Test</context>
1582<scope-items>
1583<scope-item count="1">create</scope-item>
1584<scope-item count="1">modify</scope-item>
1585<scope-item count="1">delete</scope-item>
1586</scope-items>
1587</ralph-summary>
1588<ralph-implementation-steps>
1589<step number="1" type="file-change">
1590<title>Create file</title>
1591<target-files><file path="src/new.rs" action="create"/></target-files>
1592<content><paragraph>Create</paragraph></content>
1593</step>
1594<step number="2" type="file-change">
1595<title>Modify file</title>
1596<target-files><file path="src/existing.rs" action="modify"/></target-files>
1597<content><paragraph>Modify</paragraph></content>
1598</step>
1599<step number="3" type="file-change">
1600<title>Delete file</title>
1601<target-files><file path="src/old.rs" action="delete"/></target-files>
1602<content><paragraph>Delete</paragraph></content>
1603</step>
1604</ralph-implementation-steps>
1605<ralph-critical-files>
1606<primary-files><file path="src/new.rs" action="create"/></primary-files>
1607</ralph-critical-files>
1608<ralph-risks-mitigations>
1609<risk-pair><risk>None</risk><mitigation>N/A</mitigation></risk-pair>
1610</ralph-risks-mitigations>
1611<ralph-verification-strategy>
1612<verification><method>Test</method><expected-outcome>Pass</expected-outcome></verification>
1613</ralph-verification-strategy>
1614</ralph-plan>"#;
1615
1616        let output = render_plan(xml);
1617        assert!(output.contains("➕"), "Should show create icon");
1618        assert!(output.contains("📝"), "Should show modify icon");
1619        assert!(output.contains("🗑️"), "Should show delete icon");
1620    }
1621}