use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use crate::checks;
use crate::scan::{AgentsMdState, FleetScan, Repo};
use crate::shim::{BakeState, ShimState, DISPATCHERS};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Browse,
Detail,
HookView,
Filter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Char(char),
Enter,
Esc,
Backspace,
Up,
Down,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct LineEdit {
value: String,
}
impl LineEdit {
pub fn insert(&mut self, c: char) {
self.value.push(c);
}
pub fn backspace(&mut self) {
self.value.pop();
}
pub fn clear(&mut self) {
self.value.clear();
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
pub fn as_str(&self) -> &str {
&self.value
}
#[cfg(test)]
pub fn set(&mut self, v: &str) {
self.value = v.to_string();
}
}
pub struct App {
pub scan: FleetScan,
pub selected: usize,
pub mode: Mode,
prev_mode: Mode,
pub filter: LineEdit,
pub check_selected: usize,
pub notice: Option<String>,
pub undo: Option<crate::skips::SkipPlan>,
pub scanning: bool,
pub color: bool,
pub visited: usize,
pub elapsed: f64,
pub quit: bool,
}
impl App {
pub fn new(scan: FleetScan) -> Self {
App {
scan,
selected: 0,
mode: Mode::Browse,
prev_mode: Mode::Browse,
filter: LineEdit::default(),
check_selected: 0,
notice: None,
undo: None,
scanning: false,
color: amont_runtime::ui::colors_enabled(),
visited: 0,
elapsed: 0.0,
quit: false,
}
}
pub fn rows(&self) -> Vec<&Repo> {
self.scan
.repos
.iter()
.filter(|repo| {
self.filter.is_empty()
|| repo
.path
.to_string_lossy()
.to_lowercase()
.contains(&self.filter.as_str().to_lowercase())
})
.collect()
}
pub fn on_key(&mut self, key: Key) {
match self.mode {
Mode::Filter => self.filter_key(key),
Mode::Detail => self.detail_key(key),
_ => self.browse_key(key),
}
}
fn filter_key(&mut self, key: Key) {
match key {
Key::Esc => {
self.filter.clear();
self.mode = self.prev_mode;
}
Key::Enter => self.mode = self.prev_mode,
Key::Backspace => self.filter.backspace(),
Key::Char(c) => self.filter.insert(c),
Key::Up | Key::Down => {}
}
self.selected = 0;
}
fn detail_key(&mut self, key: Key) {
let n = crate::checks::all_checks().len();
match key {
Key::Char('j') | Key::Down => {
self.check_selected = (self.check_selected + 1).min(n - 1)
}
Key::Char('k') | Key::Up => self.check_selected = self.check_selected.saturating_sub(1),
Key::Char('s') => self.begin_toggle(),
Key::Char('u') => self.take_back(),
_ => self.browse_key(key),
}
}
fn selected_repo_path(&self) -> Option<std::path::PathBuf> {
let rows = self.rows();
let r = rows.get(self.selected)?;
Some(self.scan.root.join(&r.path))
}
fn begin_toggle(&mut self) {
let Some(repo) = self.selected_repo_path() else {
return;
};
let Some(check) = crate::checks::all_checks()
.get(self.check_selected)
.copied()
else {
return;
};
let plan = crate::skips::plan(&repo, check);
if let Some(r) = &plan.refuse {
self.notice = Some(format!("refused: {r}"));
return;
}
self.commit_toggle(plan);
}
fn commit_toggle(&mut self, plan: crate::skips::SkipPlan) {
let Some(repo) = self.selected_repo_path() else {
return;
};
match crate::skips::apply(&repo, &plan) {
Ok(()) => {
let verb = match plan.action {
crate::skips::Action::Add => "skipped",
crate::skips::Action::Remove => "un-skipped",
};
self.notice = Some(format!("{verb} {} · u to undo", plan.check));
self.undo = Some(crate::skips::plan(&repo, plan.check));
self.refresh_selected_repo();
}
Err(e) => self.notice = Some(format!("failed: {e}")),
}
}
fn take_back(&mut self) {
let (Some(repo), Some(plan)) = (self.selected_repo_path(), self.undo.take()) else {
return;
};
match crate::skips::apply(&repo, &plan) {
Ok(()) => {
self.notice = Some(format!("undone: {}", plan.check));
self.refresh_selected_repo();
}
Err(e) => self.notice = Some(format!("undo failed: {e}")),
}
}
fn refresh_selected_repo(&mut self) {
let Some(path) = self.selected_repo_path() else {
return;
};
let fresh = crate::skips::read(&path);
let rows = self.rows();
let Some(target) = rows.get(self.selected).map(|r| r.path.clone()) else {
return;
};
if let Some(repo) = self.scan.repos.iter_mut().find(|repo| repo.path == target) {
repo.skips = fresh;
}
}
fn browse_key(&mut self, key: Key) {
let len = self.rows().len();
match key {
Key::Char('q') => self.quit = true,
Key::Char('/') => {
self.prev_mode = self.mode;
self.mode = Mode::Filter;
}
Key::Char('h') => {
self.mode = if self.mode == Mode::HookView {
Mode::Browse
} else {
Mode::HookView
}
}
Key::Char('j') | Key::Down if len > 0 => {
self.selected = (self.selected + 1).min(len - 1)
}
Key::Char('k') | Key::Up => self.selected = self.selected.saturating_sub(1),
Key::Enter if len > 0 => self.mode = Mode::Detail,
Key::Esc => {
if self.mode != Mode::Browse {
self.mode = Mode::Browse;
} else {
self.filter.clear();
}
}
_ => {}
}
}
}
fn tint(on: bool, c: Color) -> Style {
if on {
Style::default().fg(c)
} else {
Style::default()
}
}
const ACCENT: Color = Color::Cyan;
const DECLARED_FILE: &str = amont_runtime::manifest::MANIFEST;
fn scope_of(exts: &[String]) -> String {
if exts.is_empty() {
"*".to_string()
} else {
exts.iter()
.map(|ext| format!("*{ext}"))
.collect::<Vec<_>>()
.join(",")
}
}
fn selection_style() -> Style {
Style::default().add_modifier(Modifier::REVERSED)
}
fn selection_cursor(color: bool) -> Span<'static> {
Span::styled("> ", tint(color, ACCENT).add_modifier(Modifier::BOLD))
}
fn repo_cell(r: &Repo) -> String {
amont_runtime::ui::sanitize_path(&r.path)
}
fn shim_glyphs(r: &Repo) -> String {
r.shims
.iter()
.map(|s| match s {
ShimState::Ok { .. } => '●',
ShimState::Drifted => '◐',
ShimState::Missing => '○',
ShimState::Symlink { .. } => '!',
ShimState::Unreadable { .. } => '?',
})
.collect()
}
fn bake_word(b: &BakeState) -> &'static str {
match b {
BakeState::Current => "current",
BakeState::Stale { .. } => "stale",
BakeState::Unbaked => "unbaked",
BakeState::Mixed => "mixed",
BakeState::None => "-",
}
}
fn agents_md_word(s: AgentsMdState) -> &'static str {
match s {
AgentsMdState::UpToDate => "ok",
AgentsMdState::Missing => "-",
AgentsMdState::Drifted => "drift",
AgentsMdState::Malformed => "bad",
}
}
fn state_word(r: &Repo) -> String {
if let Some(owner) = &r.shares_hooks_with {
return format!("covered by {}", amont_runtime::ui::sanitize_path(owner));
}
if r.hooks_dir.inside().is_none() {
return "! hooks elsewhere".into();
}
if !r.managed {
return "! unmanaged".into();
}
let hazard = r
.shims
.iter()
.filter(|s| matches!(s, ShimState::Symlink { .. } | ShimState::Unreadable { .. }))
.count();
if hazard > 0 {
return format!("! not a file {hazard}");
}
let missing = r
.shims
.iter()
.filter(|s| matches!(s, ShimState::Missing))
.count();
let drifted = r
.shims
.iter()
.filter(|s| matches!(s, ShimState::Drifted))
.count();
if drifted > 0 {
format!("x drifted {drifted}")
} else if missing > 0 {
format!("x missing {missing}")
} else if !r.stale_ours.is_empty() || !r.foreign_subs.is_empty() || r.hook_pkgjson {
"! leftovers".into()
} else if matches!(r.baked, BakeState::Stale { .. } | BakeState::Mixed) {
"! stale bake".into()
} else {
"ok".into()
}
}
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(4),
Constraint::Min(3),
Constraint::Length(1),
])
.split(area);
header(f, chunks[0], app);
if app.mode == Mode::HookView {
hooks_view(f, chunks[1], app);
} else if app.mode == Mode::Detail {
detail(f, chunks[1], app);
} else if app.scan.looks_like_a_failed_scan() && !app.scanning {
failure(f, chunks[1], app);
} else {
table(f, chunks[1], app);
}
footer(f, chunks[2], app);
}
fn header(f: &mut Frame, area: Rect, app: &App) {
let s = &app.scan;
let status = if app.scanning {
format!(
"scanning · {} directories · {:.1}s",
app.visited, app.elapsed
)
} else {
format!("{} directories · {:.1}s", s.dirs_visited, app.elapsed)
};
let text = vec![
Line::from(format!("{} {status}", s.root.display())).style(tint(app.color, ACCENT)),
Line::from(format!(
"{} repositories · {} managed · {} unmanaged · {} skipped subtrees",
s.git_dirs_found, s.managed_seen, s.unmanaged_seen, s.excluded_dirs
)),
Line::from(format!(
"consistency {}",
DISPATCHERS
.iter()
.enumerate()
.map(|(i, n)| {
let ok = s
.repos
.iter()
.filter(|r| r.managed)
.filter(|r| matches!(r.shims.get(i), Some(ShimState::Ok { .. })))
.count();
format!("{n} {ok}/{}", s.managed_seen)
})
.collect::<Vec<_>>()
.join(" ")
)),
];
f.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::BOTTOM)),
area,
);
}
fn failure(f: &mut Frame, area: Rect, app: &App) {
let s = &app.scan;
let mut lines = vec![
Line::from(format!("No repositories found under {}", s.root.display())),
Line::from(""),
Line::from(format!(
"Visited {} directories in {:.1}s and found 0 git repositories.",
s.dirs_visited, app.elapsed
)),
Line::from("This is a SCAN FAILURE, not a clean fleet."),
Line::from(format!(
" - is --root correct? (currently: {})",
s.root.display()
)),
Line::from(format!(
" - is --depth deep enough? (currently: {})",
s.depth
)),
];
if !s.unreadable.is_empty() {
lines.push(Line::from(format!(
" - {} path(s) could not be read",
s.unreadable.len()
)));
}
f.render_widget(Paragraph::new(lines), area);
}
fn table(f: &mut Frame, area: Rect, app: &App) {
let rows = app.rows();
let wide = area.width >= 100;
let mid = area.width >= 76;
let header = if wide {
vec![
"REPO", "SHIMS", "BAKE", "LANG", "SKIPS", "WARN", "DECL", "AGENTS", "STATE",
]
} else if mid {
vec!["REPO", "SHIMS", "BAKE", "STATE"]
} else {
vec!["REPO", "STATE"]
};
let body: Vec<Row> = rows
.iter()
.map(|repo| {
let mut cells = vec![Cell::from(repo_cell(repo))];
if mid {
let g = shim_glyphs(repo);
let all_ok = g.chars().all(|c| c == '●');
cells.push(Cell::from(g).style(tint(
app.color,
if all_ok { Color::Green } else { Color::Red },
)));
cells.push(Cell::from(bake_word(&repo.baked)));
}
if wide {
cells.push(Cell::from(repo.languages.join(" ")));
cells.push(Cell::from(if repo.skips.is_empty() {
"-".to_string()
} else {
repo.skips.len().to_string()
}));
let weakened = repo.severities.iter().filter(|e| e.weakens()).count();
cells.push(
Cell::from(if weakened == 0 {
"-".to_string()
} else {
weakened.to_string()
})
.style(tint(
app.color,
if weakened == 0 {
Color::Reset
} else {
Color::Yellow
},
)),
);
let broken = repo.declared.iter().filter(|d| d.is_unusable()).count();
let text = match (repo.declared.len(), broken) {
(0, _) => "-".to_string(),
(n, 0) => n.to_string(),
(n, b) => format!("{n}!{b}"),
};
cells.push(Cell::from(text).style(tint(
app.color,
if broken > 0 { Color::Red } else { Color::Reset },
)));
cells.push(Cell::from(agents_md_word(repo.agents_md)).style(tint(
app.color,
match repo.agents_md {
AgentsMdState::UpToDate => Color::Green,
AgentsMdState::Missing => Color::Reset,
AgentsMdState::Drifted | AgentsMdState::Malformed => Color::Red,
},
)));
}
let word = state_word(repo);
let colour = if word.starts_with('x') {
Color::Red
} else if word.starts_with('!') {
Color::Yellow
} else {
Color::Green
};
cells.push(Cell::from(word).style(tint(app.color, colour)));
Row::new(cells)
})
.collect();
let widths: Vec<Constraint> = if wide {
vec![
Constraint::Min(28),
Constraint::Length(6),
Constraint::Length(8),
Constraint::Length(12),
Constraint::Length(6),
Constraint::Length(5),
Constraint::Length(6),
Constraint::Length(7),
Constraint::Length(14),
]
} else if mid {
vec![
Constraint::Min(20),
Constraint::Length(6),
Constraint::Length(8),
Constraint::Length(14),
]
} else {
vec![Constraint::Min(12), Constraint::Length(14)]
};
let mut state = TableState::default();
state.select(Some(app.selected.min(rows.len().saturating_sub(1))));
f.render_stateful_widget(
Table::new(body, widths)
.header(Row::new(header).style(tint(app.color, ACCENT)))
.row_highlight_style(selection_style())
.highlight_symbol(selection_cursor(app.color)),
area,
&mut state,
);
}
fn applies_here(r: &Repo, check: &str) -> bool {
crate::checks::rollup(std::slice::from_ref(r))
.into_iter()
.find(|rollup| rollup.name == check)
.map(|rollup| rollup.applicable > 0)
.unwrap_or(false)
}
fn detail(f: &mut Frame, area: Rect, app: &App) {
let rows = app.rows();
let Some(repo) = rows.get(app.selected) else {
return;
};
let mut lines = vec![
Line::from(repo.path.to_string_lossy().into_owned()),
Line::from(format!(
"{} · {} · bake {}",
if repo.managed { "managed" } else { "unmanaged" },
if repo.languages.is_empty() {
"no manifest".to_string()
} else {
repo.languages.join(" ")
},
bake_word(&repo.baked)
)),
Line::from(""),
Line::from("DISPATCHERS").style(tint(app.color, ACCENT)),
];
lines.push(Line::from(format!(
" {:<20} {}",
"hooks dir",
amont_runtime::ui::sanitize(&repo.hooks_dir.describe())
)));
if let Some(owner) = &repo.shares_hooks_with {
lines.push(Line::from(format!(
" {:<20} covered by {}",
"shares hooks",
amont_runtime::ui::sanitize_path(owner)
)));
}
for (i, n) in DISPATCHERS.iter().enumerate() {
let s = match repo.shims.get(i) {
Some(ShimState::Ok { baked }) => format!("ok -> {baked}"),
Some(ShimState::Drifted) => "DRIFTED does not match the template".into(),
Some(ShimState::Symlink { target }) => format!(
"SYMLINK -> {} (a write here would rewrite that file)",
target
.as_deref()
.map(amont_runtime::ui::sanitize_path)
.unwrap_or_else(|| "?".to_string())
),
Some(ShimState::Unreadable { why }) => {
format!("UNKNOWN {}", amont_runtime::ui::sanitize(why))
}
_ => "MISSING".to_string(),
};
lines.push(Line::from(format!(" {n:<20} {s}")));
}
if !repo.stale_ours.is_empty() || repo.hook_pkgjson {
lines.push(Line::from(""));
lines.push(
Line::from("LEFTOVERS OF OURS (nothing dispatches these — fix removes them)")
.style(tint(app.color, ACCENT)),
);
for name in &repo.stale_ours {
lines.push(Line::from(format!(" {name}")));
}
if repo.hook_pkgjson {
lines.push(Line::from(" package.json (node era)"));
}
}
if !repo.foreign_subs.is_empty() {
lines.push(Line::from(""));
lines.push(
Line::from("NOT OURS (left alone — nothing dispatches these either)")
.style(tint(app.color, ACCENT)),
);
for name in &repo.foreign_subs {
lines.push(Line::from(format!(" {name}")));
}
}
if !repo.skips.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from("hook.skip").style(tint(app.color, ACCENT)));
for skip in &repo.skips {
let scope = match &skip.scope {
crate::skips::Scope::Local => "local",
crate::skips::Scope::Global => "global",
crate::skips::Scope::Other { .. } => "other",
};
let head = if skip.is_inert() {
"! names no check".to_string()
} else if skip.is_trigger() {
format!(
"the whole {} trigger — {} checks",
skip.value,
skip.suppresses.len()
)
} else {
skip.suppresses.join(", ")
};
lines.push(Line::from(format!(
" {:<22} {scope:<7} -> {head}",
skip.value
)));
if skip.suppresses.len() > 1 {
lines.push(Line::from(format!(
" {:<22} {:<7} {}",
"",
"",
skip.suppresses.join(", ")
)));
}
}
}
if !repo.severities.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from("amont.severity").style(tint(app.color, ACCENT)));
for entry in &repo.severities {
let scope = match &entry.scope {
crate::skips::Scope::Local => "local",
crate::skips::Scope::Global => "global",
crate::skips::Scope::Other { .. } => "other",
};
let head = if entry.shadowed() {
"overridden — another entry for this check is the one git applies".to_string()
} else if entry.is_inert() {
"! changes nothing — unknown check or value".to_string()
} else if entry.weakens() {
"runs and reports, does NOT block".to_string()
} else {
"blocks (the default, written out)".to_string()
};
lines.push(Line::from(format!(
" {:<22} {:<7} {:<6} -> {head}",
entry.check, scope, entry.value
)));
}
}
if !repo.declared.is_empty() {
lines.push(Line::from(""));
let heading = match repo.trusted {
Some(true) => format!("{DECLARED_FILE} (declared here, trusted)"),
Some(false) => {
format!("{DECLARED_FILE} (declared here — NOT TRUSTED, none of these run)")
}
None => format!("{DECLARED_FILE} (declared here)"),
};
lines.push(Line::from(heading).style(tint(
app.color,
if repo.trusted == Some(false) {
Color::Red
} else {
ACCENT
},
)));
for declared in &repo.declared {
match &declared.state {
crate::scan::DeclaredState::Unusable { why } => lines.push(
Line::from(format!(" {:<18} ! {why}", declared.name))
.style(tint(app.color, Color::Red)),
),
crate::scan::DeclaredState::Usable { exts, command, .. } => {
lines.push(Line::from(format!(
" {:<18} {:<6} {:<10} {}",
declared.name,
declared.stage,
scope_of(exts),
command
)))
}
}
}
}
lines.push(Line::from(""));
let (agents_md_word, agents_md_colour) = match repo.agents_md {
AgentsMdState::UpToDate => ("up to date".to_string(), Color::Green),
AgentsMdState::Missing => ("not present (opt-in)".to_string(), Color::Reset),
AgentsMdState::Drifted => ("drifted from the generated block".to_string(), Color::Red),
AgentsMdState::Malformed => (
"unpaired marker — fix or remove it by hand".to_string(),
Color::Red,
),
};
lines.push(
Line::from(format!("AGENTS.md: {agents_md_word}")).style(tint(app.color, agents_md_colour)),
);
lines.push(Line::from(""));
lines.push(
Line::from(format!("CHECKS ({})", crate::checks::all_checks().len()))
.style(tint(app.color, ACCENT)),
);
let all = crate::checks::all_checks();
let room = (area.height as usize)
.saturating_sub(lines.len() + 3)
.max(3);
let first = app
.check_selected
.saturating_sub(room / 2)
.min(all.len().saturating_sub(room));
for (i, check) in all.iter().copied().enumerate().skip(first).take(room) {
let skipped = repo
.skips
.iter()
.any(|skip| amont_runtime::skip_suppresses(check, &skip.value));
let (glyph, word) = if skipped {
('⊘', "skipped")
} else if applies_here(repo, check) {
('●', "runs")
} else {
('○', "inert")
};
let cursor = if i == app.check_selected { '>' } else { ' ' };
lines.push(Line::from(format!(
"{cursor} {glyph} {:<22} {:<11} {word}",
amont_runtime::short_name(check),
crate::checks::trigger_of(check),
)));
}
lines.push(Line::from(
" ● runs here ○ inert (no matching manifest) ⊘ skipped via hook.skip",
));
f.render_widget(Paragraph::new(lines), area);
}
fn hooks_view(f: &mut Frame, area: Rect, app: &App) {
let rows = checks::rollup(&app.scan.repos);
let managed = app.scan.managed_seen;
let body: Vec<Row> = rows
.iter()
.map(|r| {
let flag = if r.applicable == 0 { " <- never" } else { "" };
Row::new(vec![
Cell::from(format!("{}{flag}", amont_runtime::short_name(r.name))),
Cell::from(r.trigger),
Cell::from(format!("{}/{managed}", r.applicable)),
Cell::from(r.active.to_string()),
Cell::from(r.skipped.to_string()),
Cell::from(r.inert.to_string()),
])
})
.collect();
f.render_widget(
Table::new(
body,
[
Constraint::Min(22),
Constraint::Length(11),
Constraint::Length(12),
Constraint::Length(8),
Constraint::Length(9),
Constraint::Length(7),
],
)
.header(
Row::new(vec![
"CHECK",
"TRIGGER",
"APPLICABLE",
"ACTIVE",
"SKIPPED",
"INERT",
])
.style(tint(app.color, ACCENT)),
),
area,
);
}
fn footer(f: &mut Frame, area: Rect, app: &App) {
let rows = app.rows().len();
let total = app.scan.repos.len();
let left = if app.mode == Mode::Filter {
format!("/{}", app.filter.as_str())
} else if app.filter.is_empty() {
format!("{rows} rows")
} else {
format!("{rows} of {total} rows match {:?}", app.filter.as_str())
};
if let Some(n) = &app.notice {
f.render_widget(Paragraph::new(Line::from(n.clone())), area);
return;
}
let keys = if app.mode == Mode::HookView {
"h fleet q quit"
} else if app.mode == Mode::Detail {
"j/k check s skip u undo esc back q quit"
} else {
"j/k move enter detail / filter h hooks esc clear q quit"
};
f.render_widget(Paragraph::new(Line::from(format!("{left} {keys}"))), area);
}
pub fn run(root: std::path::PathBuf, depth: usize, binary: String) -> std::io::Result<()> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use std::sync::mpsc;
use std::time::{Duration, Instant};
enum Msg {
Visited(usize),
Found(Box<Repo>),
Done(Box<FleetScan>),
}
let (tx, rx) = mpsc::channel();
let scan_root = root.clone();
std::thread::spawn(move || {
let t = tx.clone();
let scan = crate::scan::scan_with(&scan_root, depth, &binary, &mut |p| match p {
crate::scan::Progress::Visited(n) if n % 200 == 0 => {
let _ = t.send(Msg::Visited(n));
}
crate::scan::Progress::Found(r) => {
let _ = t.send(Msg::Found(Box::new(r.clone())));
}
_ => {}
});
let _ = tx.send(Msg::Done(Box::new(scan)));
});
enable_raw_mode()?;
let mut out = std::io::stdout();
crossterm::execute!(out, EnterAlternateScreen)?;
let mut term = Terminal::new(CrosstermBackend::new(out))?;
let started = Instant::now();
let mut app = App::new(FleetScan {
root,
depth,
git_dirs_found: 0,
hook_dirs_seen: 0,
managed_seen: 0,
unmanaged_seen: 0,
unreadable: Vec::new(),
hooks_outside_seen: 0,
excluded_dirs: 0,
dirs_visited: 0,
repos: Vec::new(),
});
app.scanning = true;
let result = loop {
while let Ok(msg) = rx.try_recv() {
match msg {
Msg::Visited(n) => app.visited = n,
Msg::Found(r) => {
app.scan.repos.push(*r);
app.scan.git_dirs_found = app.scan.repos.len();
app.scan.managed_seen = app.scan.repos.iter().filter(|r| r.managed).count();
app.scan.unmanaged_seen = app.scan.git_dirs_found - app.scan.managed_seen;
}
Msg::Done(s) => {
app.scan = *s;
app.scanning = false;
}
}
}
app.elapsed = started.elapsed().as_secs_f64();
if let Err(e) = term.draw(|f| draw(f, &app)) {
break Err(e);
}
if event::poll(Duration::from_millis(16))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
let mapped = match k.code {
KeyCode::Char(c) => Some(Key::Char(c)),
KeyCode::Enter => Some(Key::Enter),
KeyCode::Esc => Some(Key::Esc),
KeyCode::Backspace => Some(Key::Backspace),
KeyCode::Down => Some(Key::Down),
KeyCode::Up => Some(Key::Up),
_ => None,
};
if let Some(key) = mapped {
app.on_key(key);
}
}
}
}
if app.quit {
break Ok(());
}
};
disable_raw_mode()?;
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen)?;
term.show_cursor()?;
result
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use std::path::PathBuf;
fn render(app: &App, w: u16, h: u16) -> String {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| draw(f, app)).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn empty_scan() -> FleetScan {
FleetScan {
root: PathBuf::from("/Users/me/Dev"),
depth: 6,
git_dirs_found: 0,
hook_dirs_seen: 0,
managed_seen: 0,
unmanaged_seen: 0,
unreadable: Vec::new(),
hooks_outside_seen: 0,
excluded_dirs: 0,
dirs_visited: 412,
repos: Vec::new(),
}
}
fn repo(path: &str, managed: bool) -> Repo {
Repo {
path: PathBuf::from(path),
managed,
shims: vec![
ShimState::Ok {
baked: "/bin/gh".into()
};
4
],
baked: BakeState::Current,
stale_ours: Vec::new(),
foreign_subs: Vec::new(),
hook_pkgjson: false,
languages: vec!["rust".into()],
applicable: Vec::new(),
skips: Vec::new(),
severities: Vec::new(),
declared: Vec::new(),
trusted: None,
agents_md: AgentsMdState::Missing,
hooks_dir: crate::scan::HooksDir::In {
path: std::path::PathBuf::from(".git/hooks"),
},
shares_hooks_with: None,
}
}
fn scan_with_repos(rs: Vec<Repo>) -> FleetScan {
let managed = rs.iter().filter(|repo| repo.managed).count();
FleetScan {
root: PathBuf::from("/root"),
depth: 6,
git_dirs_found: rs.len(),
hook_dirs_seen: rs.len(),
managed_seen: managed,
unmanaged_seen: rs.len() - managed,
unreadable: Vec::new(),
hooks_outside_seen: 0,
excluded_dirs: 3,
dirs_visited: 100,
repos: rs,
}
}
#[test]
fn an_empty_scan_renders_scan_failure() {
let out = render(&App::new(empty_scan()), 90, 16);
assert!(out.contains("SCAN FAILURE"), "{out}");
assert!(out.contains("--root"), "must name what to check: {out}");
assert!(out.contains("--depth"), "{out}");
assert!(
out.contains("/Users/me/Dev"),
"and show the values used: {out}"
);
assert!(out.contains("412"), "and what it did look at: {out}");
}
#[test]
fn a_populated_scan_does_not_claim_failure() {
let out = render(&App::new(scan_with_repos(vec![repo("a", true)])), 90, 16);
assert!(!out.contains("SCAN FAILURE"), "{out}");
}
#[test]
fn the_consistency_band_shows_a_denominator() {
let app = App::new(scan_with_repos(vec![repo("a", true), repo("b", true)]));
let out = render(&app, 110, 16);
assert!(out.contains("2/2"), "expected N/M per hook: {out}");
assert!(out.contains("repositories"), "{out}");
}
#[test]
fn state_is_tinted_with_a_named_colour() {
let mut bad = repo("x", true);
bad.shims[1] = ShimState::Drifted;
let mut app = App::new(scan_with_repos(vec![repo("ok", true), bad]));
app.color = true;
let mut term = Terminal::new(ratatui::backend::TestBackend::new(110, 12)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let buf = term.backend().buffer().clone();
let used: Vec<Color> = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].fg)
.collect();
assert!(used.contains(&Color::Green), "an ok row should be green");
assert!(used.contains(&Color::Red), "a drifted row should be red");
assert!(
!used
.iter()
.any(|c| matches!(c, Color::Indexed(n) if *n > 15)),
"a fixed palette entry overrides the user's theme"
);
}
#[test]
fn the_selected_row_keeps_its_status_colour() {
let mut app = App::new(scan_with_repos(vec![repo("ok", true), repo("b", true)]));
app.color = true;
app.selected = 0;
let mut term = Terminal::new(ratatui::backend::TestBackend::new(110, 12)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let buf = term.backend().buffer().clone();
let used: Vec<Color> = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].fg)
.collect();
assert!(
used.contains(&Color::Green),
"the selected ok row must still read as ok"
);
assert!(used.contains(&ACCENT), "and the cursor carries the accent");
}
#[test]
fn headers_carry_the_accent() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
app.color = true;
let mut term = Terminal::new(ratatui::backend::TestBackend::new(110, 14)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let buf = term.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
};
let accented = |y: u16| (0..buf.area.width).any(|x| buf[(x, y)].fg == ACCENT);
let header_y = (0..buf.area.height)
.find(|y| row_text(*y).contains("REPO") && row_text(*y).contains("STATE"))
.expect("column header row");
assert!(accented(header_y), "the column header must be accented");
let title_y = (0..buf.area.height)
.find(|y| row_text(*y).contains("/root"))
.expect("title row");
assert!(accented(title_y), "the title line must be accented");
}
#[test]
fn the_selection_survives_no_color() {
let mut app = App::new(scan_with_repos(vec![repo("a", true), repo("b", true)]));
app.color = false;
app.selected = 1;
let mut term = Terminal::new(ratatui::backend::TestBackend::new(110, 12)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let buf = term.backend().buffer().clone();
let reversed = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.any(|(x, y)| buf[(x, y)].modifier.contains(Modifier::REVERSED));
assert!(reversed, "reverse video marks the row without colour");
let screen: String = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
screen.contains('>'),
"the cursor symbol is there:\n{screen}"
);
let coloured = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.any(|(x, y)| buf[(x, y)].fg != Color::Reset);
assert!(!coloured, "NO_COLOR must leave no foreground colour set");
}
#[test]
fn narrow_terminals_drop_columns_rather_than_scroll() {
let app = App::new(scan_with_repos(vec![repo("some/repo", true)]));
let wide = render(&app, 110, 12);
assert!(wide.contains("LANG") && wide.contains("SKIPS"), "{wide}");
let mid = render(&app, 80, 12);
assert!(!mid.contains("LANG"), "LANG should be dropped first: {mid}");
assert!(mid.contains("SHIMS"), "{mid}");
let narrow = render(&app, 50, 12);
assert!(!narrow.contains("SHIMS"), "{narrow}");
assert!(
narrow.contains("REPO") && narrow.contains("STATE"),
"{narrow}"
);
}
#[test]
fn state_is_encoded_in_text_not_only_colour() {
let mut r = repo("x", true);
r.shims[2] = ShimState::Missing;
let out = render(&App::new(scan_with_repos(vec![r])), 110, 12);
assert!(out.contains("missing"), "a word, not just a colour: {out}");
assert!(out.contains('○'), "and a distinct glyph: {out}");
}
#[test]
fn an_unmanaged_repo_is_labelled_not_hidden() {
let out = render(
&App::new(scan_with_repos(vec![repo("data/repo", false)])),
110,
12,
);
assert!(out.contains("unmanaged"), "{out}");
}
#[test]
fn detail_lists_every_dispatcher() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
app.mode = Mode::Detail;
let out = render(&app, 100, 20);
for n in DISPATCHERS {
assert!(out.contains(n), "detail must list {n}: {out}");
}
}
#[test]
fn detail_shows_what_each_skip_actually_suppresses() {
let mut r = repo("a", true);
r.skips = vec![crate::skips::for_test("pre-commit")];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("hook.skip"), "{out}");
assert!(
out.contains("the whole pre-commit trigger"),
"must name what it covers: {out}"
);
assert!(out.contains("15 checks"), "and how many: {out}");
}
#[test]
fn detail_flags_a_skip_that_names_nothing() {
let mut r = repo("a", true);
r.skips = vec![crate::skips::for_test("t")];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("names no check"), "{out}");
}
#[test]
fn detail_says_a_downgraded_check_does_not_block() {
let mut r = repo("a", true);
r.severities = vec![crate::severities::for_test("pre-commit-clippy", "warn")];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("amont.severity"), "{out}");
assert!(out.contains("does NOT block"), "{out}");
assert!(
!out.contains("changes nothing"),
"it does change things: {out}"
);
}
#[test]
fn detail_flags_a_severity_line_that_does_nothing() {
for entry in [
crate::severities::for_test("pre-commit-clipy", "warn"),
crate::severities::for_test("pre-commit-clippy", "advisory"),
] {
let mut r = repo("a", true);
r.severities = vec![entry.clone()];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(
out.contains("changes nothing"),
"{entry:?} was not flagged as inert: {out}"
);
}
}
#[test]
fn an_explicit_block_is_shown_without_alarm() {
let mut r = repo("a", true);
r.severities = vec![crate::severities::for_test("pre-commit-clippy", "block")];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("amont.severity"), "{out}");
assert!(!out.contains("does NOT block"), "{out}");
assert!(!out.contains("changes nothing"), "{out}");
}
#[test]
fn the_warn_column_counts_only_what_actually_weakens() {
let mut r = repo("a", true);
r.severities = vec![
crate::severities::for_test("pre-commit-clippy", "warn"),
crate::severities::for_test("pre-commit-prettier", "block"),
crate::severities::for_test("nonsense", "warn"),
];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Browse;
let out = render(&app, 130, 12);
assert!(out.contains("WARN"), "the column must exist: {out}");
let row = out
.lines()
.find(|l| l.contains("a "))
.expect("the repo row");
assert!(
row.contains(" 1 "),
"expected a count of 1 downgrade, got: {row}"
);
}
fn declared(name: &str, broken: Option<&str>) -> crate::scan::DeclaredCheck {
use crate::scan::{DeclaredCheck, DeclaredState};
DeclaredCheck {
name: name.to_string(),
stage: "pre-commit".to_string(),
state: match broken {
Some(why) => DeclaredState::Unusable {
why: why.to_string(),
},
None => DeclaredState::Usable {
severity: "block".to_string(),
exts: vec![".sh".to_string()],
command: "make lint".to_string(),
},
},
}
}
#[test]
fn detail_lists_what_the_repo_declares() {
let mut r = repo("a", true);
r.declared = vec![declared("shellcheck", None)];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("amont.conf"), "{out}");
assert!(out.contains("shellcheck"), "{out}");
assert!(out.contains("make lint"), "the command itself: {out}");
assert!(out.contains("*.sh"), "and what gates it: {out}");
}
#[test]
fn detail_leads_with_why_a_declared_check_cannot_run() {
let mut r = repo("a", true);
r.declared = vec![declared("shellcheck", Some("line 1: severity \"LOUD\""))];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
let row = out
.lines()
.find(|l| l.contains("shellcheck"))
.expect("the row");
assert!(row.contains("line 1"), "{row}");
assert!(
!row.contains("make lint"),
"a broken line must not read as a command that runs: {row}"
);
}
#[test]
fn the_decl_column_separates_broken_from_merely_present() {
let mut clean = repo("clean", true);
clean.declared = vec![declared("a", None), declared("b", None)];
let mut dirty = repo("dirty", true);
dirty.declared = vec![declared("a", None), declared("b", Some("line 2: nope"))];
let app = App::new(scan_with_repos(vec![clean, dirty]));
let out = render(&app, 140, 12);
assert!(out.contains("DECL"), "the column must exist: {out}");
let row = |needle: &str| {
out.lines()
.find(|l| l.contains(needle))
.unwrap_or_default()
.to_string()
};
assert!(row("clean").contains(" 2 "), "{}", row("clean"));
assert!(row("dirty").contains("2!1"), "{}", row("dirty"));
}
#[test]
fn the_agents_column_shows_each_repos_own_state() {
let mut current = repo("current", true);
current.agents_md = AgentsMdState::UpToDate;
let mut missing = repo("missing", true);
missing.agents_md = AgentsMdState::Missing;
let mut drifted = repo("drifted", true);
drifted.agents_md = AgentsMdState::Drifted;
let app = App::new(scan_with_repos(vec![current, missing, drifted]));
let out = render(&app, 140, 12);
assert!(out.contains("AGENTS"), "the column must exist: {out}");
let row = |needle: &str| {
out.lines()
.find(|l| l.contains(needle))
.unwrap_or_default()
.to_string()
};
assert!(row("current").contains("ok"), "{}", row("current"));
assert!(row("missing").contains('-'), "{}", row("missing"));
assert!(row("drifted").contains("drift"), "{}", row("drifted"));
}
#[test]
fn detail_reports_the_agents_md_state() {
let mut r = repo("a", true);
r.agents_md = AgentsMdState::Drifted;
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("AGENTS.md"), "{out}");
assert!(out.contains("drifted"), "{out}");
}
#[test]
fn a_repo_declaring_nothing_says_nothing() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(
!out.contains("amont.conf"),
"a file that does not exist was mentioned: {out}"
);
}
#[test]
fn detail_marks_a_shadowed_entry_rather_than_calling_it_a_downgrade() {
let mut r = repo("a", true);
r.severities = vec![
crate::severities::shadowed_for_test("pre-commit-clippy", "warn"),
crate::severities::for_test("pre-commit-clippy", "block"),
];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("overridden"), "{out}");
assert!(
!out.contains("does NOT block"),
"an overridden warn was reported as a live downgrade:\n{out}"
);
}
#[test]
fn the_warn_column_ignores_a_shadowed_entry() {
let mut r = repo("a", true);
r.severities = vec![
crate::severities::shadowed_for_test("pre-commit-clippy", "warn"),
crate::severities::for_test("pre-commit-clippy", "block"),
];
let app = App::new(scan_with_repos(vec![r]));
let out = render(&app, 140, 12);
let row = out
.lines()
.find(|l| l.contains("a "))
.expect("the repo row");
assert!(
!row.contains(" 1 "),
"counted an override git does not apply: {row}"
);
}
#[test]
fn an_exact_skip_is_not_flagged() {
let mut r = repo("a", true);
r.skips = vec![crate::skips::for_test("pre-commit-clippy")];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("pre-commit-clippy"), "{out}");
assert!(!out.contains("probably not intended"), "{out}");
assert!(!out.contains("did you mean"), "nothing to correct: {out}");
}
#[test]
fn a_short_name_is_reported_as_the_check_it_names() {
let mut r = repo("a", true);
r.skips = vec![crate::skips::for_test("run-tests-js")];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::Detail;
let out = render(&app, 120, 30);
assert!(out.contains("pre-push-run-tests-js"), "{out}");
assert!(!out.contains("did you mean"), "nothing to correct: {out}");
}
fn repo_on_disk(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("tui-toggle-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("r")).unwrap();
std::process::Command::new("git")
.args(["init", "-q", "--template=", "."])
.current_dir(d.join("r"))
.output()
.expect("git");
d
}
fn app_on(root: &std::path::Path) -> App {
let mut sc = scan_with_repos(vec![repo("r", true)]);
sc.root = root.to_path_buf();
let mut app = App::new(sc);
app.mode = Mode::Detail;
app
}
fn index_of(check: &str) -> usize {
crate::checks::all_checks()
.iter()
.position(|c| *c == check)
.expect("check")
}
#[test]
fn a_toggle_needs_no_confirmation_step() {
let root = repo_on_disk("no-confirm");
let mut app = app_on(&root);
app.check_selected = index_of("pre-commit-lint-js");
app.on_key(Key::Char('s'));
assert_eq!(app.mode, Mode::Detail, "it should just write");
assert_eq!(
crate::skips::read(&root.join("r"))
.iter()
.map(|e| e.value.clone())
.collect::<Vec<_>>(),
vec!["pre-commit-lint-js"],
"and take only the check that was pointed at"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn s_skips_a_single_check_and_u_takes_it_back() {
let root = repo_on_disk("single");
let mut app = app_on(&root);
app.check_selected = index_of("pre-commit-clippy");
app.on_key(Key::Char('s'));
assert_eq!(app.mode, Mode::Detail, "no prompt for the common case");
assert_eq!(
crate::skips::read(&root.join("r"))
.into_iter()
.map(|e| e.value)
.collect::<Vec<_>>(),
vec!["pre-commit-clippy"]
);
assert!(app.notice.as_deref().unwrap_or("").contains("u to undo"));
app.on_key(Key::Char('u'));
assert!(
crate::skips::read(&root.join("r")).is_empty(),
"undo restores"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn filtering_shows_the_match_count_against_the_total() {
let mut app = App::new(scan_with_repos(vec![
repo("alpha", true),
repo("beta", true),
repo("gamma", true),
]));
app.filter.set("bet");
assert_eq!(app.rows().len(), 1);
let out = render(&app, 100, 12);
assert!(
out.contains("1 of 3"),
"an empty result must be legible: {out}"
);
}
#[test]
fn the_hook_view_transposes_and_keeps_a_denominator() {
let mut app = App::new(scan_with_repos(vec![repo("a", true), repo("b", true)]));
app.mode = Mode::HookView;
let out = render(&app, 110, 30);
assert!(out.contains("APPLICABLE"), "{out}");
assert!(out.contains("INERT"), "inert must be its own column: {out}");
assert!(out.contains("2/2"), "counts carry the managed total: {out}");
}
#[test]
fn the_checks_panel_names_each_trigger() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
app.mode = Mode::Detail;
let out = render(&app, 100, 40);
let row = |name: &str| {
out.lines()
.find(|l| l.contains(name))
.unwrap_or("")
.to_string()
};
assert!(row("clippy").contains("pre-commit"), "{}", row("clippy"));
assert!(
row("branch-protect").contains("pre-push"),
"a pre-push check must not be labelled pre-commit: {}",
row("branch-protect")
);
assert!(
!row("clippy").contains("pre-commit-clippy"),
"the column already says it: {}",
row("clippy")
);
}
#[test]
fn the_hook_view_gives_the_trigger_its_own_column() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
app.mode = Mode::HookView;
let out = render(&app, 110, 30);
assert!(out.contains("TRIGGER"), "{out}");
assert!(out.contains("clippy"), "{out}");
assert!(
!out.contains("pre-commit-clippy"),
"the trigger column already says it: {out}"
);
let row = |name: &str| {
out.lines()
.find(|l| l.contains(name))
.unwrap_or("")
.to_string()
};
assert!(row("clippy").contains("pre-commit"), "{}", row("clippy"));
assert!(
row("branch-protect").contains("pre-push"),
"{}",
row("branch-protect")
);
}
#[test]
fn a_check_that_applies_nowhere_is_flagged() {
let mut r = repo("only-js", true);
r.languages = vec!["js".into()];
let mut app = App::new(scan_with_repos(vec![r]));
app.mode = Mode::HookView;
let out = render(&app, 110, 30);
assert!(
out.contains("never"),
"expected a marker on the dead rows: {out}"
);
}
#[test]
fn h_toggles_the_hook_view() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
assert_eq!(app.mode, Mode::Browse);
app.on_key(Key::Char('h'));
assert_eq!(app.mode, Mode::HookView);
app.on_key(Key::Char('h'));
assert_eq!(app.mode, Mode::Browse);
}
#[test]
fn keys_move_enter_and_quit() {
let mut app = App::new(scan_with_repos(vec![repo("a", true), repo("b", true)]));
app.on_key(Key::Char('j'));
assert_eq!(app.selected, 1);
app.on_key(Key::Char('j'));
assert_eq!(app.selected, 1, "must not run past the end");
app.on_key(Key::Char('k'));
assert_eq!(app.selected, 0);
app.on_key(Key::Enter);
assert_eq!(app.mode, Mode::Detail);
app.on_key(Key::Esc);
assert_eq!(
app.mode,
Mode::Browse,
"esc leaves detail before clearing a filter"
);
app.on_key(Key::Char('q'));
assert!(app.quit);
}
#[test]
fn arrows_move_and_do_not_type() {
let mut app = App::new(scan_with_repos(vec![repo("a", true), repo("b", true)]));
app.on_key(Key::Down);
assert_eq!(app.selected, 1);
app.on_key(Key::Up);
assert_eq!(app.selected, 0);
app.on_key(Key::Char('/'));
app.on_key(Key::Down);
assert_eq!(
app.filter.as_str(),
"",
"an arrow must not become a character"
);
}
#[test]
fn filter_mode_captures_typing_and_escape_clears_it() {
let mut app = App::new(scan_with_repos(vec![repo("alpha", true)]));
app.on_key(Key::Char('/'));
assert_eq!(app.mode, Mode::Filter);
for c in "alp".chars() {
app.on_key(Key::Char(c));
}
assert_eq!(app.filter.as_str(), "alp");
app.on_key(Key::Backspace);
assert_eq!(
app.filter.as_str(),
"al",
"backspace edits rather than exits"
);
app.on_key(Key::Esc);
assert_eq!(app.mode, Mode::Browse);
assert!(app.filter.is_empty());
}
#[test]
fn q_types_rather_than_quits_while_filtering() {
let mut app = App::new(scan_with_repos(vec![repo("a", true)]));
app.on_key(Key::Char('/'));
app.on_key(Key::Char('q'));
assert!(!app.quit, "q must be text here");
assert_eq!(app.filter.as_str(), "q");
}
#[test]
fn enter_keeps_the_filter_and_esc_discards_it() {
let mut app = App::new(scan_with_repos(vec![
repo("alpha", true),
repo("beta", true),
]));
app.on_key(Key::Char('/'));
app.on_key(Key::Char('b'));
app.on_key(Key::Enter);
assert_eq!(app.mode, Mode::Browse);
assert_eq!(app.filter.as_str(), "b", "enter commits the filter");
assert_eq!(app.rows().len(), 1);
}
}