1use crate::app::{App, FocusPanel, InputMode, ListContent, SortColumn, SortDirection, ViewMode};
8use crate::config::theme::ColorPalette;
9use crate::models::{FindingCounts, PatchsetStatus};
10use ratatui::Frame;
11use ratatui::layout::{Constraint, Layout, Rect};
12use ratatui::style::{Style, Stylize};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{
15 Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState, Wrap,
16};
17
18pub fn view(app: &App, frame: &mut Frame) {
23 let area = frame.area();
24 let palette = &app.config.theme.colors;
25
26 if app.config.remotes.is_empty() {
28 let block = Block::default()
29 .title(" remendo ".bold())
30 .borders(Borders::ALL)
31 .border_style(Style::default().fg(palette.border.color()));
32 let content = "No remotes configured.\n\n\
33 To get started:\n \
34 1. cp config.example.toml ~/.config/remendo/config.toml\n \
35 2. Edit the file and set your Sashiko instance URL\n \
36 3. Restart remendo";
37 let paragraph = Paragraph::new(content).block(block);
38 frame.render_widget(paragraph, area);
39 return;
40 }
41
42 let chunks = Layout::horizontal([Constraint::Length(24), Constraint::Min(40)]).split(area);
44
45 render_sidebar(app, frame, chunks[0], palette);
47
48 match app.view_mode {
50 ViewMode::List => render_main_pane(app, frame, chunks[1], palette),
51 ViewMode::Loading => {
52 render_main_pane(app, frame, chunks[1], palette);
54 render_loading_dialog(app, frame, palette);
55 }
56 ViewMode::Detail => match app.list_content {
57 ListContent::Patchsets => render_detail_view(app, frame, chunks[1], palette),
58 ListContent::Messages => render_message_detail(app, frame, chunks[1], palette),
59 },
60 }
61
62 if app.show_help {
64 render_help_overlay(app, frame, palette);
65 }
66}
67
68fn panel_border_color(
70 current_focus: FocusPanel,
71 panel: FocusPanel,
72 palette: &ColorPalette,
73) -> ratatui::style::Color {
74 if current_focus == panel {
75 palette.accent.color()
76 } else {
77 palette.border.color()
78 }
79}
80
81fn render_sidebar(
83 app: &App,
84 frame: &mut Frame,
85 area: ratatui::layout::Rect,
86 palette: &ColorPalette,
87) {
88 use crate::app::SidebarSection;
89
90 let remote_count = app.config.remotes.len();
91 let remote_height = u16::try_from(remote_count + 2)
92 .unwrap_or(5)
93 .min(area.height / 2);
94
95 let sidebar_chunks =
96 Layout::vertical([Constraint::Length(remote_height), Constraint::Min(3)]).split(area);
97
98 let highlight_style = Style::default()
99 .bg(palette.selected_bg.color())
100 .fg(palette.selected_fg.color());
101
102 let remotes_focused =
104 app.focus == FocusPanel::Sidebar && app.sidebar_section == SidebarSection::Remotes;
105 let remotes_border = if remotes_focused {
106 palette.accent.color()
107 } else {
108 palette.border.color()
109 };
110 let remotes_block = Block::default()
111 .title(format!(" Remotes ({remote_count}) ").bold())
112 .borders(Borders::ALL)
113 .border_style(Style::default().fg(remotes_border));
114
115 let remote_items: Vec<ListItem> = app
116 .config
117 .remotes
118 .iter()
119 .map(|r| ListItem::new(r.name.as_str()))
120 .collect();
121
122 let remotes_list = List::new(remote_items)
123 .block(remotes_block)
124 .highlight_style(highlight_style);
125
126 let mut remotes_state = ListState::default();
127 if remotes_focused {
128 remotes_state.select(Some(app.active_remote_index));
129 }
130 frame.render_stateful_widget(remotes_list, sidebar_chunks[0], &mut remotes_state);
131
132 let lists_focused =
134 app.focus == FocusPanel::Sidebar && app.sidebar_section == SidebarSection::MailingLists;
135 let lists_border = if lists_focused {
136 palette.accent.color()
137 } else {
138 palette.border.color()
139 };
140 let list_count = app.mailing_lists.len();
141 let lists_block = Block::default()
142 .title(format!(" Lists ({list_count}) ").bold())
143 .borders(Borders::ALL)
144 .border_style(Style::default().fg(lists_border));
145
146 if app.mailing_lists.is_empty() {
147 let placeholder = Paragraph::new("(none)")
148 .style(Style::default().fg(palette.muted.color()))
149 .block(lists_block);
150 frame.render_widget(placeholder, sidebar_chunks[1]);
151 } else {
152 let mut list_items: Vec<ListItem> = vec![ListItem::new("All")];
153 for ml in &app.mailing_lists {
154 list_items.push(ListItem::new(ml.name.as_str()));
155 }
156
157 let ml_list = List::new(list_items)
158 .block(lists_block)
159 .highlight_style(highlight_style);
160
161 let mut ml_state = ListState::default();
162 if lists_focused {
163 ml_state.select(Some(app.sidebar_list_index));
164 }
165 frame.render_stateful_widget(ml_list, sidebar_chunks[1], &mut ml_state);
166 }
167}
168
169fn build_main_title(app: &App) -> String {
171 if let Some(ref err) = app.error_state {
172 return format!(" remendo | ERROR: {err} ");
173 }
174 let remote = if app.active_remote.is_empty() {
175 "(no remote)"
176 } else {
177 &app.active_remote
178 };
179 let (content_label, content_total, total_pages) = match app.list_content {
180 ListContent::Patchsets => (
181 "patchsets",
182 app.patchsets.total,
183 app.patchsets.total_pages(),
184 ),
185 ListContent::Messages => ("messages", app.messages.total, app.messages.total_pages()),
186 };
187 let page_indicator = if total_pages > 1 {
188 format!(" | page {}/{total_pages}", app.list_params.page)
189 } else {
190 String::new()
191 };
192 let search_indicator = app
193 .list_params
194 .search
195 .as_ref()
196 .map_or(String::new(), |q| format!(" | q: \"{q}\""));
197 let list_indicator = app
198 .list_params
199 .mailing_list
200 .as_ref()
201 .map_or(String::new(), |l| format!(" | list: {l}"));
202 let bookmark_indicator = if app.show_bookmarks_only {
203 " | [B] bookmarks"
204 } else {
205 ""
206 };
207 if let Some(ref stats) = app.stats {
208 format!(
209 " remendo | {remote} | v{} | {} pending | {} reviewing | {content_total} {content_label}{page_indicator}{search_indicator}{list_indicator}{bookmark_indicator} ",
210 stats.version, stats.pending, stats.reviewing
211 )
212 } else {
213 format!(
214 " remendo | {remote} | {content_total} {content_label}{page_indicator}{search_indicator}{list_indicator}{bookmark_indicator} "
215 )
216 }
217}
218
219fn render_main_pane(
221 app: &App,
222 frame: &mut Frame,
223 area: ratatui::layout::Rect,
224 palette: &ColorPalette,
225) {
226 let border_color = panel_border_color(app.focus, FocusPanel::PatchsetList, palette);
227 let status = build_main_title(app);
228
229 let block = Block::default()
230 .title(status.bold())
231 .borders(Borders::ALL)
232 .border_style(Style::default().fg(border_color));
233
234 let items_empty = match app.list_content {
236 ListContent::Patchsets => app.patchsets.items.is_empty(),
237 ListContent::Messages => app.messages.items.is_empty(),
238 };
239 if items_empty {
240 let text = if app.error_state.is_some() {
241 "Error loading. Press Ctrl-r to retry.".to_string()
242 } else if let Some(ref q) = app.list_params.search {
243 format!("No results for \"{q}\"")
244 } else {
245 "Loading...".to_string()
246 };
247 let paragraph = Paragraph::new(text).block(block);
248 frame.render_widget(paragraph, area);
249 return;
250 }
251
252 if app.list_content == ListContent::Messages {
254 render_message_table(app, frame, area, block, palette);
255 return;
256 }
257
258 let rows: Vec<Row> = app
260 .patchsets
261 .items
262 .iter()
263 .filter(|ps| !app.show_bookmarks_only || app.bookmarks.contains(&app.active_remote, ps.id))
264 .map(|ps| build_patchset_row(app, ps, palette))
265 .collect();
266
267 let sort_header = |label: &str, col: SortColumn| -> String {
268 if app.sort_column == col {
269 match app.sort_direction {
270 SortDirection::Ascending => format!("{label} \u{25b2}"),
271 SortDirection::Descending => format!("{label} \u{25bc}"),
272 }
273 } else {
274 label.to_string()
275 }
276 };
277
278 let header = Row::new(vec![
279 Cell::from(""),
280 Cell::from(sort_header("Status", SortColumn::Status)),
281 Cell::from("Subject".to_string()),
282 Cell::from(sort_header("Author", SortColumn::Author)),
283 Cell::from(sort_header("Date", SortColumn::Date)),
284 Cell::from(sort_header("Findings", SortColumn::Findings)),
285 Cell::from("Subsystems".to_string()),
286 ])
287 .style(Style::default().fg(palette.accent.color()).bold());
288
289 let widths = [
290 Constraint::Length(3),
291 Constraint::Length(14),
292 Constraint::Min(30),
293 Constraint::Length(20),
294 Constraint::Length(10),
295 Constraint::Length(10),
296 Constraint::Length(18),
297 ];
298
299 let table = Table::new(rows, widths)
300 .header(header)
301 .block(block)
302 .row_highlight_style(
303 Style::default()
304 .bg(palette.selected_bg.color())
305 .fg(palette.selected_fg.color()),
306 );
307
308 let mut table_state = TableState::default();
309 table_state.select(Some(app.selected_index));
310
311 frame.render_stateful_widget(table, area, &mut table_state);
312
313 if app.input_mode == InputMode::Search {
315 let search_area = Rect::new(
316 area.x + 1,
317 area.y + area.height.saturating_sub(2),
318 area.width.saturating_sub(2),
319 1,
320 );
321 let search_text = format!("/{}", app.search_buffer);
322 frame.render_widget(
323 Paragraph::new(search_text).style(Style::default().fg(palette.accent.color())),
324 search_area,
325 );
326 #[allow(clippy::cast_possible_truncation)]
327 frame.set_cursor_position((search_area.x + 1 + app.search_cursor as u16, search_area.y));
328 }
329}
330
331fn status_color(status: PatchsetStatus, palette: &ColorPalette) -> Style {
333 match status {
334 PatchsetStatus::Pending => Style::default().fg(palette.foreground.color()),
335 PatchsetStatus::InReview => Style::default().fg(palette.accent.color()),
336 PatchsetStatus::Reviewed => Style::default().fg(palette.success.color()),
337 PatchsetStatus::Failed | PatchsetStatus::FailedToApply => {
338 Style::default().fg(palette.error.color())
339 }
340 PatchsetStatus::Cancelled => Style::default().fg(palette.warning.color()),
341 PatchsetStatus::Incomplete | PatchsetStatus::Skipped | PatchsetStatus::Unknown => {
342 Style::default().fg(palette.muted.color())
343 }
344 }
345}
346
347fn format_date(ts: Option<i64>) -> String {
349 let Some(ts) = ts else {
350 return String::from("—");
351 };
352
353 let secs = ts;
356 let days = secs / 86400;
357 let z = days + 719_468;
359 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
360 let doe = z - era * 146_097;
361 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
362 let y = yoe + era * 400;
363 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
364 let mp = (5 * doy + 2) / 153;
365 let d = doy - (153 * mp + 2) / 5 + 1;
366 let m = if mp < 10 { mp + 3 } else { mp - 9 };
367 let y = if m <= 2 { y + 1 } else { y };
368
369 format!("{y:04}-{m:02}-{d:02}")
370}
371
372fn format_findings<'a>(findings: &FindingCounts, palette: &'a ColorPalette) -> Line<'a> {
377 if findings.is_empty() {
378 return Line::from("—").style(Style::default().fg(palette.muted.color()));
379 }
380
381 let mut spans: Vec<Span<'a>> = Vec::new();
382 let error_style = Style::default().fg(palette.error.color());
383 let warn_style = Style::default().fg(palette.warning.color());
384 let default_style = Style::default().fg(palette.muted.color());
385
386 if findings.critical > 0 {
387 if !spans.is_empty() {
388 spans.push(Span::raw(" "));
389 }
390 spans.push(Span::styled(format!("{}C", findings.critical), error_style));
391 }
392 if findings.high > 0 {
393 if !spans.is_empty() {
394 spans.push(Span::raw(" "));
395 }
396 spans.push(Span::styled(format!("{}H", findings.high), error_style));
397 }
398 if findings.medium > 0 {
399 if !spans.is_empty() {
400 spans.push(Span::raw(" "));
401 }
402 spans.push(Span::styled(format!("{}M", findings.medium), warn_style));
403 }
404 if findings.low > 0 {
405 if !spans.is_empty() {
406 spans.push(Span::raw(" "));
407 }
408 spans.push(Span::styled(format!("{}L", findings.low), default_style));
409 }
410
411 Line::from(spans)
412}
413
414fn build_patchset_row<'a>(
416 app: &'a App,
417 ps: &'a crate::models::Patchset,
418 palette: &'a ColorPalette,
419) -> Row<'a> {
420 let bookmark_cell = if app.bookmarks.contains(&app.active_remote, ps.id) {
421 Cell::from(" * ").style(Style::default().fg(palette.accent.color()))
422 } else {
423 Cell::from(" ")
424 };
425
426 let status_style = status_color(ps.status, palette);
427 let status_text = match (&ps.failed_reason, ps.status) {
428 (Some(reason), PatchsetStatus::Failed | PatchsetStatus::FailedToApply) => {
429 let mut text = format!("Failed: {reason}");
430 text.truncate(14);
431 text
432 }
433 _ => ps.status.to_string(),
434 };
435 let status_cell = Cell::from(status_text).style(status_style);
436 let subject_cell = Cell::from(ps.subject());
437 let author_cell = Cell::from(ps.author()).style(Style::default().fg(palette.muted.color()));
438 let date_cell =
439 Cell::from(format_date(ps.date)).style(Style::default().fg(palette.muted.color()));
440 let findings_cell = Cell::from(format_findings(&ps.findings, palette));
441 let subsystems_text = if ps.subsystems.is_empty() {
442 String::new()
443 } else {
444 ps.subsystems.join(", ")
445 };
446 let subsystems_cell =
447 Cell::from(subsystems_text).style(Style::default().fg(palette.muted.color()));
448
449 Row::new(vec![
450 bookmark_cell,
451 status_cell,
452 subject_cell,
453 author_cell,
454 date_cell,
455 findings_cell,
456 subsystems_cell,
457 ])
458}
459
460fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
462 let w = width.min(area.width);
463 let h = height.min(area.height);
464 let x = area.x + (area.width.saturating_sub(w)) / 2;
465 let y = area.y + (area.height.saturating_sub(h)) / 2;
466 Rect::new(x, y, w, h)
467}
468
469fn render_message_table(
473 app: &App,
474 frame: &mut Frame,
475 area: Rect,
476 block: Block<'_>,
477 palette: &ColorPalette,
478) {
479 let rows: Vec<Row> = app
480 .messages
481 .items
482 .iter()
483 .map(|msg| {
484 Row::new(vec![
485 Cell::from(msg.subject.as_deref().unwrap_or("(no subject)")),
486 Cell::from(msg.author.as_deref().unwrap_or("(unknown)"))
487 .style(Style::default().fg(palette.muted.color())),
488 Cell::from(format_date(msg.date)).style(Style::default().fg(palette.muted.color())),
489 Cell::from(msg.mailing_list.as_deref().unwrap_or(""))
490 .style(Style::default().fg(palette.muted.color())),
491 ])
492 })
493 .collect();
494
495 let header = Row::new(vec![
496 Cell::from("Subject"),
497 Cell::from("Author"),
498 Cell::from("Date"),
499 Cell::from("List"),
500 ])
501 .style(Style::default().fg(palette.accent.color()).bold());
502
503 let widths = [
504 Constraint::Min(30),
505 Constraint::Length(20),
506 Constraint::Length(10),
507 Constraint::Length(24),
508 ];
509
510 let table = Table::new(rows, widths)
511 .header(header)
512 .block(block)
513 .row_highlight_style(
514 Style::default()
515 .bg(palette.selected_bg.color())
516 .fg(palette.selected_fg.color()),
517 );
518
519 let mut table_state = TableState::default();
520 table_state.select(Some(app.selected_index));
521
522 frame.render_stateful_widget(table, area, &mut table_state);
523}
524
525fn classify_diff_line(line: &str, palette: &ColorPalette) -> Style {
531 if line.starts_with("+++")
532 || line.starts_with("---")
533 || line.starts_with("diff ")
534 || line.starts_with("index ")
535 {
536 Style::default().fg(palette.foreground.color()).bold()
537 } else if line.starts_with("@@") {
538 Style::default().fg(palette.accent.color()).bold()
539 } else if line.starts_with('+') {
540 Style::default().fg(palette.success.color())
541 } else if line.starts_with('-') {
542 Style::default().fg(palette.error.color())
543 } else {
544 Style::default().fg(palette.muted.color())
545 }
546}
547
548fn classify_review_line(line: &str, seen_quoted: bool, palette: &ColorPalette) -> Style {
567 if line.is_empty() {
568 return Style::default();
569 }
570
571 if line.starts_with('>') {
572 let stripped = strip_review_quoting(line);
574 classify_diff_line(stripped, palette)
575 } else if seen_quoted {
576 Style::default().fg(palette.foreground.color())
578 } else {
579 Style::default().fg(palette.muted.color())
581 }
582}
583
584fn strip_review_quoting(line: &str) -> &str {
589 let mut s = line;
590 while s.starts_with('>') {
591 s = s.strip_prefix('>').unwrap_or(s);
592 s = s.strip_prefix(' ').unwrap_or(s);
594 }
595 s
596}
597
598fn message_detail_lines<'a>(
602 msg: &'a crate::models::EmailMessage,
603 palette: &'a ColorPalette,
604) -> Vec<Line<'a>> {
605 let mut lines: Vec<Line> = Vec::new();
606
607 lines.push(Line::from(vec![
609 Span::styled("From: ", Style::default().fg(palette.muted.color())),
610 Span::raw(msg.author.as_deref().unwrap_or("(unknown)")),
611 ]));
612 lines.push(Line::from(vec![
613 Span::styled("Date: ", Style::default().fg(palette.muted.color())),
614 Span::raw(format_date(msg.date)),
615 ]));
616 if let Some(ref to) = msg.to {
617 lines.push(Line::from(vec![
618 Span::styled("To: ", Style::default().fg(palette.muted.color())),
619 Span::raw(to.as_str()),
620 ]));
621 }
622 if let Some(ref cc) = msg.cc {
623 lines.push(Line::from(vec![
624 Span::styled("Cc: ", Style::default().fg(palette.muted.color())),
625 Span::raw(cc.as_str()),
626 ]));
627 }
628 if let Some(ref list) = msg.mailing_list {
629 lines.push(Line::from(vec![
630 Span::styled("List: ", Style::default().fg(palette.muted.color())),
631 Span::raw(list.as_str()),
632 ]));
633 }
634
635 lines.push(Line::raw(""));
636
637 if let Some(ref body) = msg.body {
639 for body_line in body.lines() {
640 lines.push(Line::raw(body_line));
641 }
642 } else {
643 lines.push(Line::styled(
644 "(no body)",
645 Style::default().fg(palette.muted.color()),
646 ));
647 }
648
649 if let Some(ref diff) = msg.diff {
651 lines.push(Line::raw(""));
652 lines.push(Line::styled(
653 "── Diff ──",
654 Style::default().fg(palette.accent.color()).bold(),
655 ));
656 for diff_line in diff.lines() {
657 let style = classify_diff_line(diff_line, palette);
658 lines.push(Line::styled(diff_line, style));
659 }
660 }
661
662 lines
663}
664
665fn render_message_detail(app: &App, frame: &mut Frame, area: Rect, palette: &ColorPalette) {
666 let border_color = panel_border_color(app.focus, FocusPanel::PatchsetList, palette);
667
668 let Some(ref msg) = app.selected_message else {
669 let block = Block::default()
670 .title(" Loading message... ".bold())
671 .borders(Borders::ALL)
672 .border_style(Style::default().fg(border_color));
673 frame.render_widget(Paragraph::new("").block(block), area);
674 return;
675 };
676
677 let subject = msg.subject.as_deref().unwrap_or("(no subject)");
678 let title = format!(" {subject} ");
679 let block = Block::default()
680 .title(title.bold())
681 .borders(Borders::ALL)
682 .border_style(Style::default().fg(border_color));
683
684 let lines = message_detail_lines(msg, palette);
685
686 let scroll_offset = u16::try_from(app.detail_scroll_offset).unwrap_or(u16::MAX);
687 let paragraph = Paragraph::new(lines)
688 .block(block)
689 .wrap(Wrap { trim: false })
690 .scroll((scroll_offset, 0));
691
692 frame.render_widget(paragraph, area);
693}
694
695fn render_loading_dialog(app: &App, frame: &mut Frame, palette: &ColorPalette) {
696 let Some(ref ctx) = app.loading_context else {
697 return;
698 };
699
700 let popup_width: u16 = 50;
701 let popup_height: u16 = 7;
702 let area = centered_rect(popup_width, popup_height, frame.area());
703
704 frame.render_widget(Clear, area);
705
706 let block = Block::default()
707 .title(" Loading Patchset ".bold())
708 .borders(Borders::ALL)
709 .border_style(Style::default().fg(palette.accent.color()));
710
711 let text = vec![
712 Line::from(""),
713 Line::from(vec![
714 Span::styled(" ID: ", Style::default().fg(palette.muted.color())),
715 Span::styled(
716 ctx.patchset_id.to_string(),
717 Style::default().fg(palette.accent.color()),
718 ),
719 Span::raw(" "),
720 Span::styled(
721 format!("[{}]", ctx.status),
722 Style::default().fg(palette.foreground.color()),
723 ),
724 ]),
725 Line::from(vec![
726 Span::styled(" ", Style::default()),
727 Span::raw(&ctx.subject),
728 ]),
729 Line::from(""),
730 Line::styled(
731 " Loading detail...",
732 Style::default().fg(palette.muted.color()),
733 ),
734 ];
735
736 let paragraph = Paragraph::new(text).block(block);
737 frame.render_widget(paragraph, area);
738}
739
740fn render_help_overlay(app: &App, frame: &mut Frame, palette: &ColorPalette) {
741 let mut entries: Vec<_> = app
743 .config
744 .keybindings
745 .bindings
746 .iter()
747 .map(|(combo, action)| (combo.to_string(), action.label()))
748 .collect();
749 entries.sort_by(|a, b| a.1.cmp(b.1));
750
751 let popup_width: u16 = 38;
752 let popup_height = u16::try_from(entries.len() + 4).unwrap_or(24).min(40);
753 let area = centered_rect(popup_width, popup_height, frame.area());
754
755 frame.render_widget(Clear, area);
756
757 let rows: Vec<Row> = entries
758 .iter()
759 .map(|(key, label)| {
760 Row::new(vec![
761 Cell::from(key.as_str()).style(Style::default().fg(palette.accent.color())),
762 Cell::from(*label),
763 ])
764 })
765 .collect();
766
767 let widths = [Constraint::Length(12), Constraint::Min(18)];
768 let block = Block::default()
769 .title(" Keybindings ".bold())
770 .borders(Borders::ALL)
771 .border_style(Style::default().fg(palette.accent.color()));
772
773 let table = Table::new(rows, widths).block(block);
774 frame.render_widget(table, area);
775}
776
777fn render_detail_view(app: &App, frame: &mut Frame, area: Rect, palette: &ColorPalette) {
779 let border_color = panel_border_color(app.focus, FocusPanel::PatchsetList, palette);
780
781 let Some(ref detail) = app.selected_detail else {
782 let block = Block::default()
783 .title(" Loading detail... ".bold())
784 .borders(Borders::ALL)
785 .border_style(Style::default().fg(border_color));
786 frame.render_widget(Paragraph::new("").block(block), area);
787 return;
788 };
789
790 let subject = detail.subject.as_deref().unwrap_or("(no subject)");
791 let title = format!(" {subject} ");
792 let block = Block::default()
793 .title(title.bold())
794 .borders(Borders::ALL)
795 .border_style(Style::default().fg(border_color));
796
797 let mut lines: Vec<Line> = Vec::new();
798 detail_header_lines(detail, palette, &mut lines);
799 detail_patches_lines(detail, palette, &mut lines);
800 detail_thread_lines(detail, palette, &mut lines);
801
802 let scroll_offset = u16::try_from(app.detail_scroll_offset).unwrap_or(u16::MAX);
803 let paragraph = Paragraph::new(lines)
804 .block(block)
805 .wrap(Wrap { trim: false })
806 .scroll((scroll_offset, 0));
807
808 frame.render_widget(paragraph, area);
809}
810
811fn detail_header_lines<'a>(
813 detail: &'a crate::models::PatchsetDetail,
814 palette: &'a ColorPalette,
815 lines: &mut Vec<Line<'a>>,
816) {
817 let status_style = status_color(detail.status, palette);
818 lines.push(Line::from(vec![
819 Span::styled(detail.status.to_string(), status_style),
820 Span::raw(" "),
821 Span::styled(
822 detail.author.as_deref().unwrap_or("(unknown)"),
823 Style::default().fg(palette.muted.color()),
824 ),
825 Span::raw(" "),
826 Span::styled(
827 format_date(detail.date),
828 Style::default().fg(palette.muted.color()),
829 ),
830 ]));
831
832 if let Some(ref reason) = detail.failed_reason {
834 lines.push(Line::styled(
835 format!("Failed: {reason}"),
836 Style::default().fg(palette.error.color()),
837 ));
838 }
839
840 if let (Some(total), Some(received)) = (detail.total_parts, detail.received_parts) {
841 let sub_text = if detail.subsystems.is_empty() {
842 String::new()
843 } else {
844 format!(" {}", detail.subsystems.join(", "))
845 };
846 lines.push(Line::from(vec![
847 Span::styled(
848 format!("Parts: {received}/{total}"),
849 Style::default().fg(palette.muted.color()),
850 ),
851 Span::styled(sub_text, Style::default().fg(palette.muted.color())),
852 ]));
853 }
854
855 if let Some(ref baseline) = detail.baseline {
856 let branch = baseline.branch.as_deref().unwrap_or("?");
857 let commit = baseline
858 .commit
859 .as_deref()
860 .map_or("?", |c| if c.len() > 12 { &c[..12] } else { c });
861 let has_logs = detail.baseline_logs.as_ref().is_some_and(|s| !s.is_empty());
862 let mut spans = vec![Span::styled(
863 format!("Baseline: {branch} @ {commit}"),
864 Style::default().fg(palette.info.color()),
865 )];
866 if has_logs {
867 spans.push(Span::styled(
868 " [logs: L]",
869 Style::default().fg(palette.accent.color()),
870 ));
871 }
872 lines.push(Line::from(spans));
873 }
874
875 if let Some(ref model) = detail.model_name {
876 let provider = detail.provider.as_deref().unwrap_or("?");
877 lines.push(Line::styled(
878 format!("Model: {provider}/{model}"),
879 Style::default().fg(palette.muted.color()),
880 ));
881 }
882
883 lines.push(Line::raw(""));
884}
885
886fn detail_patches_lines<'a>(
888 detail: &'a crate::models::PatchsetDetail,
889 palette: &'a ColorPalette,
890 lines: &mut Vec<Line<'a>>,
891) {
892 let total_parts = detail.total_parts.unwrap_or(0);
893 lines.push(Line::styled(
894 format!("── Patches ({}) ──", detail.patches.len()),
895 Style::default().fg(palette.accent.color()).bold(),
896 ));
897
898 if detail.patches.is_empty() {
899 lines.push(Line::styled(
900 "(no patches)",
901 Style::default().fg(palette.muted.color()),
902 ));
903 } else {
904 for patch in &detail.patches {
905 let idx = patch.part_index.unwrap_or(0);
906 let status_str = patch
907 .status
908 .as_ref()
909 .map_or_else(|| "?".to_string(), ToString::to_string);
910 let patch_subject = patch.subject.as_deref().unwrap_or("(no subject)");
911 lines.push(Line::from(vec![
912 Span::styled(
913 format!("{idx}/{total_parts} "),
914 Style::default().fg(palette.accent.color()),
915 ),
916 Span::styled(
917 format!("[{status_str}] "),
918 Style::default().fg(palette.success.color()),
919 ),
920 Span::raw(patch_subject),
921 ]));
922
923 let review = detail.reviews.iter().find(|r| r.patch_id == patch.id);
924 if let Some(rev) = review {
925 if let Some(ref summary) = rev.summary {
926 lines.push(Line::styled(
927 format!(" Summary: {summary}"),
928 Style::default().fg(palette.muted.color()),
929 ));
930 }
931 if let Some(ref inline) = rev.inline_review {
932 lines.push(Line::raw(""));
933 let mut seen_quoted = false;
934 for review_line in inline.lines() {
935 if review_line.starts_with('>') {
936 seen_quoted = true;
937 }
938 let style = classify_review_line(review_line, seen_quoted, palette);
939 lines.push(Line::from(vec![
940 Span::raw(" "),
941 Span::styled(review_line, style),
942 ]));
943 }
944 lines.push(Line::raw(""));
945 }
946 } else {
947 lines.push(Line::styled(
948 " (no review)",
949 Style::default().fg(palette.muted.color()),
950 ));
951 }
952 }
953 }
954
955 lines.push(Line::raw(""));
956}
957
958fn detail_thread_lines<'a>(
964 detail: &'a crate::models::PatchsetDetail,
965 palette: &'a ColorPalette,
966 lines: &mut Vec<Line<'a>>,
967) {
968 lines.push(Line::styled(
969 format!("── Thread ({}) ──", detail.thread.len()),
970 Style::default().fg(palette.accent.color()).bold(),
971 ));
972
973 if detail.thread.is_empty() {
974 lines.push(Line::styled(
975 "(no messages)",
976 Style::default().fg(palette.muted.color()),
977 ));
978 } else {
979 for msg in &detail.thread {
980 let author = msg.author.as_deref().unwrap_or("(unknown)");
981 let date = format_date(msg.date);
982 let subj = msg.subject.as_deref().unwrap_or("(no subject)");
983 lines.push(Line::from(vec![
984 Span::styled(author, Style::default().fg(palette.accent.color())),
985 Span::raw(" "),
986 Span::styled(date, Style::default().fg(palette.muted.color())),
987 ]));
988 lines.push(Line::styled(
989 format!(" {subj}"),
990 Style::default().fg(palette.foreground.color()),
991 ));
992 }
993 }
994}
995
996#[cfg(test)]
997#[allow(clippy::expect_used)]
998mod tests {
999 use super::*;
1000 use crate::config::Config;
1001 use crate::models::{FindingCounts, Paginated, Patchset};
1002 use ratatui::Terminal;
1003 use ratatui::backend::TestBackend;
1004
1005 fn make_config_with_remotes(names: &[&str]) -> Config {
1006 let mut config = Config::default();
1007 for name in names {
1008 config
1009 .remotes
1010 .push(crate::config::RemoteConfig::fixture(name));
1011 }
1012 config
1013 }
1014
1015 #[test]
1016 fn view_renders_without_panic() {
1017 let app = App::new(Config::default());
1018 let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("create test terminal");
1019 terminal
1020 .draw(|f| view(&app, f))
1021 .expect("draw should not fail");
1022 insta::assert_snapshot!(terminal.backend());
1023 }
1024
1025 #[test]
1026 fn view_renders_error_state() {
1027 let mut app = App::new(Config::default());
1028 app.error_state = Some("connection refused".to_string());
1029 let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("create test terminal");
1030 terminal
1031 .draw(|f| view(&app, f))
1032 .expect("draw with error should not fail");
1033 insta::assert_snapshot!(terminal.backend());
1034 }
1035
1036 #[test]
1037 fn view_renders_patchset_table() {
1038 let config = make_config_with_remotes(&["upstream"]);
1039 let mut app = App::new(config);
1040 app.patchsets = Paginated {
1041 items: vec![Patchset::fixture()],
1042 total: 1,
1043 page: 1,
1044 per_page: 50,
1045 };
1046 let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("create test terminal");
1047 terminal
1048 .draw(|f| view(&app, f))
1049 .expect("draw with patchsets should not fail");
1050 insta::assert_snapshot!(terminal.backend());
1051 }
1052
1053 #[test]
1054 fn view_renders_loading_state() {
1055 let config = make_config_with_remotes(&["upstream"]);
1056 let app = App::new(config);
1057 let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("create test terminal");
1058 terminal
1059 .draw(|f| view(&app, f))
1060 .expect("draw loading should not fail");
1061 insta::assert_snapshot!(terminal.backend());
1062 }
1063
1064 #[test]
1065 fn view_renders_sidebar_with_multiple_remotes() {
1066 let config = make_config_with_remotes(&["upstream", "staging", "local"]);
1067 let app = App::new(config);
1068 let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("create test terminal");
1069 terminal
1070 .draw(|f| view(&app, f))
1071 .expect("draw with sidebar should not fail");
1072 insta::assert_snapshot!(terminal.backend());
1073 }
1074
1075 #[test]
1076 fn view_renders_sidebar_focus() {
1077 let config = make_config_with_remotes(&["upstream", "staging"]);
1078 let mut app = App::new(config);
1079 app.focus = FocusPanel::Sidebar;
1080 let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("create test terminal");
1081 terminal
1082 .draw(|f| view(&app, f))
1083 .expect("draw with sidebar focus should not fail");
1084 insta::assert_snapshot!(terminal.backend());
1085 }
1086
1087 #[test]
1088 fn format_date_valid() {
1089 let result = format_date(Some(1_778_690_980));
1091 assert_eq!(result, "2026-05-13");
1092 }
1093
1094 #[test]
1095 fn format_date_none() {
1096 assert_eq!(format_date(None), "—");
1097 }
1098
1099 #[test]
1100 fn format_date_epoch() {
1101 assert_eq!(format_date(Some(0)), "1970-01-01");
1102 }
1103
1104 #[test]
1105 fn format_findings_empty() {
1106 let palette = ColorPalette::default();
1107 let fc = FindingCounts::default();
1108 let line = format_findings(&fc, &palette);
1109 assert_eq!(line.spans.len(), 1);
1111 }
1112
1113 #[test]
1114 fn format_findings_mixed() {
1115 let palette = ColorPalette::default();
1116 let fc = FindingCounts {
1117 low: 1,
1118 medium: 2,
1119 high: 1,
1120 critical: 0,
1121 };
1122 let line = format_findings(&fc, &palette);
1123 let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
1125 assert!(text.contains("1H"));
1126 assert!(text.contains("2M"));
1127 assert!(text.contains("1L"));
1128 }
1129
1130 #[test]
1131 fn format_findings_critical_only() {
1132 let palette = ColorPalette::default();
1133 let fc = FindingCounts {
1134 low: 0,
1135 medium: 0,
1136 high: 0,
1137 critical: 3,
1138 };
1139 let line = format_findings(&fc, &palette);
1140 let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
1141 assert_eq!(text, "3C");
1142 }
1143
1144 #[test]
1145 fn status_color_maps_correctly() {
1146 let palette = ColorPalette::default();
1147 let reviewed = status_color(PatchsetStatus::Reviewed, &palette);
1148 assert_eq!(reviewed.fg, Some(palette.success.color()));
1149
1150 let failed = status_color(PatchsetStatus::Failed, &palette);
1151 assert_eq!(failed.fg, Some(palette.error.color()));
1152
1153 let in_review = status_color(PatchsetStatus::InReview, &palette);
1154 assert_eq!(in_review.fg, Some(palette.accent.color()));
1155 }
1156
1157 #[test]
1162 fn classify_diff_line_addition() {
1163 let palette = ColorPalette::default();
1164 let style = classify_diff_line("+ x = compute();", &palette);
1165 assert_eq!(style.fg, Some(palette.success.color()));
1166 }
1167
1168 #[test]
1169 fn classify_diff_line_deletion() {
1170 let palette = ColorPalette::default();
1171 let style = classify_diff_line("- return 0;", &palette);
1172 assert_eq!(style.fg, Some(palette.error.color()));
1173 }
1174
1175 #[test]
1176 fn classify_diff_line_hunk_header() {
1177 let palette = ColorPalette::default();
1178 let style = classify_diff_line("@@ -10,3 +10,4 @@ int main(void)", &palette);
1179 assert_eq!(style.fg, Some(palette.accent.color()));
1180 }
1181
1182 #[test]
1183 fn classify_diff_line_file_header_plus() {
1184 let palette = ColorPalette::default();
1185 let style = classify_diff_line("+++ b/foo.c", &palette);
1186 assert_eq!(style.fg, Some(palette.foreground.color()));
1187 }
1188
1189 #[test]
1190 fn classify_diff_line_file_header_minus() {
1191 let palette = ColorPalette::default();
1192 let style = classify_diff_line("--- a/foo.c", &palette);
1193 assert_eq!(style.fg, Some(palette.foreground.color()));
1194 }
1195
1196 #[test]
1197 fn classify_diff_line_diff_header() {
1198 let palette = ColorPalette::default();
1199 let style = classify_diff_line("diff --git a/foo.c b/foo.c", &palette);
1200 assert_eq!(style.fg, Some(palette.foreground.color()));
1201 }
1202
1203 #[test]
1204 fn classify_diff_line_index_header() {
1205 let palette = ColorPalette::default();
1206 let style = classify_diff_line("index abc123..def456 100644", &palette);
1207 assert_eq!(style.fg, Some(palette.foreground.color()));
1208 }
1209
1210 #[test]
1211 fn classify_diff_line_context() {
1212 let palette = ColorPalette::default();
1213 let style = classify_diff_line(" int x = 0;", &palette);
1214 assert_eq!(style.fg, Some(palette.muted.color()));
1215 }
1216
1217 #[test]
1218 fn classify_diff_line_empty() {
1219 let palette = ColorPalette::default();
1220 let style = classify_diff_line("", &palette);
1221 assert_eq!(style.fg, Some(palette.muted.color()));
1222 }
1223
1224 #[test]
1229 fn review_line_quoted_addition() {
1230 let palette = ColorPalette::default();
1231 let style = classify_review_line("> + x = compute();", true, &palette);
1232 assert_eq!(style.fg, Some(palette.success.color()));
1233 }
1234
1235 #[test]
1236 fn review_line_quoted_deletion() {
1237 let palette = ColorPalette::default();
1238 let style = classify_review_line("> - return 0;", true, &palette);
1239 assert_eq!(style.fg, Some(palette.error.color()));
1240 }
1241
1242 #[test]
1243 fn review_line_quoted_hunk_header() {
1244 let palette = ColorPalette::default();
1245 let style = classify_review_line("> @@ -10,3 +10,4 @@", true, &palette);
1246 assert_eq!(style.fg, Some(palette.accent.color()));
1247 }
1248
1249 #[test]
1250 fn review_line_quoted_file_header() {
1251 let palette = ColorPalette::default();
1252 let style = classify_review_line("> +++ b/foo.c", true, &palette);
1253 assert_eq!(style.fg, Some(palette.foreground.color()));
1254 }
1255
1256 #[test]
1257 fn review_line_double_quoted() {
1258 let palette = ColorPalette::default();
1259 let style = classify_review_line(">> +added in nested quote", true, &palette);
1260 assert_eq!(style.fg, Some(palette.success.color()));
1261 }
1262
1263 #[test]
1264 fn review_line_quoted_no_space() {
1265 let palette = ColorPalette::default();
1266 let style = classify_review_line(">+added line", true, &palette);
1267 assert_eq!(style.fg, Some(palette.success.color()));
1268 }
1269
1270 #[test]
1271 fn review_line_quoted_context() {
1272 let palette = ColorPalette::default();
1273 let style = classify_review_line("> context line", true, &palette);
1274 assert_eq!(style.fg, Some(palette.muted.color()));
1275 }
1276
1277 #[test]
1280 fn review_line_metadata_commit_hash() {
1281 let palette = ColorPalette::default();
1282 let style = classify_review_line(
1283 "commit 37076247c47b0c3300cbefe9a1791f066ad33f2e",
1284 false,
1285 &palette,
1286 );
1287 assert_eq!(style.fg, Some(palette.muted.color()));
1289 }
1290
1291 #[test]
1292 fn review_line_metadata_author() {
1293 let palette = ColorPalette::default();
1294 let style =
1295 classify_review_line("Author: Audra Mitchell <audra@redhat.com>", false, &palette);
1296 assert_eq!(style.fg, Some(palette.muted.color()));
1297 }
1298
1299 #[test]
1300 fn review_line_metadata_subject() {
1301 let palette = ColorPalette::default();
1302 let style = classify_review_line(
1303 "Subject: mm/hugetlb: fix avoid_reserve to allow taking folio from subpool",
1304 false,
1305 &palette,
1306 );
1307 assert_eq!(style.fg, Some(palette.muted.color()));
1308 }
1309
1310 #[test]
1311 fn review_line_metadata_link() {
1312 let palette = ColorPalette::default();
1313 let style = classify_review_line(
1314 "Link: https://lkml.kernel.org/r/20250107204002.2683356-1-peterx@redhat.com",
1315 false,
1316 &palette,
1317 );
1318 assert_eq!(style.fg, Some(palette.muted.color()));
1319 }
1320
1321 #[test]
1322 fn review_line_metadata_description() {
1323 let palette = ColorPalette::default();
1324 let style = classify_review_line(
1325 "This commit backports an upstream change to allow hugetlb COW faults",
1326 false,
1327 &palette,
1328 );
1329 assert_eq!(style.fg, Some(palette.muted.color()));
1330 }
1331
1332 #[test]
1335 fn review_line_commentary_is_foreground() {
1336 let palette = ColorPalette::default();
1337 let style = classify_review_line("LGTM. The null check looks correct.", true, &palette);
1338 assert_eq!(style.fg, Some(palette.foreground.color()));
1339 }
1340
1341 #[test]
1342 fn review_line_commentary_suggestion() {
1343 let palette = ColorPalette::default();
1344 let style = classify_review_line("Consider adding a dev_err() log here.", true, &palette);
1345 assert_eq!(style.fg, Some(palette.foreground.color()));
1346 }
1347
1348 #[test]
1349 fn review_line_commentary_question() {
1350 let palette = ColorPalette::default();
1351 let style = classify_review_line("Should this be guarded by a mutex?", true, &palette);
1352 assert_eq!(style.fg, Some(palette.foreground.color()));
1353 }
1354
1355 #[test]
1358 fn review_line_empty_is_unstyled() {
1359 let palette = ColorPalette::default();
1360 let style = classify_review_line("", false, &palette);
1361 assert_eq!(style.fg, None);
1362 }
1363
1364 #[test]
1365 fn review_line_empty_after_quoted_is_unstyled() {
1366 let palette = ColorPalette::default();
1367 let style = classify_review_line("", true, &palette);
1368 assert_eq!(style.fg, None);
1369 }
1370
1371 #[test]
1374 fn review_stateful_classification_full_review() {
1375 let palette = ColorPalette::default();
1376 let review = concat!(
1377 "commit abc123\n",
1378 "Author: dev@example.com\n",
1379 "Subject: Fix null deref\n",
1380 "\n",
1381 "This fixes the bug.\n",
1382 "\n",
1383 "> diff --git a/foo.c b/foo.c\n",
1384 "> --- a/foo.c\n",
1385 "> +++ b/foo.c\n",
1386 "> @@ -10,3 +10,4 @@\n",
1387 "> int x = 0;\n",
1388 "> - return 0;\n",
1389 "> + x = compute();\n",
1390 "\n",
1391 "Good fix. Consider also checking for overflow.\n",
1392 );
1393
1394 let mut seen_quoted = false;
1395 let mut styles: Vec<Option<ratatui::style::Color>> = Vec::new();
1396 for line in review.lines() {
1397 if line.starts_with('>') {
1398 seen_quoted = true;
1399 }
1400 let style = classify_review_line(line, seen_quoted, &palette);
1401 styles.push(style.fg);
1402 }
1403
1404 assert_eq!(styles[0], Some(palette.muted.color()), "commit hash");
1406 assert_eq!(styles[1], Some(palette.muted.color()), "Author");
1407 assert_eq!(styles[2], Some(palette.muted.color()), "Subject");
1408 assert_eq!(styles[3], None, "empty separator");
1410 assert_eq!(styles[4], Some(palette.muted.color()), "description");
1412 assert_eq!(styles[5], None, "empty separator");
1414 assert_eq!(styles[6], Some(palette.foreground.color()), "diff header"); assert_eq!(styles[7], Some(palette.foreground.color()), "--- header"); assert_eq!(styles[8], Some(palette.foreground.color()), "+++ header"); assert_eq!(styles[9], Some(palette.accent.color()), "@@ hunk");
1419 assert_eq!(styles[10], Some(palette.muted.color()), "context");
1420 assert_eq!(styles[11], Some(palette.error.color()), "deletion");
1421 assert_eq!(styles[12], Some(palette.success.color()), "addition");
1422 assert_eq!(styles[13], None, "empty separator");
1424 assert_eq!(styles[14], Some(palette.foreground.color()), "commentary");
1426 }
1427
1428 #[test]
1431 fn strip_review_quoting_no_prefix() {
1432 assert_eq!(strip_review_quoting("+added line"), "+added line");
1433 }
1434
1435 #[test]
1436 fn strip_review_quoting_single_level() {
1437 assert_eq!(strip_review_quoting("> +added line"), "+added line");
1438 }
1439
1440 #[test]
1441 fn strip_review_quoting_double_level() {
1442 assert_eq!(strip_review_quoting(">> +added line"), "+added line");
1443 }
1444
1445 #[test]
1446 fn strip_review_quoting_no_space() {
1447 assert_eq!(strip_review_quoting(">+added line"), "+added line");
1448 }
1449
1450 #[test]
1451 fn strip_review_quoting_empty() {
1452 assert_eq!(strip_review_quoting(""), "");
1453 }
1454
1455 #[test]
1456 fn strip_review_quoting_only_chevron() {
1457 assert_eq!(strip_review_quoting("> "), "");
1458 }
1459
1460 #[test]
1463 fn detail_thread_lines_renders_flat() {
1464 use crate::models::{PatchsetDetail, ThreadMessage};
1465
1466 let mut detail = PatchsetDetail::fixture();
1467 detail.thread = vec![
1468 ThreadMessage {
1469 id: 1,
1470 message_id: Some("msg-1@example.com".to_string()),
1471 author: Some("developer@kernel.org".to_string()),
1472 date: Some(1_778_690_980),
1473 subject: Some("[PATCH v2 0/3] Fix null deref".to_string()),
1474 in_reply_to: None,
1475 },
1476 ThreadMessage {
1477 id: 2,
1478 message_id: Some("msg-2@example.com".to_string()),
1479 author: Some("maintainer@kernel.org".to_string()),
1480 date: Some(1_778_700_000),
1481 subject: Some("Re: [PATCH v2 0/3] Fix null deref".to_string()),
1482 in_reply_to: Some("msg-1@example.com".to_string()),
1483 },
1484 ];
1485
1486 let palette = ColorPalette::default();
1487 let mut lines: Vec<Line> = Vec::new();
1488 detail_thread_lines(&detail, &palette, &mut lines);
1489
1490 assert!(lines[0].to_string().contains("Thread (2)"));
1492
1493 assert!(lines[1].spans[0].content.contains("developer@kernel.org"));
1495
1496 assert!(lines[3].spans[0].content.contains("maintainer@kernel.org"));
1498
1499 assert_eq!(lines.len(), 5);
1501 }
1502
1503 #[test]
1504 fn detail_header_lines_basic() {
1505 let palette = ColorPalette::default();
1506 let detail = crate::models::PatchsetDetail::fixture();
1507 let mut lines: Vec<Line> = Vec::new();
1508 detail_header_lines(&detail, &palette, &mut lines);
1509 assert!(!lines.is_empty());
1511 let last = lines.last().expect("should have lines");
1513 assert!(last.spans.is_empty() || last.to_string().is_empty());
1514 }
1515
1516 #[test]
1517 fn detail_header_lines_with_failure_reason() {
1518 let palette = ColorPalette::default();
1519 let mut detail = crate::models::PatchsetDetail::fixture();
1520 detail.failed_reason = Some("compile error".to_string());
1521 let mut lines: Vec<Line> = Vec::new();
1522 detail_header_lines(&detail, &palette, &mut lines);
1523 let text: String = lines
1524 .iter()
1525 .map(std::string::ToString::to_string)
1526 .collect::<Vec<_>>()
1527 .join("\n");
1528 assert!(text.contains("compile error"), "got: {text}");
1529 }
1530
1531 #[test]
1532 fn detail_header_lines_with_baseline() {
1533 let palette = ColorPalette::default();
1534 let mut detail = crate::models::PatchsetDetail::fixture();
1535 detail.baseline = Some(crate::models::Baseline {
1536 branch: Some("main".to_string()),
1537 commit: Some("abc123def456".to_string()),
1538 repo_url: None,
1539 });
1540 let mut lines: Vec<Line> = Vec::new();
1541 detail_header_lines(&detail, &palette, &mut lines);
1542 let text: String = lines
1543 .iter()
1544 .map(std::string::ToString::to_string)
1545 .collect::<Vec<_>>()
1546 .join("\n");
1547 assert!(text.contains("Baseline:"), "got: {text}");
1548 assert!(text.contains("main"), "got: {text}");
1549 }
1550
1551 #[test]
1552 fn detail_header_lines_with_model() {
1553 let palette = ColorPalette::default();
1554 let mut detail = crate::models::PatchsetDetail::fixture();
1555 detail.model_name = Some("gpt-4".to_string());
1556 detail.provider = Some("openai".to_string());
1557 let mut lines: Vec<Line> = Vec::new();
1558 detail_header_lines(&detail, &palette, &mut lines);
1559 let text: String = lines
1560 .iter()
1561 .map(std::string::ToString::to_string)
1562 .collect::<Vec<_>>()
1563 .join("\n");
1564 assert!(text.contains("Model: openai/gpt-4"), "got: {text}");
1565 }
1566
1567 #[test]
1568 fn detail_patches_lines_empty() {
1569 let palette = ColorPalette::default();
1570 let mut detail = crate::models::PatchsetDetail::fixture();
1571 detail.patches.clear();
1572 let mut lines: Vec<Line> = Vec::new();
1573 detail_patches_lines(&detail, &palette, &mut lines);
1574 let text: String = lines
1575 .iter()
1576 .map(std::string::ToString::to_string)
1577 .collect::<Vec<_>>()
1578 .join("\n");
1579 assert!(text.contains("Patches (0)"), "got: {text}");
1580 assert!(text.contains("(no patches)"), "got: {text}");
1581 }
1582
1583 #[test]
1584 fn detail_patches_lines_with_patches() {
1585 let palette = ColorPalette::default();
1586 let detail = crate::models::PatchsetDetail::fixture();
1587 let mut lines: Vec<Line> = Vec::new();
1588 detail_patches_lines(&detail, &palette, &mut lines);
1589 assert!(lines.len() >= 2);
1591 let text: String = lines
1592 .iter()
1593 .map(std::string::ToString::to_string)
1594 .collect::<Vec<_>>()
1595 .join("\n");
1596 assert!(text.contains("Patches ("), "got: {text}");
1597 }
1598
1599 #[test]
1600 fn message_detail_lines_basic() {
1601 let palette = ColorPalette::default();
1602 let msg = crate::models::EmailMessage::fixture();
1603 let lines = message_detail_lines(&msg, &palette);
1604 let text: String = lines
1605 .iter()
1606 .map(std::string::ToString::to_string)
1607 .collect::<Vec<_>>()
1608 .join("\n");
1609 assert!(text.contains("From:"), "got: {text}");
1610 assert!(text.contains("Date:"), "got: {text}");
1611 }
1612
1613 #[test]
1614 fn message_detail_lines_with_optional_fields() {
1615 let palette = ColorPalette::default();
1616 let mut msg = crate::models::EmailMessage::fixture();
1617 msg.to = Some("recipient@example.com".to_string());
1618 msg.cc = Some("cc@example.com".to_string());
1619 msg.mailing_list = Some("linux-kernel".to_string());
1620 let lines = message_detail_lines(&msg, &palette);
1621 let text: String = lines
1622 .iter()
1623 .map(std::string::ToString::to_string)
1624 .collect::<Vec<_>>()
1625 .join("\n");
1626 assert!(text.contains("To:"), "got: {text}");
1627 assert!(text.contains("Cc:"), "got: {text}");
1628 assert!(text.contains("List:"), "got: {text}");
1629 }
1630
1631 #[test]
1632 fn message_detail_lines_with_body() {
1633 let palette = ColorPalette::default();
1634 let mut msg = crate::models::EmailMessage::fixture();
1635 msg.body = Some("Hello world\nSecond line".to_string());
1636 let lines = message_detail_lines(&msg, &palette);
1637 let text: String = lines
1638 .iter()
1639 .map(std::string::ToString::to_string)
1640 .collect::<Vec<_>>()
1641 .join("\n");
1642 assert!(text.contains("Hello world"), "got: {text}");
1643 assert!(text.contains("Second line"), "got: {text}");
1644 }
1645
1646 #[test]
1647 fn message_detail_lines_no_body() {
1648 let palette = ColorPalette::default();
1649 let mut msg = crate::models::EmailMessage::fixture();
1650 msg.body = None;
1651 let lines = message_detail_lines(&msg, &palette);
1652 let text: String = lines
1653 .iter()
1654 .map(std::string::ToString::to_string)
1655 .collect::<Vec<_>>()
1656 .join("\n");
1657 assert!(text.contains("(no body)"), "got: {text}");
1658 }
1659
1660 #[test]
1661 fn message_detail_lines_with_diff() {
1662 let palette = ColorPalette::default();
1663 let msg = crate::models::EmailMessage::fixture_with_diff();
1664 let lines = message_detail_lines(&msg, &palette);
1665 let text: String = lines
1666 .iter()
1667 .map(std::string::ToString::to_string)
1668 .collect::<Vec<_>>()
1669 .join("\n");
1670 assert!(text.contains("Diff"), "got: {text}");
1671 }
1672}