Skip to main content

seshat_cli/tui/
widgets.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    style::{Color, Modifier, Style},
5    symbols,
6    text::{Line, Span},
7    widgets::{Block, Borders, Paragraph, Widget, Wrap},
8};
9
10#[cfg(test)]
11use super::app::CodeExample;
12use super::app::{App, ConventionItem};
13
14pub fn render(frame: &mut ratatui::Frame, app: &App) {
15    let area = frame.area();
16
17    let has_examples = app
18        .current()
19        .map(|c| !c.examples.is_empty())
20        .unwrap_or(false);
21
22    let has_filter = app.search_mode || app.filter_locked;
23
24    if let Some(convention) = app.current() {
25        let card = ConventionCard {
26            convention,
27            current: if has_filter {
28                app.filtered_current_index()
29            } else {
30                app.current_index
31            },
32            total: if has_filter {
33                app.filtered_total()
34            } else {
35                app.total()
36            },
37            review_complete: app.review_complete,
38            has_examples,
39            search_mode: app.search_mode,
40            search_query: &app.search_query,
41            filter_locked: app.filter_locked,
42            no_match: false,
43        };
44        card.render(area, frame.buffer_mut());
45    } else if !app.conventions.is_empty()
46        && app.filtered_indices.is_empty()
47        && (app.search_mode || app.filter_locked)
48    {
49        let card = ConventionCard {
50            convention: &app.conventions[0],
51            current: 0,
52            total: 0,
53            review_complete: false,
54            has_examples: false,
55            search_mode: app.search_mode,
56            search_query: &app.search_query,
57            filter_locked: app.filter_locked,
58            no_match: true,
59        };
60        card.render(area, frame.buffer_mut());
61    } else {
62        Paragraph::new("No convention to display").render(area, frame.buffer_mut());
63    }
64}
65
66pub struct ConventionCard<'a> {
67    pub convention: &'a ConventionItem,
68    pub current: usize,
69    pub total: usize,
70    pub review_complete: bool,
71    has_examples: bool,
72    search_mode: bool,
73    search_query: &'a str,
74    filter_locked: bool,
75    no_match: bool,
76}
77
78impl ConventionCard<'_> {
79    /// Build the example panel's title bar text.
80    ///
81    /// Three states, kept in one place so the previously-nested
82    /// `if self.has_examples { if let Some(example) = ... { if N > 1 { ...
83    /// } else ... } else ... } else ...` chain inside the `Span::styled`
84    /// argument doesn't have to live mid-render.
85    fn example_title(&self) -> String {
86        if !self.has_examples {
87            return "── (no usage examples) ".to_owned();
88        }
89        let Some(example) = self.convention.examples.get(self.convention.example_index) else {
90            // Out-of-bounds index: examples DO exist, but the cursor
91            // points past the end. This indicates an off-by-one bug
92            // in the convention-list cursor (e.g. the list shrank but
93            // `example_index` was not reset). Earlier code returned
94            // the same "(no usage examples)" title used for the truly-
95            // empty case, silently masking the bug. Render a distinct
96            // marker so the regression is visible at runtime.
97            debug_assert!(
98                false,
99                "example_index {} out of bounds (have {} examples) — \
100                 cursor was not reset after the convention list changed",
101                self.convention.example_index,
102                self.convention.examples.len(),
103            );
104            return format!(
105                "── (example index {}/{} out of range) ",
106                self.convention.example_index + 1,
107                self.convention.examples.len(),
108            );
109        };
110        let example_num = self.convention.example_index + 1;
111        let examples_count = self.convention.examples.len();
112        // Composite evidence (file-level summaries from
113        // aggregate_findings — "98 files match this convention" snippet)
114        // has empty `file` and `line == 0`. Render a distinct
115        // "── Summary " title without the bogus `(…:0)` suffix the
116        // per-file branch would produce.
117        if example.file.is_empty() && example.line == 0 {
118            return if examples_count > 1 {
119                format!("── Summary ({example_num}/{examples_count}) ")
120            } else {
121                "── Summary ".to_owned()
122            };
123        }
124        let file_display = shorten_path(&example.file);
125        let line = example.line;
126        if examples_count > 1 {
127            format!("── Example ({example_num}/{examples_count}): (\u{2026}{file_display}:{line}) ")
128        } else {
129            format!("── Example: (\u{2026}{file_display}:{line}) ")
130        }
131    }
132}
133
134impl Widget for ConventionCard<'_> {
135    fn render(self, area: Rect, buf: &mut Buffer) {
136        let border_style = Style::default().fg(Color::Blue);
137        let divider_set = symbols::border::Set {
138            top_left: "├",
139            top_right: "┤",
140            ..symbols::border::PLAIN
141        };
142        let outer_block = Block::default()
143            .borders(Borders::ALL)
144            .title("── Seshat Convention Review ")
145            .style(Style::default().fg(Color::Cyan))
146            .border_style(border_style);
147        let inner = outer_block.inner(area);
148        outer_block.render(area, buf);
149
150        if self.no_match {
151            Paragraph::new("  No matching conventions")
152                .style(
153                    Style::default()
154                        .fg(Color::Yellow)
155                        .add_modifier(Modifier::BOLD),
156                )
157                .render(inner, buf);
158            render_key_bindings(
159                buf,
160                Rect {
161                    x: area.x,
162                    y: area.height.saturating_sub(1),
163                    width: area.width,
164                    height: 1,
165                },
166                0,
167            );
168            return;
169        }
170
171        let has_search_bar = self.search_mode;
172
173        // Fixed: header(1), div(1), info(3). Example fills rest. Fixed: div(1), ctrl(1). Optional: search_bar(1).
174        let [header_height, info_height] = if self.filter_locked {
175            [Constraint::Length(2), Constraint::Length(2)]
176        } else {
177            [Constraint::Length(1), Constraint::Length(3)]
178        };
179
180        let constraints: Vec<Constraint> = {
181            let mut v = vec![header_height, Constraint::Length(1), info_height];
182            v.push(Constraint::Min(2));
183            v.push(Constraint::Length(1));
184            v.push(Constraint::Length(1));
185            v
186        };
187
188        let areas = Layout::vertical(&constraints).split(inner);
189        let header_area = areas[0];
190        let div1_area = areas[1];
191        let info_area = areas[2];
192        let example_area = areas[3];
193        let div2_area = areas[4];
194        let ctrl_area = areas[5];
195
196        // Header: "    1/53: description" or "[filter: 'keyword']"
197        if self.filter_locked {
198            let filter_text = format!("  [filter: '{}']", self.search_query);
199            Paragraph::new(filter_text)
200                .style(
201                    Style::default()
202                        .fg(Color::Cyan)
203                        .add_modifier(Modifier::BOLD),
204                )
205                .render(header_area, buf);
206
207            let desc_text = format!(
208                "  {}/{}: {}",
209                self.current + 1,
210                self.total,
211                self.convention.description
212            );
213            Paragraph::new(desc_text)
214                .style(
215                    Style::default()
216                        .fg(Color::White)
217                        .add_modifier(Modifier::BOLD),
218                )
219                .wrap(Wrap { trim: false })
220                .render(
221                    Rect {
222                        y: header_area.y + 1,
223                        height: 1,
224                        ..header_area
225                    },
226                    buf,
227                );
228        } else {
229            let desc_text = format!(
230                "  {}/{}: {}",
231                self.current + 1,
232                self.total,
233                self.convention.description
234            );
235            Paragraph::new(desc_text)
236                .style(
237                    Style::default()
238                        .fg(Color::White)
239                        .add_modifier(Modifier::BOLD),
240                )
241                .wrap(Wrap { trim: false })
242                .render(header_area, buf);
243        }
244
245        // ├────────────────────────────────────────────────────────────────────┤
246        Block::default()
247            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
248            .border_set(divider_set)
249            .border_style(border_style)
250            .render(
251                Rect {
252                    x: area.x,
253                    y: div1_area.y,
254                    width: area.width,
255                    height: 1,
256                },
257                buf,
258            );
259
260        // Info section: metadata + adoption (2-space indent)
261        let weight_display = match self.convention.weight.as_str() {
262            "rule" => "Rule",
263            "strong" => "Strong",
264            "moderate" => "Moderate",
265            "weak" => "Weak",
266            "info" => "Info",
267            other => other,
268        };
269        let nature_display = match self.convention.nature.as_str() {
270            "convention" => "Convention",
271            "observation" => "Observation",
272            other => other,
273        };
274
275        let meta = Line::from(vec![
276            Span::raw("  "),
277            Span::styled(
278                format!("Nature: {nature_display}"),
279                Style::default().fg(Color::Green),
280            ),
281            Span::raw("       "),
282            Span::styled(
283                format!("Confidence: {}%", self.convention.confidence_pct),
284                Style::default().fg(Color::Yellow),
285            ),
286            Span::raw("       "),
287            Span::styled(
288                format!("Weight: {weight_display}"),
289                Style::default().fg(Color::Magenta),
290            ),
291        ]);
292
293        let adoption = format!(
294            "  Found in: {}/{} files ({}% adoption)",
295            self.convention.adoption_count,
296            self.convention.total_count,
297            self.convention.adoption_rate_pct
298        );
299
300        Paragraph::new(vec![meta, Line::from(adoption), Line::default()]).render(info_area, buf);
301
302        // Example section: collapsed-border title + code lines filling remaining space
303        // ├── Example (n/m): (…path:line) ─────────────────────────────┤
304        let example_title = self.example_title();
305        Block::default()
306            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
307            .border_set(divider_set)
308            .border_style(border_style)
309            .title(Span::styled(example_title, border_style))
310            .render(
311                Rect {
312                    x: area.x,
313                    y: example_area.y,
314                    width: area.width,
315                    height: 1,
316                },
317                buf,
318            );
319
320        // Code lines fill the rest of example_area
321        let code_area = Rect {
322            y: example_area.y + 1,
323            height: example_area.height.saturating_sub(1),
324            ..example_area
325        };
326
327        if self.has_examples {
328            if let Some(example) = self.convention.examples.get(self.convention.example_index) {
329                let max_lines = code_area.height as usize;
330                let max_chars = code_area.width.saturating_sub(8).max(1) as usize;
331
332                let is_composite = example.file.is_empty() && example.line == 0;
333
334                let snippet_lines: Vec<Line> = if is_composite {
335                    // Composite/synthetic summary: no real source lines
336                    // to anchor at — render the snippet text as-is, no
337                    // line-number gutter, no green highlight.
338                    example
339                        .snippet
340                        .lines()
341                        .take(max_lines)
342                        .map(|line_text| {
343                            Line::from(Span::styled(
344                                truncate_str(line_text, max_chars),
345                                Style::default().fg(Color::Cyan),
346                            ))
347                        })
348                        .collect()
349                } else {
350                    let snippet_start = if example.snippet_start_line > 0 {
351                        example.snippet_start_line
352                    } else {
353                        example.line
354                    };
355                    example
356                        .snippet
357                        .lines()
358                        .take(max_lines)
359                        .enumerate()
360                        .map(|(i, line_text)| {
361                            let line_num = snippet_start + i as u32;
362                            let is_highlight = line_num >= example.line
363                                && line_num <= example.end_line.max(example.line);
364                            let display = truncate_str(line_text, max_chars);
365                            let text_style = if is_highlight {
366                                Style::default()
367                                    .fg(Color::Green)
368                                    .add_modifier(Modifier::BOLD)
369                            } else {
370                                Style::default().fg(Color::Yellow)
371                            };
372                            Line::from(vec![
373                                Span::styled(
374                                    format!("{:>5}  ", line_num),
375                                    Style::default().fg(Color::DarkGray),
376                                ),
377                                Span::styled(display, text_style),
378                            ])
379                        })
380                        .collect()
381                };
382
383                if snippet_lines.is_empty() {
384                    Paragraph::new("(no snippet available)")
385                        .style(Style::default().fg(Color::DarkGray))
386                        .render(code_area, buf);
387                } else {
388                    Paragraph::new(snippet_lines).render(code_area, buf);
389                }
390            }
391        } else {
392            Paragraph::new("(no usage examples found for this convention)")
393                .style(Style::default().fg(Color::DarkGray))
394                .render(code_area, buf);
395        }
396
397        // ├────────────────────────────────────────────────────────────────────┤
398        Block::default()
399            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
400            .border_set(divider_set)
401            .border_style(border_style)
402            .render(
403                Rect {
404                    x: area.x,
405                    y: div2_area.y,
406                    width: area.width,
407                    height: 1,
408                },
409                buf,
410            );
411
412        let examples_count = self.convention.examples.len();
413
414        if has_search_bar {
415            let hint = "Press Enter to keep or Esc to clear";
416            let prompt = format!("  Filter: {}", self.search_query);
417            let prompt_width = prompt.chars().count();
418            let hint_width = hint.chars().count();
419            let gap = ctrl_area.width as usize;
420
421            if prompt_width + hint_width + 2 < gap {
422                let pad = gap - prompt_width - hint_width - 2;
423                let full = format!("{prompt}{}{hint}", " ".repeat(pad));
424                Paragraph::new(full)
425                    .style(Style::default().fg(Color::Yellow))
426                    .render(ctrl_area, buf);
427            } else {
428                Paragraph::new(prompt)
429                    .style(Style::default().fg(Color::Yellow))
430                    .render(ctrl_area, buf);
431            }
432
433            let cursor_pos = 10 + self.search_query.len();
434            if cursor_pos < ctrl_area.width as usize {
435                if let Some(c) = buf.cell_mut((ctrl_area.x + cursor_pos as u16, ctrl_area.y)) {
436                    c.set_style(
437                        Style::default()
438                            .fg(Color::Cyan)
439                            .add_modifier(Modifier::REVERSED),
440                    );
441                }
442            }
443        } else {
444            render_key_bindings(buf, ctrl_area, examples_count);
445        }
446    }
447}
448
449fn truncate_str(s: &str, max_len: usize) -> String {
450    if s.chars().count() <= max_len {
451        return s.to_owned();
452    }
453    let truncated: String = s.chars().take(max_len).collect();
454    format!("{}\u{2026}", truncated)
455}
456
457fn shorten_path(path: &str) -> String {
458    let parts: Vec<&str> = path.split('/').collect();
459    if parts.len() <= 4 {
460        return path.to_owned();
461    }
462    let tail = &parts[parts.len() - 4..];
463    format!("\u{2026}/{}", tail.join("/"))
464}
465
466fn render_key_bindings(buf: &mut Buffer, area: Rect, examples_count: usize) {
467    let inner_width = area.width as usize;
468
469    let mut parts: Vec<(&str, Style)> = vec![
470        (
471            " [y] Confirm",
472            Style::default()
473                .fg(Color::Green)
474                .add_modifier(Modifier::BOLD),
475        ),
476        (
477            "[n] Reject",
478            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
479        ),
480        (
481            "[p] Partial",
482            Style::default()
483                .fg(Color::Yellow)
484                .add_modifier(Modifier::BOLD),
485        ),
486        (
487            "[s] Skip",
488            Style::default()
489                .fg(Color::Blue)
490                .add_modifier(Modifier::BOLD),
491        ),
492        (
493            "[\u{2191}\u{2193}/jk] Navigate",
494            Style::default()
495                .fg(Color::White)
496                .add_modifier(Modifier::BOLD),
497        ),
498    ];
499
500    if examples_count > 1 {
501        parts.push((
502            "[\u{2190}\u{2192}/ad] Examples",
503            Style::default()
504                .fg(Color::White)
505                .add_modifier(Modifier::BOLD),
506        ));
507    }
508
509    parts.push((
510        "[q/Esc] Finish",
511        Style::default()
512            .fg(Color::Magenta)
513            .add_modifier(Modifier::BOLD),
514    ));
515
516    let mut spans = Vec::new();
517    for (text, style) in &parts {
518        if !spans.is_empty() {
519            spans.push(Span::raw("  "));
520        }
521        spans.push(Span::styled(text.to_string(), *style));
522    }
523
524    let rendered_text: String = Line::from(spans.clone()).to_string();
525    if rendered_text.chars().count() > inner_width {
526        let take = inner_width.saturating_sub(3);
527        let truncated: String = rendered_text.chars().take(take).collect();
528        spans = vec![Span::styled(truncated + "...", parts.last().unwrap().1)];
529    }
530
531    Paragraph::new(Line::from(spans)).render(area, buf);
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn shorten_path_keeps_short_paths() {
540        assert_eq!(shorten_path("src/main.rs"), "src/main.rs");
541        assert_eq!(shorten_path("a/b/c/d.rs"), "a/b/c/d.rs");
542    }
543
544    #[test]
545    fn shorten_path_truncates_long_paths() {
546        let result = shorten_path("very/long/path/that/has/many/segments/file.rs");
547        assert!(result.starts_with("\u{2026}/"));
548        assert!(result.contains("file.rs"));
549    }
550
551    #[test]
552    fn shorten_path_exact_four_parts_not_truncated() {
553        assert_eq!(shorten_path("a/b/c/d"), "a/b/c/d");
554    }
555
556    #[test]
557    fn shorten_path_five_parts_truncated() {
558        let result = shorten_path("a/b/c/d/e.rs");
559        assert!(result.starts_with("\u{2026}/"));
560        assert!(result.contains("d/e.rs"));
561    }
562
563    #[test]
564    fn layout_constraints_produce_valid_areas() {
565        let area = Rect::new(0, 0, 120, 40);
566        let areas: [Rect; 6] = Layout::vertical([
567            Constraint::Length(1),
568            Constraint::Length(1),
569            Constraint::Length(2),
570            Constraint::Min(2),
571            Constraint::Length(1),
572            Constraint::Length(1),
573        ])
574        .areas(area);
575
576        assert!(areas[3].height >= 2);
577        assert_eq!(areas[5].height, 1);
578    }
579
580    #[test]
581    fn layout_with_examples_provides_six_areas() {
582        let inner = Rect::new(0, 0, 120, 30);
583        let areas: [Rect; 6] = Layout::vertical([
584            Constraint::Length(1),
585            Constraint::Length(1),
586            Constraint::Length(2),
587            Constraint::Min(2),
588            Constraint::Length(1),
589            Constraint::Length(1),
590        ])
591        .areas(inner);
592
593        assert_eq!(areas.len(), 6);
594        assert!(areas[3].height >= 2);
595    }
596
597    #[test]
598    fn layout_without_examples_provides_zero_height_for_code() {
599        let inner = Rect::new(0, 0, 120, 30);
600        let areas: [Rect; 6] = Layout::vertical([
601            Constraint::Length(1),
602            Constraint::Length(1),
603            Constraint::Length(2),
604            Constraint::Length(0),
605            Constraint::Length(1),
606            Constraint::Length(1),
607        ])
608        .areas(inner);
609
610        assert_eq!(areas.len(), 6);
611        assert_eq!(areas[3].height, 0);
612    }
613
614    #[test]
615    fn progress_title_format_single_digit() {
616        let total_width = 9.to_string().len().max(1);
617        let title = format!(
618            " Seshat Convention Review {:>width$}/{:<width$} ",
619            1,
620            9,
621            width = total_width
622        );
623        assert!(title.contains("1/9"));
624    }
625
626    #[test]
627    fn progress_title_format_double_digit() {
628        let total_width = 10.to_string().len().max(1);
629        let title = format!(
630            " Seshat Convention Review {:>width$}/{:<width$} ",
631            5,
632            10,
633            width = total_width
634        );
635        assert!(title.contains(" 5/10"));
636    }
637
638    #[test]
639    fn progress_title_format_triple_digit() {
640        let total_width = 100.to_string().len().max(1);
641        let title = format!(
642            " Seshat Convention Review {:>width$}/{:<width$} ",
643            50,
644            100,
645            width = total_width
646        );
647        assert!(title.contains(" 50/100"));
648    }
649
650    #[test]
651    fn truncate_str_short_string_no_change() {
652        assert_eq!(truncate_str("hello", 10), "hello");
653    }
654
655    #[test]
656    fn truncate_str_long_string_truncates() {
657        let result = truncate_str("hello world", 7);
658        assert!(result.ends_with("\u{2026}"));
659        assert_eq!(result.chars().count(), 8); // 7 + "\u{2026}"
660    }
661
662    #[test]
663    fn truncate_str_empty_string() {
664        assert_eq!(truncate_str("", 0), "");
665        assert_eq!(truncate_str("", 5), "");
666    }
667
668    #[test]
669    fn truncate_str_exact_length() {
670        assert_eq!(truncate_str("abc", 3), "abc");
671    }
672
673    #[test]
674    fn render_key_bindings_single_example() {
675        let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
676        render_key_bindings(&mut buf, Rect::new(0, 0, 120, 1), 1);
677        let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
678        assert!(text.contains("[y] Confirm"));
679        assert!(text.contains("[n] Reject"));
680        assert!(!text.contains("Examples"));
681    }
682
683    #[test]
684    fn render_key_bindings_multiple_examples() {
685        let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
686        render_key_bindings(&mut buf, Rect::new(0, 0, 120, 1), 3);
687        let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
688        assert!(text.contains("Examples"));
689    }
690
691    #[test]
692    fn render_key_bindings_zero_examples() {
693        let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
694        render_key_bindings(&mut buf, Rect::new(0, 0, 120, 1), 0);
695        let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
696        assert!(!text.contains("Examples"));
697    }
698
699    fn make_conv_item(desc: &str, examples: Vec<CodeExample>) -> ConventionItem {
700        use std::hash::{Hash, Hasher};
701        let mut hasher = std::collections::hash_map::DefaultHasher::default();
702        desc.hash(&mut hasher);
703        let hash = hasher.finish();
704
705        ConventionItem {
706            node_id: 1,
707            description: desc.to_owned(),
708            nature: "convention".to_owned(),
709            weight: "strong".to_owned(),
710            confidence_pct: 85,
711            adoption_count: 10,
712            total_count: 12,
713            adoption_rate_pct: 83,
714            trend: "stable".to_owned(),
715            source: "auto".to_owned(),
716            examples,
717            snapshot_hash: hash,
718            example_index: 0,
719            description_hash: None,
720        }
721    }
722
723    #[test]
724    fn convention_card_fills_buffer() {
725        let examples = vec![CodeExample {
726            file: "src/main.rs".to_owned(),
727            line: 10,
728            end_line: 12,
729            snippet: "fn main() {\n    println!(\"hi\");\n}".to_owned(),
730            snippet_start_line: 10,
731        }];
732        let item = make_conv_item("Use snake_case", examples);
733        let card = ConventionCard {
734            convention: &item,
735            current: 0,
736            total: 1,
737            review_complete: false,
738            has_examples: true,
739            search_mode: false,
740            search_query: "",
741            filter_locked: false,
742            no_match: false,
743        };
744        let mut buf = Buffer::empty(Rect::new(0, 0, 120, 30));
745        card.render(Rect::new(0, 0, 120, 30), &mut buf);
746        let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
747        assert!(text.contains("Use snake_case"));
748        assert!(text.contains("Nature: Convention"));
749        assert!(text.contains("Confidence: 85%"));
750    }
751
752    #[test]
753    fn convention_card_no_examples_fills_buffer() {
754        let item = make_conv_item("Use camelCase", vec![]);
755        let card = ConventionCard {
756            convention: &item,
757            current: 0,
758            total: 1,
759            review_complete: true,
760            has_examples: false,
761            search_mode: false,
762            search_query: "",
763            filter_locked: false,
764            no_match: false,
765        };
766        let mut buf = Buffer::empty(Rect::new(0, 0, 120, 30));
767        card.render(Rect::new(0, 0, 120, 30), &mut buf);
768        let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
769        assert!(text.contains("Use camelCase"));
770    }
771
772    /// Three states for the example title bar produce three distinct
773    /// titles. An earlier version returned `"── (no usage examples)"`
774    /// for both "no examples exist" AND "examples exist but the cursor
775    /// is out of bounds" — silently masking off-by-one bugs in the
776    /// convention list cursor.
777    #[test]
778    fn example_title_distinguishes_no_examples_from_oob_cursor() {
779        // State 1: has_examples = false → "no usage examples".
780        let item_empty = make_conv_item("X", vec![]);
781        let card_empty = ConventionCard {
782            convention: &item_empty,
783            current: 0,
784            total: 1,
785            review_complete: false,
786            has_examples: false,
787            search_mode: false,
788            search_query: "",
789            filter_locked: false,
790            no_match: false,
791        };
792        assert!(card_empty.example_title().contains("no usage examples"));
793    }
794
795    /// State 2: has_examples = true, but example_index points past
796    /// the examples vec. Production must NOT silently render the
797    /// empty-state title.
798    ///
799    /// The function has two branches by build profile:
800    /// - debug builds: `debug_assert!(false, ...)` panics so the
801    ///   off-by-one is impossible to miss locally.
802    /// - release builds: falls through to `format!(...)` returning a
803    ///   distinct "out of range" title so the bug is at least visible
804    ///   in the UI.
805    ///
806    /// `#[should_panic]` only verifies the debug behaviour and FAILS
807    /// the build under `cargo test --release`. Use `catch_unwind` to
808    /// cover both branches in one test.
809    #[test]
810    fn example_title_oob_index_does_not_silently_render_no_examples() {
811        let mut item = make_conv_item(
812            "X",
813            vec![CodeExample {
814                file: "a.rs".to_owned(),
815                line: 1,
816                end_line: 1,
817                snippet: "x".to_owned(),
818                snippet_start_line: 1,
819            }],
820        );
821        item.example_index = 5; // out of bounds
822        let card = ConventionCard {
823            convention: &item,
824            current: 0,
825            total: 1,
826            review_complete: false,
827            has_examples: true,
828            search_mode: false,
829            search_query: "",
830            filter_locked: false,
831            no_match: false,
832        };
833        let result =
834            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| card.example_title()));
835
836        if cfg!(debug_assertions) {
837            assert!(
838                result.is_err(),
839                "debug build must panic via debug_assert! when example_index is OOB",
840            );
841        } else {
842            let title = result.expect("release build must not panic on OOB index");
843            assert!(
844                title.contains("out of range"),
845                "release build must surface a distinct OOB title; got {title:?}",
846            );
847            // The empty-state title is the regression class this test
848            // guards against — must NEVER be returned for OOB.
849            assert!(
850                !title.contains("no usage examples"),
851                "OOB index must not render the empty-state title; got {title:?}",
852            );
853        }
854    }
855
856    #[test]
857    fn convention_card_tiny_area_does_not_panic() {
858        let examples = vec![CodeExample {
859            file: "src/lib.rs".to_owned(),
860            line: 1,
861            end_line: 1,
862            snippet: "pub fn add(a: i32, b: i32) -> i32 { a + b }".to_owned(),
863            snippet_start_line: 1,
864        }];
865        let item = make_conv_item("Prefer explicit types", examples);
866        let card = ConventionCard {
867            convention: &item,
868            current: 0,
869            total: 1,
870            review_complete: false,
871            has_examples: true,
872            search_mode: false,
873            search_query: "",
874            filter_locked: false,
875            no_match: false,
876        };
877        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 3));
878        card.render(Rect::new(0, 0, 10, 3), &mut buf);
879    }
880}