use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str};
use crate::db::CommandRecord;
use crate::tui::actions::{Action, TuiBackend};
pub const DEFAULT_PAGE_SIZE: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatusFilter {
#[default]
All,
Success,
Failure,
}
impl StatusFilter {
pub fn next(self) -> Self {
match self {
StatusFilter::All => StatusFilter::Success,
StatusFilter::Success => StatusFilter::Failure,
StatusFilter::Failure => StatusFilter::All,
}
}
pub fn label(self) -> &'static str {
match self {
StatusFilter::All => "tous",
StatusFilter::Success => "succès",
StatusFilter::Failure => "échecs",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TuiFilters {
pub project: Option<String>,
pub branch: Option<String>,
pub cwd: Option<String>,
pub status: StatusFilter,
}
impl TuiFilters {
pub fn is_empty(&self) -> bool {
self.project.is_none()
&& self.branch.is_none()
&& self.cwd.is_none()
&& self.status == StatusFilter::All
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Overview {
pub total: usize,
pub visible: usize,
pub success: usize,
pub failed: usize,
pub projects: usize,
pub top_shell: Option<String>,
}
impl Overview {
pub fn compute(records: &[CommandRecord], filtered: &[usize]) -> Self {
let visible = filtered.len();
let mut failed = 0usize;
for &idx in filtered {
if let Some(code) = records[idx].exit_code {
if code != 0 {
failed += 1;
}
}
}
let success = visible - failed;
let mut projects: Vec<&str> = filtered
.iter()
.filter_map(|&idx| records[idx].git_root.as_deref().filter(|s| !s.is_empty()))
.map(last_segment)
.collect();
projects.sort_unstable();
projects.dedup();
let top_shell = dominant_shell(filtered.iter().map(|&idx| &records[idx]));
Self {
total: records.len(),
visible,
success,
failed,
projects: projects.len(),
top_shell,
}
}
pub fn failure_rate(&self) -> f64 {
if self.visible == 0 {
0.0
} else {
(self.failed as f64 / self.visible as f64) * 100.0
}
}
}
fn dominant_shell<'a>(records: impl Iterator<Item = &'a CommandRecord>) -> Option<String> {
use std::collections::HashMap;
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut order: Vec<&str> = Vec::new();
for r in records {
if let Some(shell) = r.shell.as_deref().filter(|s| !s.is_empty()) {
let entry = counts.entry(shell).or_insert(0);
if *entry == 0 {
order.push(shell);
}
*entry += 1;
}
}
order
.into_iter()
.max_by_key(|shell| counts[shell])
.map(|s| s.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiMode {
Search,
Help,
ConfirmDelete,
Filters,
Details,
}
pub struct TuiApp {
pub query: String,
pub records: Vec<CommandRecord>,
pub filtered: Vec<usize>,
pub selected: usize,
pub filters: TuiFilters,
pub mode: TuiMode,
pub status_message: Option<String>,
pub outcome: Option<String>,
pub should_quit: bool,
pub copy_buffer: Option<String>,
pub page_size: usize,
pub current_project: Option<String>,
pub current_branch: Option<String>,
matcher: Matcher,
}
impl TuiApp {
pub fn new(records: Vec<CommandRecord>, filters: TuiFilters, query: String) -> Self {
let mut app = Self {
query,
records,
filtered: Vec::new(),
selected: 0,
filters,
mode: TuiMode::Search,
status_message: None,
outcome: None,
should_quit: false,
copy_buffer: None,
page_size: DEFAULT_PAGE_SIZE,
current_project: None,
current_branch: None,
matcher: Matcher::new(NucleoConfig::DEFAULT),
};
app.recompute();
app
}
pub fn set_current_context(&mut self, project: Option<String>, branch: Option<String>) {
self.current_project = project;
self.current_branch = branch;
}
fn matches_filters(&self, r: &CommandRecord) -> bool {
if let Some(project) = &self.filters.project {
let ok = match &r.git_root {
Some(root) => root == project || last_segment(root) == project.as_str(),
None => false,
};
if !ok {
return false;
}
}
if let Some(branch) = &self.filters.branch {
if r.git_branch.as_deref() != Some(branch.as_str()) {
return false;
}
}
if let Some(cwd) = &self.filters.cwd {
if r.cwd.as_deref() != Some(cwd.as_str()) {
return false;
}
}
match self.filters.status {
StatusFilter::All => {}
StatusFilter::Success => {
if r.exit_code != Some(0) {
return false;
}
}
StatusFilter::Failure => match r.exit_code {
Some(code) if code != 0 => {}
_ => return false,
},
}
true
}
pub fn recompute(&mut self) {
let candidates: Vec<usize> = (0..self.records.len())
.filter(|&i| self.matches_filters(&self.records[i]))
.collect();
self.filtered = if self.query.trim().is_empty() {
candidates
} else {
let pattern = Pattern::parse(
self.query.trim(),
CaseMatching::Ignore,
Normalization::Smart,
);
let mut buf: Vec<char> = Vec::new();
let mut scored: Vec<(usize, u32)> = candidates
.into_iter()
.filter_map(|i| {
let haystack = Utf32Str::new(&self.records[i].command, &mut buf);
pattern.score(haystack, &mut self.matcher).map(|s| (i, s))
})
.collect();
scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
scored.into_iter().map(|(i, _)| i).collect()
};
self.clamp_selection();
}
fn clamp_selection(&mut self) {
if self.filtered.is_empty() {
self.selected = 0;
} else if self.selected >= self.filtered.len() {
self.selected = self.filtered.len() - 1;
}
}
pub fn select_next(&mut self) {
if !self.filtered.is_empty() && self.selected + 1 < self.filtered.len() {
self.selected += 1;
}
}
pub fn select_previous(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn page_down(&mut self) {
if self.filtered.is_empty() {
return;
}
self.selected = (self.selected + self.page_size).min(self.filtered.len() - 1);
}
pub fn page_up(&mut self) {
self.selected = self.selected.saturating_sub(self.page_size);
}
pub fn select_first(&mut self) {
self.selected = 0;
}
pub fn select_last(&mut self) {
if !self.filtered.is_empty() {
self.selected = self.filtered.len() - 1;
}
}
pub fn selected_record(&self) -> Option<&CommandRecord> {
self.filtered
.get(self.selected)
.map(|&idx| &self.records[idx])
}
pub fn selected_id(&self) -> Option<i64> {
self.selected_record().map(|r| r.id)
}
pub fn overview(&self) -> Overview {
Overview::compute(&self.records, &self.filtered)
}
pub fn push_query_char(&mut self, c: char) {
self.query.push(c);
self.recompute();
}
pub fn pop_query_char(&mut self) {
self.query.pop();
self.recompute();
}
pub fn toggle_help(&mut self) {
self.mode = if self.mode == TuiMode::Help {
TuiMode::Search
} else {
TuiMode::Help
};
}
pub fn toggle_filters(&mut self) {
self.mode = if self.mode == TuiMode::Filters {
TuiMode::Search
} else {
TuiMode::Filters
};
}
pub fn toggle_details_focus(&mut self) {
self.mode = if self.mode == TuiMode::Details {
TuiMode::Search
} else {
TuiMode::Details
};
}
pub fn filter_project_from_selection(&mut self) {
match self.selected_record().and_then(|r| r.git_root.clone()) {
Some(root) => {
let name = last_segment(&root).to_string();
self.filters.project = Some(name.clone());
self.status_message = Some(format!("Filtre projet = {name}"));
}
None => self.status_message = Some("Sélection sans projet Git.".to_string()),
}
self.recompute();
}
pub fn filter_branch_from_selection(&mut self) {
match self.selected_record().and_then(|r| r.git_branch.clone()) {
Some(branch) => {
self.filters.branch = Some(branch.clone());
self.status_message = Some(format!("Filtre branche = {branch}"));
}
None => self.status_message = Some("Sélection sans branche Git.".to_string()),
}
self.recompute();
}
pub fn filter_cwd_from_selection(&mut self) {
match self.selected_record().and_then(|r| r.cwd.clone()) {
Some(cwd) => {
self.filters.cwd = Some(cwd.clone());
self.status_message = Some(format!("Filtre dossier = {cwd}"));
}
None => self.status_message = Some("Sélection sans répertoire.".to_string()),
}
self.recompute();
}
pub fn cycle_status_filter(&mut self) {
self.filters.status = self.filters.status.next();
self.status_message = Some(format!("Statut = {}", self.filters.status.label()));
self.recompute();
}
pub fn clear_filters(&mut self) {
self.filters = TuiFilters::default();
self.status_message = Some("Filtres réinitialisés.".to_string());
self.recompute();
}
pub fn filter_project_current(&mut self) {
match &self.current_project {
Some(project) => {
self.filters.project = Some(project.clone());
self.status_message = Some(format!("Filtre projet courant = {project}"));
}
None => {
self.status_message = Some("Projet courant indéterminé.".to_string());
}
}
self.recompute();
}
pub fn filter_branch_current(&mut self) {
match &self.current_branch {
Some(branch) => {
self.filters.branch = Some(branch.clone());
self.status_message = Some(format!("Filtre branche courante = {branch}"));
}
None => {
self.status_message = Some("Branche courante indéterminée.".to_string());
}
}
self.recompute();
}
pub fn focus_search(&mut self) {
self.mode = TuiMode::Search;
}
pub fn export_results(&mut self) {
if self.filtered.is_empty() {
self.status_message = Some("Aucun résultat à exporter.".to_string());
return;
}
let refs: Vec<&CommandRecord> = self.filtered.iter().map(|&i| &self.records[i]).collect();
let json = match crate::export::records_to_json(&refs) {
Ok(j) => j,
Err(e) => {
self.status_message = Some(format!("Export impossible : {e}"));
return;
}
};
let stamp: String = crate::db::now_timestamp()
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
let filename = format!("mnemo-export-{stamp}.json");
match std::fs::write(&filename, json) {
Ok(()) => {
self.status_message = Some(format!(
"{} commande(s) exportée(s) vers {filename}",
refs.len()
));
}
Err(e) => {
self.status_message = Some(format!("Écriture de l'export impossible : {e}"));
}
}
}
pub fn request_delete(&mut self) {
if self.selected_id().is_some() {
self.mode = TuiMode::ConfirmDelete;
} else {
self.status_message = Some("Aucune commande à supprimer.".to_string());
}
}
pub fn cancel_delete(&mut self) {
self.mode = TuiMode::Search;
self.status_message = Some("Suppression annulée.".to_string());
}
pub fn confirm_delete<B: TuiBackend>(&mut self, backend: &mut B) {
let id = match self.selected_id() {
Some(id) => id,
None => {
self.mode = TuiMode::Search;
return;
}
};
match backend.backup_and_delete(id) {
Ok(()) => {
self.records.retain(|r| r.id != id);
self.recompute();
self.status_message = Some(format!("Commande #{id} supprimée (sauvegarde créée)."));
}
Err(e) => {
self.status_message = Some(format!(
"Suppression impossible : {e}. Aucune donnée modifiée."
));
}
}
self.mode = TuiMode::Search;
}
pub fn copy_selection(&mut self) {
let cmd = match self.selected_record() {
Some(r) => r.command.clone(),
None => {
self.status_message = Some("Aucune commande à copier.".to_string());
return;
}
};
self.copy_buffer = Some(cmd.clone());
match crate::tui::clipboard::copy_to_clipboard(&cmd) {
Ok(true) => {
self.status_message = Some("Commande copiée dans le presse-papiers.".to_string());
}
_ => {
self.status_message = Some(
"Presse-papiers indisponible ; utilisez Entrée pour imprimer la commande."
.to_string(),
);
}
}
}
pub fn refresh<B: TuiBackend>(&mut self, backend: &mut B) {
match backend.reload() {
Ok(records) => {
self.records = records;
self.recompute();
self.status_message = Some("Résultats rafraîchis.".to_string());
}
Err(e) => {
self.status_message = Some(format!("Rafraîchissement impossible : {e}"));
}
}
}
pub fn select_and_quit(&mut self) {
if let Some(r) = self.selected_record() {
self.outcome = Some(r.command.clone());
}
self.should_quit = true;
}
pub fn dispatch<B: TuiBackend>(&mut self, action: Action, backend: &mut B) {
match action {
Action::Quit => self.should_quit = true,
Action::Select => self.select_and_quit(),
Action::Up => self.select_previous(),
Action::Down => self.select_next(),
Action::PageUp => self.page_up(),
Action::PageDown => self.page_down(),
Action::Home => self.select_first(),
Action::End => self.select_last(),
Action::Backspace => self.pop_query_char(),
Action::Input(c) => self.push_query_char(c),
Action::ToggleHelp => self.toggle_help(),
Action::ToggleFilters => self.toggle_filters(),
Action::ToggleDetailsFocus => self.toggle_details_focus(),
Action::FocusSearch => self.focus_search(),
Action::Refresh => self.refresh(backend),
Action::Copy => self.copy_selection(),
Action::ExportResults => self.export_results(),
Action::RequestDelete => self.request_delete(),
Action::ConfirmYes => self.confirm_delete(backend),
Action::ConfirmNo => self.cancel_delete(),
Action::FilterProjectFromSelection => self.filter_project_from_selection(),
Action::FilterBranchFromSelection => self.filter_branch_from_selection(),
Action::FilterCwdFromSelection => self.filter_cwd_from_selection(),
Action::FilterProjectCurrent => self.filter_project_current(),
Action::FilterBranchCurrent => self.filter_branch_current(),
Action::CycleStatusFilter => self.cycle_status_filter(),
Action::ClearFilters => self.clear_filters(),
Action::None => {}
}
}
}
pub fn last_segment(path: &str) -> &str {
path.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::CommandRecord;
use crate::tui::actions::TuiBackend;
use anyhow::Result;
struct FakeBackend {
fail: bool,
deleted: Vec<i64>,
reload_with: Option<Vec<CommandRecord>>,
}
impl FakeBackend {
fn ok() -> Self {
Self {
fail: false,
deleted: Vec::new(),
reload_with: None,
}
}
fn failing() -> Self {
Self {
fail: true,
deleted: Vec::new(),
reload_with: None,
}
}
}
impl TuiBackend for FakeBackend {
fn backup_and_delete(&mut self, id: i64) -> Result<()> {
if self.fail {
anyhow::bail!("backup simulé en échec");
}
self.deleted.push(id);
Ok(())
}
fn reload(&mut self) -> Result<Vec<CommandRecord>> {
Ok(self.reload_with.clone().unwrap_or_default())
}
}
fn rec(id: i64, command: &str) -> CommandRecord {
CommandRecord {
id,
command: command.to_string(),
cwd: Some("/home/user".to_string()),
shell: Some("bash".to_string()),
hostname: Some("host".to_string()),
exit_code: Some(0),
created_at: "2026-06-14 10:00:00".to_string(),
git_root: None,
git_branch: None,
git_remote: None,
session_id: None,
}
}
fn with_git(mut r: CommandRecord, root: &str, branch: &str) -> CommandRecord {
r.git_root = Some(root.to_string());
r.git_branch = Some(branch.to_string());
r
}
fn sample() -> Vec<CommandRecord> {
vec![
with_git(rec(1, "cargo build"), "/home/user/mnemo", "main"),
with_git(rec(2, "cargo test"), "/home/user/mnemo", "dev"),
with_git(rec(3, "git status"), "/home/user/other", "main"),
{
let mut r = rec(4, "ls -la");
r.cwd = Some("/tmp".to_string());
r.exit_code = Some(1);
r
},
{
let mut r = rec(5, "false");
r.exit_code = Some(2);
r
},
]
}
fn app() -> TuiApp {
TuiApp::new(sample(), TuiFilters::default(), String::new())
}
#[test]
fn overview_compte_succes_echecs_projets_et_shell() {
let a = app();
let ov = a.overview();
assert_eq!(ov.total, 5);
assert_eq!(ov.visible, 5);
assert_eq!(ov.success, 3, "3 commandes en succès");
assert_eq!(ov.failed, 2, "2 commandes en échec");
assert_eq!(ov.projects, 2);
assert_eq!(ov.top_shell.as_deref(), Some("bash"));
assert!((ov.failure_rate() - 40.0).abs() < 1e-9);
}
#[test]
fn overview_taux_echec_nul_sans_commande_executee() {
let ov = Overview::compute(&[], &[]);
assert_eq!(ov.total, 0);
assert_eq!(ov.visible, 0);
assert_eq!(ov.success, 0);
assert_eq!(ov.failed, 0);
assert_eq!(ov.projects, 0);
assert_eq!(ov.failure_rate(), 0.0);
assert!(ov.top_shell.is_none());
}
#[test]
fn overview_suit_les_filtres_visibles() {
let mut a = app();
a.cycle_status_filter(); let ov = a.overview();
assert_eq!(ov.total, 5, "le total reste l'ensemble chargé");
assert_eq!(ov.visible, 3, "seuls les succès sont visibles");
assert_eq!(ov.failed, 0);
assert_eq!(ov.success, 3);
assert!((ov.failure_rate() - 0.0).abs() < 1e-9);
}
#[test]
fn overview_taux_echec_huit_succes_deux_echecs() {
let mut records: Vec<CommandRecord> = (0..10).map(|i| rec(i, "cmd")).collect();
records[3].exit_code = Some(1);
records[7].exit_code = Some(130);
let all: Vec<usize> = (0..records.len()).collect();
let ov = Overview::compute(&records, &all);
assert_eq!(ov.visible, 10);
assert_eq!(ov.success, 8);
assert_eq!(ov.failed, 2);
assert!((ov.failure_rate() - 20.0).abs() < 1e-9);
}
#[test]
fn overview_toutes_en_echec_donne_100_pour_cent() {
let records: Vec<CommandRecord> = (0..4)
.map(|i| {
let mut r = rec(i, "boom");
r.exit_code = Some(1);
r
})
.collect();
let all: Vec<usize> = (0..records.len()).collect();
let ov = Overview::compute(&records, &all);
assert_eq!(ov.success, 0);
assert_eq!(ov.failed, 4);
assert!((ov.failure_rate() - 100.0).abs() < 1e-9);
}
#[test]
fn overview_toutes_en_succes_donne_0_pour_cent() {
let records: Vec<CommandRecord> = (0..4).map(|i| rec(i, "ok")).collect();
let all: Vec<usize> = (0..records.len()).collect();
let ov = Overview::compute(&records, &all);
assert_eq!(ov.success, 4);
assert_eq!(ov.failed, 0);
assert!((ov.failure_rate() - 0.0).abs() < 1e-9);
}
#[test]
fn overview_exit_inconnu_compte_comme_succes() {
let mut records: Vec<CommandRecord> = (0..1241)
.map(|i| {
let mut r = rec(i, "legacy");
r.exit_code = None; r
})
.collect();
for r in records.iter_mut().take(6) {
r.exit_code = Some(1);
}
for r in records.iter_mut().skip(6).take(75) {
r.exit_code = Some(0);
}
let all: Vec<usize> = (0..records.len()).collect();
let ov = Overview::compute(&records, &all);
assert_eq!(ov.total, 1241);
assert_eq!(ov.visible, 1241);
assert_eq!(ov.failed, 6);
assert_eq!(
ov.success, 1235,
"succès = visibles - échecs (inconnu = succès)"
);
assert_eq!(
ov.success + ov.failed,
ov.visible,
"succès + échecs == visibles"
);
assert!((ov.failure_rate() - (6.0 / 1241.0 * 100.0)).abs() < 1e-9);
assert!(ov.failure_rate() < 1.0, "taux ~0,5 % et non ~7,4 %");
}
#[test]
fn overview_projets_et_shell_sur_visibles_seulement() {
let mut records = sample();
records[2].shell = Some("zsh".to_string()); let ov = Overview::compute(&records, &[2]);
assert_eq!(ov.total, 5, "total inchangé");
assert_eq!(ov.visible, 1);
assert_eq!(ov.projects, 1, "un seul projet visible");
assert_eq!(ov.top_shell.as_deref(), Some("zsh"));
}
#[test]
fn overview_sans_shell_ni_projet_ne_panique_pas() {
let mut records: Vec<CommandRecord> = (0..3).map(|i| rec(i, "x")).collect();
for r in records.iter_mut() {
r.shell = None;
r.git_root = None;
r.exit_code = Some(-1); }
let all: Vec<usize> = (0..records.len()).collect();
let ov = Overview::compute(&records, &all);
assert_eq!(ov.projects, 0);
assert!(ov.top_shell.is_none());
assert_eq!(ov.failed, 3, "exit_code négatif compte comme échec");
assert!((ov.failure_rate() - 100.0).abs() < 1e-9);
}
#[test]
fn navigation_haut_bas_bornee() {
let mut a = app();
assert_eq!(a.selected, 0);
a.select_previous();
assert_eq!(a.selected, 0, "ne descend pas sous 0");
a.select_next();
assert_eq!(a.selected, 1);
for _ in 0..20 {
a.select_next();
}
assert_eq!(a.selected, a.filtered.len() - 1);
}
#[test]
fn page_up_down_bornees() {
let mut a = app();
a.page_size = 2;
a.page_down();
assert_eq!(a.selected, 2);
a.page_down();
assert_eq!(a.selected, 4);
a.page_down();
assert_eq!(a.selected, a.filtered.len() - 1);
a.page_up();
assert_eq!(a.selected, 2);
a.page_up();
a.page_up();
assert_eq!(a.selected, 0);
}
#[test]
fn home_end() {
let mut a = app();
a.select_last();
assert_eq!(a.selected, a.filtered.len() - 1);
a.select_first();
assert_eq!(a.selected, 0);
}
#[test]
fn filtrage_par_projet() {
let mut a = app();
a.filters.project = Some("mnemo".to_string());
a.recompute();
assert_eq!(a.filtered.len(), 2);
assert!(a
.filtered
.iter()
.all(|&i| a.records[i].git_root.as_deref() == Some("/home/user/mnemo")));
}
#[test]
fn filtrage_par_branche() {
let mut a = app();
a.filters.branch = Some("main".to_string());
a.recompute();
assert_eq!(a.filtered.len(), 2);
assert!(a
.filtered
.iter()
.all(|&i| a.records[i].git_branch.as_deref() == Some("main")));
}
#[test]
fn filtrage_par_cwd() {
let mut a = app();
a.filters.cwd = Some("/tmp".to_string());
a.recompute();
assert_eq!(a.filtered.len(), 1);
assert_eq!(a.records[a.filtered[0]].command, "ls -la");
}
#[test]
fn filtrage_failed_et_success() {
let mut a = app();
a.filters.status = StatusFilter::Failure;
a.recompute();
assert_eq!(a.filtered.len(), 2, "ls -la (1) et false (2)");
a.filters.status = StatusFilter::Success;
a.recompute();
assert_eq!(a.filtered.len(), 3);
}
#[test]
fn clear_filters_reinitialise() {
let mut a = app();
a.filters.project = Some("mnemo".to_string());
a.filters.status = StatusFilter::Failure;
a.recompute();
a.clear_filters();
assert!(a.filters.is_empty());
assert_eq!(a.filtered.len(), a.records.len());
}
#[test]
fn recherche_conserve_une_selection_valide() {
let mut a = app();
a.select_last();
let before = a.selected;
assert!(before > 0);
a.push_query_char('c');
a.push_query_char('a');
assert!(a.selected < a.filtered.len());
assert!(!a.filtered.is_empty());
}
#[test]
fn suppression_confirmee_retire_l_element() {
let mut a = app();
let mut backend = FakeBackend::ok();
a.select_first();
let id = a.selected_id().unwrap();
let before = a.records.len();
a.request_delete();
assert_eq!(a.mode, TuiMode::ConfirmDelete);
a.confirm_delete(&mut backend);
assert_eq!(a.records.len(), before - 1);
assert!(a.records.iter().all(|r| r.id != id));
assert_eq!(backend.deleted, vec![id]);
assert_eq!(a.mode, TuiMode::Search);
}
#[test]
fn suppression_annulee_ne_modifie_rien() {
let mut a = app();
let before = a.records.len();
a.request_delete();
a.cancel_delete();
assert_eq!(a.records.len(), before);
assert_eq!(a.mode, TuiMode::Search);
}
#[test]
fn suppression_echoue_ne_retire_rien() {
let mut a = app();
let mut backend = FakeBackend::failing();
let before = a.records.len();
a.request_delete();
a.confirm_delete(&mut backend);
assert_eq!(
a.records.len(),
before,
"rien n'est supprimé si le backup échoue"
);
assert!(a.status_message.as_deref().unwrap().contains("impossible"));
assert_eq!(a.mode, TuiMode::Search);
}
#[test]
fn etat_vide_ne_panique_pas() {
let mut a = TuiApp::new(Vec::new(), TuiFilters::default(), String::new());
let mut backend = FakeBackend::ok();
a.select_next();
a.select_previous();
a.page_down();
a.page_up();
a.select_first();
a.select_last();
a.request_delete();
assert_eq!(a.mode, TuiMode::Search);
a.confirm_delete(&mut backend);
a.copy_selection();
a.select_and_quit();
assert!(a.selected_record().is_none());
assert!(a.outcome.is_none());
}
#[test]
fn refresh_recharge_les_records() {
let mut a = app();
let mut backend = FakeBackend::ok();
backend.reload_with = Some(vec![rec(99, "echo rafraichi")]);
a.refresh(&mut backend);
assert_eq!(a.records.len(), 1);
assert_eq!(a.records[0].id, 99);
}
#[test]
fn filtre_projet_depuis_selection() {
let mut a = app();
a.select_first();
a.filter_project_from_selection();
assert_eq!(a.filters.project.as_deref(), Some("mnemo"));
}
#[test]
fn select_and_quit_imprime_la_commande() {
let mut a = app();
a.select_first();
a.select_and_quit();
assert!(a.should_quit);
assert_eq!(a.outcome.as_deref(), Some("cargo build"));
}
}