1use 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
28pub 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
46fn render_development_result(content: &str, context: &Option<XmlOutputContext>) -> String {
55 let mut output = String::new();
56
57 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 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 output.push_str("📋 Summary:\n");
77 for line in elements.summary.lines() {
78 output.push_str(&format!(" {}\n", line));
79 }
80
81 if let Some(ref files) = elements.files_changed {
83 output.push_str(&render_files_changed_as_diff_like_view(files));
84 }
85
86 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", §ions);
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 let mut parts = header.split_whitespace();
186 let _ = parts.next()?; let _ = parts.next()?; 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
275fn 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 output.push_str("📋 Context:\n");
295 output.push_str(&format!(" {}\n\n", elements.summary.context));
296
297 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 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 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 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
390fn render_issues(content: &str, context: &Option<XmlOutputContext>) -> String {
398 let mut output = String::new();
399
400 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 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 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
629fn extract_file_from_issue(issue: &str) -> Option<&str> {
632 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 let end = rest
640 .find(|c: char| c.is_whitespace() || c == ',')
641 .unwrap_or(rest.len());
642 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
655fn 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", §ions));
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
701fn render_commit(content: &str) -> String {
708 let mut output = String::new();
709
710 output.push_str("\n╔═══ Commit Message ═══╗\n\n");
711
712 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
783fn 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 #[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 #[test]
923 fn test_render_plan_basic_structure() {
924 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1516 fn test_all_renderers_have_header_boxes() {
1517 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 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}