Skip to main content

canonic/
tui.rs

1//! Interactive terminal UI for browsing and gating the canned-response corpus.
2//!
3//! Launch with `canonic tui`. Pure state helpers are unit-tested with
4//! [`ratatui::backend::TestBackend`]; the live loop uses crossterm.
5
6use crate::check::{check_responses, format_check_report, CheckReport};
7use crate::convert::{convert_path_to_jira, tool_available as pandoc_available};
8use crate::corpus::{load_response, walk_responses, CannedResponse};
9use crate::doctor::{collect_statuses, format_doctor};
10use crate::index::{default_index_dir, reindex, search};
11use crate::lint::{format_report, lint_paths, LintEngine};
12use anyhow::{Context, Result};
13use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
14use crossterm::execute;
15use crossterm::terminal::{
16    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
17};
18use ratatui::backend::{Backend, CrosstermBackend};
19use ratatui::layout::{Constraint, Direction, Layout, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
23use ratatui::{Frame, Terminal};
24use std::io::{self, Stdout};
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28/// Teal-ish accent matching the Shibuya / canonic brand.
29const ACCENT: Color = Color::Rgb(13, 148, 136); // #0D9488
30const MUTED: Color = Color::DarkGray;
31const WARN: Color = Color::Yellow;
32const OK: Color = Color::Green;
33const BAD: Color = Color::Red;
34
35/// Which pane / modal is focused.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Focus {
38    List,
39    Filter,
40    Help,
41}
42
43/// One row in the browser list (filtered view over the full corpus).
44#[derive(Debug, Clone)]
45pub struct ListEntry {
46    pub id: String,
47    pub title: String,
48    pub path: PathBuf,
49    pub check_ok: bool,
50    pub finding_count: usize,
51}
52
53/// Application state for the corpus browser (testable without a real terminal).
54#[derive(Debug)]
55pub struct App {
56    pub corpus: PathBuf,
57    pub responses: Vec<CannedResponse>,
58    pub entries: Vec<ListEntry>,
59    pub list_state: ListState,
60    pub filter: String,
61    pub focus: Focus,
62    pub status: String,
63    pub preview: String,
64    pub side_panel: String,
65    pub should_quit: bool,
66    pub scroll: u16,
67}
68
69impl App {
70    /// Load corpus from disk and build initial list + selection.
71    pub fn load(corpus: PathBuf) -> Result<Self> {
72        let responses = if corpus.exists() {
73            walk_responses(&corpus)?
74        } else {
75            Vec::new()
76        };
77        let mut app = Self {
78            corpus,
79            responses,
80            entries: Vec::new(),
81            list_state: ListState::default(),
82            filter: String::new(),
83            focus: Focus::List,
84            status: String::new(),
85            preview: String::new(),
86            side_panel: String::new(),
87            should_quit: false,
88            scroll: 0,
89        };
90        app.rebuild_entries();
91        app.refresh_side_panel();
92        app.status = format!(
93            "{} response(s) in {} · ↑↓ move · / filter · c convert · r reindex · ? help · q quit",
94            app.entries.len(),
95            app.corpus.display()
96        );
97        Ok(app)
98    }
99
100    /// Apply filter string against id/title/content and refresh selection.
101    pub fn rebuild_entries(&mut self) {
102        let q = self.filter.to_lowercase();
103        let prev_id = self
104            .list_state
105            .selected()
106            .and_then(|i| self.entries.get(i).map(|e| e.id.clone()));
107
108        self.entries = self
109            .responses
110            .iter()
111            .filter(|r| {
112                if q.is_empty() {
113                    return true;
114                }
115                r.id.to_lowercase().contains(&q)
116                    || r.title.to_lowercase().contains(&q)
117                    || r.content.to_lowercase().contains(&q)
118                    || r.tags.iter().any(|t| t.to_lowercase().contains(&q))
119            })
120            .map(|r| {
121                let report = check_responses(std::slice::from_ref(r));
122                ListEntry {
123                    id: r.id.clone(),
124                    title: r.title.clone(),
125                    path: r.path.clone(),
126                    check_ok: report.ok(),
127                    finding_count: report.findings.len(),
128                }
129            })
130            .collect();
131
132        // Restore selection if possible.
133        let idx = prev_id
134            .and_then(|id| self.entries.iter().position(|e| e.id == id))
135            .or_else(|| if self.entries.is_empty() { None } else { Some(0) });
136        self.list_state.select(idx);
137        self.update_preview();
138    }
139
140    pub fn selected_entry(&self) -> Option<&ListEntry> {
141        self.list_state.selected().and_then(|i| self.entries.get(i))
142    }
143
144    pub fn selected_response(&self) -> Option<&CannedResponse> {
145        let id = self.selected_entry()?.id.as_str();
146        self.responses.iter().find(|r| r.id == id)
147    }
148
149    pub fn select_next(&mut self) {
150        if self.entries.is_empty() {
151            return;
152        }
153        let i = match self.list_state.selected() {
154            Some(i) => (i + 1).min(self.entries.len() - 1),
155            None => 0,
156        };
157        self.list_state.select(Some(i));
158        self.scroll = 0;
159        self.update_preview();
160    }
161
162    pub fn select_prev(&mut self) {
163        if self.entries.is_empty() {
164            return;
165        }
166        let i = match self.list_state.selected() {
167            Some(0) | None => 0,
168            Some(i) => i - 1,
169        };
170        self.list_state.select(Some(i));
171        self.scroll = 0;
172        self.update_preview();
173    }
174
175    pub fn update_preview(&mut self) {
176        match self.selected_response() {
177            Some(r) => {
178                let report = check_responses(std::slice::from_ref(r));
179                let mut preview = r.body.clone();
180                if preview.len() > 12_000 {
181                    preview.truncate(12_000);
182                    preview.push_str("\n… (truncated)");
183                }
184                let gate = if report.ok() {
185                    "check: OK".to_string()
186                } else {
187                    format!(
188                        "check: {} finding(s)\n{}",
189                        report.findings.len(),
190                        format_check_report(&report)
191                    )
192                };
193                self.preview = format!(
194                    "id: {}\ntitle: {}\npath: {}\nsop: {}\ntags: {:?}\n\n── quality ──\n{}\n\n── markdown ──\n{}",
195                    r.id,
196                    r.title,
197                    r.path.display(),
198                    r.sop.as_deref().unwrap_or("-"),
199                    r.tags,
200                    gate,
201                    preview
202                );
203            }
204            None => {
205                self.preview = if self.responses.is_empty() {
206                    format!(
207                        "No responses in {}.\n\nScaffold one:\n  canonic new \"Topic title\"\n\nOr import drafts:\n  canonic import-jira \"project = HSP AND labels = canned-response\"\n  canonic promote corpus/imports/…md",
208                        self.corpus.display()
209                    )
210                } else {
211                    "No rows match the current filter.".into()
212                };
213            }
214        }
215    }
216
217    pub fn refresh_side_panel(&mut self) {
218        let doctor = format_doctor(&collect_statuses());
219        let clean = self.entries.iter().filter(|e| e.check_ok).count();
220        let dirty = self.entries.len().saturating_sub(clean);
221        self.side_panel = format!(
222            "corpus: {}\nentries: {} (filter {:?})\nclean: {} · findings: {}\n\n── doctor ──\n{}",
223            self.corpus.display(),
224            self.entries.len(),
225            self.filter,
226            clean,
227            dirty,
228            doctor.trim()
229        );
230    }
231
232    /// Run quality check over the full (unfiltered) corpus and show the report.
233    pub fn run_check_all(&mut self) -> Result<()> {
234        let report: CheckReport = check_responses(&self.responses);
235        self.status = report.summary_line();
236        self.preview = format_check_report(&report);
237        self.rebuild_entries();
238        Ok(())
239    }
240
241    /// Lint the selected path (or whole corpus) with harper-core.
242    pub fn run_lint_selected(&mut self) -> Result<()> {
243        let paths: Vec<PathBuf> = if let Some(e) = self.selected_entry() {
244            vec![e.path.clone()]
245        } else {
246            self.responses.iter().map(|r| r.path.clone()).collect()
247        };
248        if paths.is_empty() {
249            self.status = "lint: nothing to lint".into();
250            return Ok(());
251        }
252        let report = lint_paths(&paths, LintEngine::Harper)?;
253        self.status = report.summary_line();
254        self.preview = format_report(&report);
255        Ok(())
256    }
257
258    /// Convert the selected response with pandoc jira writer into the preview pane.
259    pub fn run_convert_selected(&mut self) -> Result<()> {
260        let Some(entry) = self.selected_entry().cloned() else {
261            self.status = "convert: no selection".into();
262            return Ok(());
263        };
264        if !pandoc_available() {
265            self.status = "convert: pandoc missing (install pandoc)".into();
266            self.preview = "pandoc is not on PATH. Install pandoc to convert markdown → jira wiki.".into();
267            return Ok(());
268        }
269        let jira = convert_path_to_jira(&entry.path)
270            .with_context(|| format!("convert {}", entry.path.display()))?;
271        self.status = format!(
272            "convert: {} → {} bytes jira wiki (not posted)",
273            entry.id,
274            jira.len()
275        );
276        self.preview = format!(
277            "── jira wiki (pandoc) · {} ──\n\n{}",
278            entry.id, jira
279        );
280        Ok(())
281    }
282
283    /// Rebuild the Tantivy index from the published corpus.
284    pub fn run_reindex(&mut self) -> Result<()> {
285        let index = default_index_dir();
286        let n = reindex(&self.corpus, &index)?;
287        self.status = format!("reindexed {n} document(s) → {}", index.display());
288        Ok(())
289    }
290
291    /// Search the local index with the current filter (or a fixed query).
292    pub fn run_search(&mut self, query: &str) -> Result<()> {
293        let index = default_index_dir();
294        if !index.exists() {
295            let n = reindex(&self.corpus, &index)?;
296            self.status = format!("built index ({n} docs); searching…");
297        }
298        let hits = search(&index, query, 15)?;
299        if hits.is_empty() {
300            self.preview = format!("(no hits for {query:?})");
301            self.status = format!("search: 0 hits for {query:?}");
302        } else {
303            let mut out = format!("── search: {query:?} ({} hit(s)) ──\n\n", hits.len());
304            for (i, h) in hits.iter().enumerate() {
305                out.push_str(&format!(
306                    "{}. {}  score={:.3}\n   {}\n   {}\n\n",
307                    i + 1,
308                    h.id,
309                    h.score,
310                    h.title,
311                    h.snippet
312                ));
313            }
314            self.preview = out;
315            self.status = format!("search: {} hit(s) for {query:?}", hits.len());
316        }
317        Ok(())
318    }
319
320    /// Reload markdown files from disk (after external edits).
321    pub fn reload_corpus(&mut self) -> Result<()> {
322        self.responses = if self.corpus.exists() {
323            walk_responses(&self.corpus)?
324        } else {
325            Vec::new()
326        };
327        self.rebuild_entries();
328        self.refresh_side_panel();
329        self.status = format!("reloaded {} response(s)", self.responses.len());
330        Ok(())
331    }
332
333    /// Handle a key press. Returns Ok after mutating state.
334    pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {
335        if self.focus == Focus::Help {
336            if matches!(
337                code,
338                KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter
339            ) {
340                self.focus = Focus::List;
341            }
342            return Ok(());
343        }
344
345        if self.focus == Focus::Filter {
346            match code {
347                KeyCode::Esc => {
348                    self.filter.clear();
349                    self.focus = Focus::List;
350                    self.rebuild_entries();
351                    self.status = "filter cleared".into();
352                }
353                KeyCode::Enter => {
354                    self.focus = Focus::List;
355                    self.rebuild_entries();
356                    self.status = format!("filter: {:?}", self.filter);
357                }
358                KeyCode::Backspace => {
359                    self.filter.pop();
360                    self.rebuild_entries();
361                }
362                KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
363                    self.filter.push(c);
364                    self.rebuild_entries();
365                }
366                _ => {}
367            }
368            return Ok(());
369        }
370
371        // List focus
372        match code {
373            KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
374            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
375                self.should_quit = true;
376            }
377            KeyCode::Down | KeyCode::Char('j') => self.select_next(),
378            KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
379            KeyCode::Char('/') => {
380                self.focus = Focus::Filter;
381                self.status = "filter: type to match id/title/body · Enter apply · Esc clear".into();
382            }
383            KeyCode::Char('?') => {
384                self.focus = Focus::Help;
385            }
386            KeyCode::Char('g') => {
387                if !self.entries.is_empty() {
388                    self.list_state.select(Some(0));
389                    self.scroll = 0;
390                    self.update_preview();
391                }
392            }
393            KeyCode::Char('G') => {
394                if !self.entries.is_empty() {
395                    self.list_state.select(Some(self.entries.len() - 1));
396                    self.scroll = 0;
397                    self.update_preview();
398                }
399            }
400            KeyCode::PageDown | KeyCode::Char('J') => {
401                self.scroll = self.scroll.saturating_add(8);
402            }
403            KeyCode::PageUp | KeyCode::Char('K') => {
404                self.scroll = self.scroll.saturating_sub(8);
405            }
406            KeyCode::Char('R') => {
407                self.reload_corpus()?;
408            }
409            KeyCode::Char('r') => {
410                if let Err(e) = self.run_reindex() {
411                    self.status = format!("reindex failed: {e:#}");
412                }
413            }
414            KeyCode::Char('c') => {
415                if let Err(e) = self.run_convert_selected() {
416                    self.status = format!("convert failed: {e:#}");
417                }
418            }
419            KeyCode::Char('C') => {
420                if let Err(e) = self.run_check_all() {
421                    self.status = format!("check failed: {e:#}");
422                }
423            }
424            KeyCode::Char('l') => {
425                if let Err(e) = self.run_lint_selected() {
426                    self.status = format!("lint failed: {e:#}");
427                }
428            }
429            KeyCode::Char('s') => {
430                let q = if self.filter.is_empty() {
431                    self.selected_entry()
432                        .map(|e| e.title.clone())
433                        .unwrap_or_else(|| "snell".into())
434                } else {
435                    self.filter.clone()
436                };
437                if let Err(e) = self.run_search(&q) {
438                    self.status = format!("search failed: {e:#}");
439                }
440            }
441            KeyCode::Char('d') => {
442                self.refresh_side_panel();
443                self.preview = self.side_panel.clone();
444                self.status = "doctor snapshot in preview".into();
445            }
446            KeyCode::Enter => {
447                self.update_preview();
448                self.status = self
449                    .selected_entry()
450                    .map(|e| format!("selected {}", e.id))
451                    .unwrap_or_else(|| "no selection".into());
452            }
453            _ => {}
454        }
455        Ok(())
456    }
457}
458
459fn help_text() -> String {
460    r#"canonic TUI — corpus browser
461
462Navigation
463  j / ↓          next response
464  k / ↑          previous response
465  g / G          first / last
466  PgDn/PgUp      scroll preview
467  /              filter by id, title, tags, body
468  Enter          refresh preview
469  ?              this help
470  q / Esc        quit
471
472Actions (operate on selection or corpus)
473  C              run quality check on whole corpus
474  c              convert selection → jira wiki (pandoc; preview only)
475  l              lint selection with harper-core
476  r              rebuild Tantivy index
477  s              search index (uses filter text or selected title)
478  R              reload corpus from disk
479  d              show doctor / tooling snapshot
480
481Constraints
482  • Markdown under corpus/responses/ is source of truth
483  • TUI never POSTs to Jira (use canonic jira-comment explicitly)
484  • Promote imports with: canonic promote PATH.md
485"#
486    .to_string()
487}
488
489fn draw(frame: &mut Frame, app: &mut App) {
490    let root = Layout::default()
491        .direction(Direction::Vertical)
492        .constraints([
493            Constraint::Length(3),
494            Constraint::Min(8),
495            Constraint::Length(3),
496        ])
497        .split(frame.area());
498
499    // Header
500    let title = Line::from(vec![
501        Span::styled(
502            " canonic ",
503            Style::default()
504                .fg(Color::Black)
505                .bg(ACCENT)
506                .add_modifier(Modifier::BOLD),
507        ),
508        Span::raw(" "),
509        Span::styled("corpus browser", Style::default().fg(ACCENT)),
510        Span::raw("  "),
511        Span::styled(
512            format!(
513                "{} shown · {} total",
514                app.entries.len(),
515                app.responses.len()
516            ),
517            Style::default().fg(MUTED),
518        ),
519    ]);
520    let header = Paragraph::new(title).block(
521        Block::default()
522            .borders(Borders::ALL)
523            .border_style(Style::default().fg(ACCENT))
524            .title(" canned-response corpus "),
525    );
526    frame.render_widget(header, root[0]);
527
528    // Body: list | preview
529    let body = Layout::default()
530        .direction(Direction::Horizontal)
531        .constraints([Constraint::Percentage(34), Constraint::Percentage(66)])
532        .split(root[1]);
533
534    let items: Vec<ListItem> = app
535        .entries
536        .iter()
537        .map(|e| {
538            let mark = if e.check_ok { "✓" } else { "!" };
539            let style = if e.check_ok {
540                Style::default().fg(OK)
541            } else {
542                Style::default().fg(BAD)
543            };
544            ListItem::new(Line::from(vec![
545                Span::styled(format!(" {mark} "), style),
546                Span::styled(e.id.clone(), Style::default().add_modifier(Modifier::BOLD)),
547                Span::styled(format!("  {}", e.title), Style::default().fg(MUTED)),
548            ]))
549        })
550        .collect();
551
552    let list_title = if app.focus == Focus::Filter {
553        format!(" filter: {}_ ", app.filter)
554    } else if app.filter.is_empty() {
555        " responses ".into()
556    } else {
557        format!(" responses · /{} ", app.filter)
558    };
559    let list = List::new(items)
560        .block(
561            Block::default()
562                .borders(Borders::ALL)
563                .title(list_title)
564                .border_style(if app.focus == Focus::Filter {
565                    Style::default().fg(WARN)
566                } else {
567                    Style::default().fg(ACCENT)
568                }),
569        )
570        .highlight_style(
571            Style::default()
572                .bg(Color::Rgb(19, 78, 74))
573                .fg(Color::White)
574                .add_modifier(Modifier::BOLD),
575        )
576        .highlight_symbol("› ");
577    frame.render_stateful_widget(list, body[0], &mut app.list_state);
578
579    let preview = Paragraph::new(app.preview.as_str())
580        .wrap(Wrap { trim: false })
581        .scroll((app.scroll, 0))
582        .block(
583            Block::default()
584                .borders(Borders::ALL)
585                .title(" preview ")
586                .border_style(Style::default().fg(ACCENT)),
587        );
588    frame.render_widget(preview, body[1]);
589
590    // Status
591    let status = Paragraph::new(app.status.as_str())
592        .wrap(Wrap { trim: true })
593        .block(
594            Block::default()
595                .borders(Borders::ALL)
596                .title(" status · C check · c convert · l lint · r reindex · s search · / filter · ? help · q quit ")
597                .border_style(Style::default().fg(MUTED)),
598        );
599    frame.render_widget(status, root[2]);
600
601    if app.focus == Focus::Help {
602        let area = centered_rect(72, 80, frame.area());
603        frame.render_widget(Clear, area);
604        let help = Paragraph::new(help_text())
605            .wrap(Wrap { trim: false })
606            .block(
607                Block::default()
608                    .borders(Borders::ALL)
609                    .title(" help · Esc/q/? to close ")
610                    .border_style(Style::default().fg(ACCENT)),
611            );
612        frame.render_widget(help, area);
613    }
614}
615
616fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
617    let popup_layout = Layout::default()
618        .direction(Direction::Vertical)
619        .constraints([
620            Constraint::Percentage((100 - percent_y) / 2),
621            Constraint::Percentage(percent_y),
622            Constraint::Percentage((100 - percent_y) / 2),
623        ])
624        .split(r);
625    Layout::default()
626        .direction(Direction::Horizontal)
627        .constraints([
628            Constraint::Percentage((100 - percent_x) / 2),
629            Constraint::Percentage(percent_x),
630            Constraint::Percentage((100 - percent_x) / 2),
631        ])
632        .split(popup_layout[1])[1]
633}
634
635/// Run the interactive TUI until the user quits.
636pub fn run_tui(corpus: PathBuf) -> Result<()> {
637    let mut app = App::load(corpus)?;
638    let mut terminal = setup_terminal()?;
639    let result = run_loop(&mut terminal, &mut app);
640    restore_terminal(&mut terminal)?;
641    result
642}
643
644fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
645    enable_raw_mode().context("enable raw mode")?;
646    let mut stdout = io::stdout();
647    execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
648    let backend = CrosstermBackend::new(stdout);
649    Terminal::new(backend).context("create terminal")
650}
651
652fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
653    disable_raw_mode().context("disable raw mode")?;
654    execute!(terminal.backend_mut(), LeaveAlternateScreen)
655        .context("leave alternate screen")?;
656    terminal.show_cursor().context("show cursor")?;
657    Ok(())
658}
659
660fn run_loop<B>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
661where
662    B: Backend,
663    B::Error: Send + Sync + 'static,
664{
665    loop {
666        terminal
667            .draw(|f| draw(f, app))
668            .map_err(|e| anyhow::anyhow!("draw: {e}"))?;
669        if app.should_quit {
670            break;
671        }
672        if event::poll(Duration::from_millis(200)).context("poll events")? {
673            if let Event::Key(key) = event::read().context("read event")? {
674                if key.kind == KeyEventKind::Press {
675                    app.handle_key(key.code, key.modifiers)?;
676                }
677            }
678        }
679    }
680    Ok(())
681}
682
683/// Render one frame to a test backend (for structural UI tests).
684pub fn render_test_frame(app: &mut App, width: u16, height: u16) -> Result<String> {
685    use ratatui::backend::TestBackend;
686    let backend = TestBackend::new(width, height);
687    let mut terminal = Terminal::new(backend).context("test terminal")?;
688    terminal.draw(|f| draw(f, app))?;
689    let buffer = terminal.backend().buffer().clone();
690    let mut out = String::new();
691    for y in 0..height {
692        for x in 0..width {
693            let cell = &buffer[(x, y)];
694            out.push_str(cell.symbol());
695        }
696        out.push('\n');
697    }
698    Ok(out)
699}
700
701/// Open path as a single-file corpus view (load parent dir, select that id).
702pub fn load_focusing(corpus: PathBuf, focus_path: Option<&Path>) -> Result<App> {
703    let mut app = App::load(corpus)?;
704    if let Some(path) = focus_path {
705        if path.is_file() {
706            let doc = load_response(path)?;
707            if let Some(i) = app.entries.iter().position(|e| e.id == doc.id) {
708                app.list_state.select(Some(i));
709                app.update_preview();
710            }
711        }
712    }
713    Ok(app)
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use std::fs;
720    use tempfile::tempdir;
721
722    fn write_ok(dir: &Path, id: &str, title: &str, body: &str) {
723        fs::write(
724            dir.join(format!("{id}.md")),
725            format!(
726                "---\nid: {id}\ntitle: {title}\nprefix: snell\nsop: none\n---\n\n# {title}\n\n{body}\n\nRegards,\nSupport Team\n"
727            ),
728        )
729        .unwrap();
730    }
731
732    #[test]
733    fn load_lists_check_clean_entries() {
734        let dir = tempdir().unwrap();
735        write_ok(
736            dir.path(),
737            "snell-alpha",
738            "Alpha",
739            "Alpha body about project space.",
740        );
741        write_ok(dir.path(), "snell-beta", "Beta", "Beta body about modules.");
742        let app = App::load(dir.path().to_path_buf()).unwrap();
743        assert_eq!(app.entries.len(), 2);
744        assert!(app.entries.iter().all(|e| e.check_ok));
745        assert!(app.list_state.selected().is_some());
746        assert!(app.preview.contains("snell-") || app.preview.contains("Alpha") || app.preview.contains("Beta"));
747    }
748
749    #[test]
750    fn filter_narrows_list() {
751        let dir = tempdir().unwrap();
752        write_ok(dir.path(), "snell-storage", "Storage topic", "disk quota");
753        write_ok(dir.path(), "snell-queue", "Queue topic", "partition limits");
754        let mut app = App::load(dir.path().to_path_buf()).unwrap();
755        app.filter = "queue".into();
756        app.rebuild_entries();
757        assert_eq!(app.entries.len(), 1);
758        assert_eq!(app.entries[0].id, "snell-queue");
759    }
760
761    #[test]
762    fn handle_key_quit_and_nav() {
763        let dir = tempdir().unwrap();
764        write_ok(dir.path(), "snell-a", "A", "one");
765        write_ok(dir.path(), "snell-b", "B", "two");
766        let mut app = App::load(dir.path().to_path_buf()).unwrap();
767        app.handle_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
768        assert_eq!(app.list_state.selected(), Some(1));
769        app.handle_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
770        assert_eq!(app.list_state.selected(), Some(0));
771        app.handle_key(KeyCode::Char('q'), KeyModifiers::NONE)
772            .unwrap();
773        assert!(app.should_quit);
774    }
775
776    #[test]
777    fn test_backend_renders_branding() {
778        let dir = tempdir().unwrap();
779        write_ok(
780            dir.path(),
781            "snell-project-space",
782            "Project space",
783            "Not a backup.",
784        );
785        let mut app = App::load(dir.path().to_path_buf()).unwrap();
786        let frame = render_test_frame(&mut app, 100, 30).unwrap();
787        assert!(
788            frame.contains("canonic") || frame.contains("corpus"),
789            "frame missing brand: {frame}"
790        );
791        assert!(
792            frame.contains("snell-project-space") || frame.contains("Project"),
793            "frame missing entry: {frame}"
794        );
795    }
796
797    #[test]
798    fn empty_corpus_has_guidance() {
799        let dir = tempdir().unwrap();
800        let app = App::load(dir.path().to_path_buf()).unwrap();
801        assert!(app.entries.is_empty());
802        assert!(
803            app.preview.contains("canonic new") || app.preview.contains("No responses"),
804            "{}",
805            app.preview
806        );
807    }
808}