1use 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
28const ACCENT: Color = Color::Rgb(13, 148, 136); const MUTED: Color = Color::DarkGray;
31const WARN: Color = Color::Yellow;
32const OK: Color = Color::Green;
33const BAD: Color = Color::Red;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Focus {
38 List,
39 Filter,
40 Help,
41}
42
43#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
635pub 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
683pub 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
701pub 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}