Skip to main content

mnemo/tui/
app.rs

1//! Modèle interne de la TUI et logique métier testable (sans terminal).
2//!
3//! Tout l'état interactif vit dans [`TuiApp`]. Les méthodes de navigation et de
4//! filtrage sont pures (aucune dépendance au terminal ni à la base) afin d'être
5//! testées unitairement. Les actions touchant la base passent par le trait
6//! [`TuiBackend`], ce qui permet de les simuler dans les tests.
7
8use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
9use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str};
10
11use crate::db::CommandRecord;
12use crate::tui::actions::{Action, TuiBackend};
13
14/// Taille de page par défaut pour PageUp / PageDown (ajustée au rendu selon la
15/// hauteur réelle de la liste).
16pub const DEFAULT_PAGE_SIZE: usize = 10;
17
18/// Filtre sur le code de sortie des commandes.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum StatusFilter {
21    /// Toutes les commandes.
22    #[default]
23    All,
24    /// Uniquement les succès (`exit_code == 0`).
25    Success,
26    /// Uniquement les échecs (`exit_code` présent et ≠ 0).
27    Failure,
28}
29
30impl StatusFilter {
31    /// Fait défiler All -> Success -> Failure -> All.
32    pub fn next(self) -> Self {
33        match self {
34            StatusFilter::All => StatusFilter::Success,
35            StatusFilter::Success => StatusFilter::Failure,
36            StatusFilter::Failure => StatusFilter::All,
37        }
38    }
39
40    /// Libellé court pour l'affichage.
41    pub fn label(self) -> &'static str {
42        match self {
43            StatusFilter::All => "tous",
44            StatusFilter::Success => "succès",
45            StatusFilter::Failure => "échecs",
46        }
47    }
48}
49
50/// Filtres interactifs appliqués en plus de la recherche fuzzy.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct TuiFilters {
53    pub project: Option<String>,
54    pub branch: Option<String>,
55    pub cwd: Option<String>,
56    pub status: StatusFilter,
57}
58
59impl TuiFilters {
60    /// Vrai si aucun filtre n'est actif.
61    pub fn is_empty(&self) -> bool {
62        self.project.is_none()
63            && self.branch.is_none()
64            && self.cwd.is_none()
65            && self.status == StatusFilter::All
66    }
67}
68
69/// Indicateurs agrégés affichés dans la barre de synthèse (« overview »).
70///
71/// Tous les indicateurs hormis `total` sont calculés sur les commandes
72/// **visibles** (après recherche et filtres). `total` reflète l'ensemble
73/// chargé en mémoire avant filtres. Les compteurs sont cohérents :
74/// `success + failed == visible`.
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct Overview {
77    /// Nombre total de commandes chargées (avant filtres).
78    pub total: usize,
79    /// Commandes visibles après filtres et recherche.
80    pub visible: usize,
81    /// Visibles n'ayant pas échoué (`exit_code == 0` ou inconnu).
82    pub success: usize,
83    /// Visibles terminées en échec (`exit_code` présent et ≠ 0).
84    pub failed: usize,
85    /// Projets Git distincts parmi les commandes visibles.
86    pub projects: usize,
87    /// Shell dominant parmi les commandes visibles, le cas échéant.
88    pub top_shell: Option<String>,
89}
90
91impl Overview {
92    /// Calcule les indicateurs à partir des commandes et des indices visibles.
93    ///
94    /// Seuls les indices présents dans `filtered` (les commandes visibles)
95    /// alimentent `visible`, `success`, `failed`, `projects` et `top_shell`.
96    /// `total` reste l'ensemble chargé. Un échec est une commande dont le
97    /// `exit_code` est présent et différent de zéro ; tout le reste (succès
98    /// explicite ou statut inconnu) compte comme succès, de sorte que
99    /// `success + failed == visible`.
100    pub fn compute(records: &[CommandRecord], filtered: &[usize]) -> Self {
101        let visible = filtered.len();
102
103        let mut failed = 0usize;
104        for &idx in filtered {
105            if let Some(code) = records[idx].exit_code {
106                if code != 0 {
107                    failed += 1;
108                }
109            }
110        }
111        let success = visible - failed;
112
113        let mut projects: Vec<&str> = filtered
114            .iter()
115            .filter_map(|&idx| records[idx].git_root.as_deref().filter(|s| !s.is_empty()))
116            .map(last_segment)
117            .collect();
118        projects.sort_unstable();
119        projects.dedup();
120
121        let top_shell = dominant_shell(filtered.iter().map(|&idx| &records[idx]));
122
123        Self {
124            total: records.len(),
125            visible,
126            success,
127            failed,
128            projects: projects.len(),
129            top_shell,
130        }
131    }
132
133    /// Taux d'échec parmi les commandes **visibles** (0.0 à 100.0).
134    /// Renvoie `0.0` si aucune commande n'est visible.
135    pub fn failure_rate(&self) -> f64 {
136        if self.visible == 0 {
137            0.0
138        } else {
139            (self.failed as f64 / self.visible as f64) * 100.0
140        }
141    }
142}
143
144/// Détermine le shell le plus fréquent parmi les commandes fournies (ignorant
145/// les valeurs absentes). En cas d'égalité, renvoie l'un des shells à fréquence
146/// maximale de façon déterministe pour un même jeu de données.
147fn dominant_shell<'a>(records: impl Iterator<Item = &'a CommandRecord>) -> Option<String> {
148    use std::collections::HashMap;
149    let mut counts: HashMap<&str, usize> = HashMap::new();
150    let mut order: Vec<&str> = Vec::new();
151    for r in records {
152        if let Some(shell) = r.shell.as_deref().filter(|s| !s.is_empty()) {
153            let entry = counts.entry(shell).or_insert(0);
154            if *entry == 0 {
155                order.push(shell);
156            }
157            *entry += 1;
158        }
159    }
160    order
161        .into_iter()
162        .max_by_key(|shell| counts[shell])
163        .map(|s| s.to_string())
164}
165
166/// Mode courant de l'interface (détermine le rendu et l'interprétation des
167/// touches).
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum TuiMode {
170    /// Saisie de recherche (la frappe édite la requête).
171    Search,
172    /// Aide plein écran.
173    Help,
174    /// Confirmation de suppression.
175    ConfirmDelete,
176    /// Panneau de filtres.
177    Filters,
178    /// Focus sur la liste/les détails (raccourcis vim, la frappe ne modifie pas
179    /// la requête).
180    Details,
181}
182
183/// État complet de la TUI.
184pub struct TuiApp {
185    /// Requête de recherche courante.
186    pub query: String,
187    /// Toutes les commandes chargées en mémoire.
188    pub records: Vec<CommandRecord>,
189    /// Indices (dans `records`) retenus après filtres + recherche.
190    pub filtered: Vec<usize>,
191    /// Position sélectionnée dans `filtered`.
192    pub selected: usize,
193    /// Filtres interactifs actifs.
194    pub filters: TuiFilters,
195    /// Mode courant.
196    pub mode: TuiMode,
197    /// Message de statut transitoire affiché en pied de page.
198    pub status_message: Option<String>,
199    /// Commande retenue par l'utilisateur (imprimée à la sortie).
200    pub outcome: Option<String>,
201    /// Demande de sortie de la boucle d'événements.
202    pub should_quit: bool,
203    /// Dernière commande mise dans le tampon interne (raccourci `c`).
204    pub copy_buffer: Option<String>,
205    /// Taille de page courante (mise à jour au rendu).
206    pub page_size: usize,
207    /// Projet du dossier de lancement (pour le filtre `p`).
208    pub current_project: Option<String>,
209    /// Branche du dossier de lancement (pour le filtre `b`).
210    pub current_branch: Option<String>,
211    matcher: Matcher,
212}
213
214impl TuiApp {
215    /// Construit l'application à partir des commandes chargées, de filtres
216    /// initiaux et d'une requête initiale.
217    pub fn new(records: Vec<CommandRecord>, filters: TuiFilters, query: String) -> Self {
218        let mut app = Self {
219            query,
220            records,
221            filtered: Vec::new(),
222            selected: 0,
223            filters,
224            mode: TuiMode::Search,
225            status_message: None,
226            outcome: None,
227            should_quit: false,
228            copy_buffer: None,
229            page_size: DEFAULT_PAGE_SIZE,
230            current_project: None,
231            current_branch: None,
232            matcher: Matcher::new(NucleoConfig::DEFAULT),
233        };
234        app.recompute();
235        app
236    }
237
238    /// Renseigne le projet et la branche du dossier de lancement, utilisés par
239    /// les raccourcis « filtre projet/branche courant(e) » (`p` / `b`).
240    pub fn set_current_context(&mut self, project: Option<String>, branch: Option<String>) {
241        self.current_project = project;
242        self.current_branch = branch;
243    }
244
245    // -- Filtrage et recherche ---------------------------------------------
246
247    /// Vrai si `r` satisfait les filtres structurés actifs.
248    fn matches_filters(&self, r: &CommandRecord) -> bool {
249        if let Some(project) = &self.filters.project {
250            let ok = match &r.git_root {
251                Some(root) => root == project || last_segment(root) == project.as_str(),
252                None => false,
253            };
254            if !ok {
255                return false;
256            }
257        }
258        if let Some(branch) = &self.filters.branch {
259            if r.git_branch.as_deref() != Some(branch.as_str()) {
260                return false;
261            }
262        }
263        if let Some(cwd) = &self.filters.cwd {
264            if r.cwd.as_deref() != Some(cwd.as_str()) {
265                return false;
266            }
267        }
268        match self.filters.status {
269            StatusFilter::All => {}
270            StatusFilter::Success => {
271                if r.exit_code != Some(0) {
272                    return false;
273                }
274            }
275            StatusFilter::Failure => match r.exit_code {
276                Some(code) if code != 0 => {}
277                _ => return false,
278            },
279        }
280        true
281    }
282
283    /// Recalcule la liste filtrée (filtres structurés puis recherche fuzzy) et
284    /// borne la sélection pour qu'elle reste valide.
285    pub fn recompute(&mut self) {
286        let candidates: Vec<usize> = (0..self.records.len())
287            .filter(|&i| self.matches_filters(&self.records[i]))
288            .collect();
289
290        self.filtered = if self.query.trim().is_empty() {
291            candidates
292        } else {
293            let pattern = Pattern::parse(
294                self.query.trim(),
295                CaseMatching::Ignore,
296                Normalization::Smart,
297            );
298            let mut buf: Vec<char> = Vec::new();
299            let mut scored: Vec<(usize, u32)> = candidates
300                .into_iter()
301                .filter_map(|i| {
302                    let haystack = Utf32Str::new(&self.records[i].command, &mut buf);
303                    pattern.score(haystack, &mut self.matcher).map(|s| (i, s))
304                })
305                .collect();
306            scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
307            scored.into_iter().map(|(i, _)| i).collect()
308        };
309
310        self.clamp_selection();
311    }
312
313    /// Garde `selected` dans les bornes de `filtered`.
314    fn clamp_selection(&mut self) {
315        if self.filtered.is_empty() {
316            self.selected = 0;
317        } else if self.selected >= self.filtered.len() {
318            self.selected = self.filtered.len() - 1;
319        }
320    }
321
322    // -- Navigation (bornée) -----------------------------------------------
323
324    pub fn select_next(&mut self) {
325        if !self.filtered.is_empty() && self.selected + 1 < self.filtered.len() {
326            self.selected += 1;
327        }
328    }
329
330    pub fn select_previous(&mut self) {
331        self.selected = self.selected.saturating_sub(1);
332    }
333
334    pub fn page_down(&mut self) {
335        if self.filtered.is_empty() {
336            return;
337        }
338        self.selected = (self.selected + self.page_size).min(self.filtered.len() - 1);
339    }
340
341    pub fn page_up(&mut self) {
342        self.selected = self.selected.saturating_sub(self.page_size);
343    }
344
345    pub fn select_first(&mut self) {
346        self.selected = 0;
347    }
348
349    pub fn select_last(&mut self) {
350        if !self.filtered.is_empty() {
351            self.selected = self.filtered.len() - 1;
352        }
353    }
354
355    // -- Sélection ---------------------------------------------------------
356
357    /// Référence vers la commande sélectionnée, le cas échéant.
358    pub fn selected_record(&self) -> Option<&CommandRecord> {
359        self.filtered
360            .get(self.selected)
361            .map(|&idx| &self.records[idx])
362    }
363
364    /// Identifiant de la commande sélectionnée.
365    pub fn selected_id(&self) -> Option<i64> {
366        self.selected_record().map(|r| r.id)
367    }
368
369    /// Calcule les indicateurs (KPI) affichés dans la barre de synthèse :
370    /// total chargé, visibles après filtres, succès / échecs visibles, taux
371    /// d'échec, nombre de projets distincts et shell dominant.
372    pub fn overview(&self) -> Overview {
373        Overview::compute(&self.records, &self.filtered)
374    }
375
376    // -- Édition de la requête ---------------------------------------------
377
378    pub fn push_query_char(&mut self, c: char) {
379        self.query.push(c);
380        self.recompute();
381    }
382
383    pub fn pop_query_char(&mut self) {
384        self.query.pop();
385        self.recompute();
386    }
387
388    // -- Bascules de mode --------------------------------------------------
389
390    pub fn toggle_help(&mut self) {
391        self.mode = if self.mode == TuiMode::Help {
392            TuiMode::Search
393        } else {
394            TuiMode::Help
395        };
396    }
397
398    pub fn toggle_filters(&mut self) {
399        self.mode = if self.mode == TuiMode::Filters {
400            TuiMode::Search
401        } else {
402            TuiMode::Filters
403        };
404    }
405
406    pub fn toggle_details_focus(&mut self) {
407        self.mode = if self.mode == TuiMode::Details {
408            TuiMode::Search
409        } else {
410            TuiMode::Details
411        };
412    }
413
414    // -- Filtres -----------------------------------------------------------
415
416    pub fn filter_project_from_selection(&mut self) {
417        match self.selected_record().and_then(|r| r.git_root.clone()) {
418            Some(root) => {
419                let name = last_segment(&root).to_string();
420                self.filters.project = Some(name.clone());
421                self.status_message = Some(format!("Filtre projet = {name}"));
422            }
423            None => self.status_message = Some("Sélection sans projet Git.".to_string()),
424        }
425        self.recompute();
426    }
427
428    pub fn filter_branch_from_selection(&mut self) {
429        match self.selected_record().and_then(|r| r.git_branch.clone()) {
430            Some(branch) => {
431                self.filters.branch = Some(branch.clone());
432                self.status_message = Some(format!("Filtre branche = {branch}"));
433            }
434            None => self.status_message = Some("Sélection sans branche Git.".to_string()),
435        }
436        self.recompute();
437    }
438
439    pub fn filter_cwd_from_selection(&mut self) {
440        match self.selected_record().and_then(|r| r.cwd.clone()) {
441            Some(cwd) => {
442                self.filters.cwd = Some(cwd.clone());
443                self.status_message = Some(format!("Filtre dossier = {cwd}"));
444            }
445            None => self.status_message = Some("Sélection sans répertoire.".to_string()),
446        }
447        self.recompute();
448    }
449
450    pub fn cycle_status_filter(&mut self) {
451        self.filters.status = self.filters.status.next();
452        self.status_message = Some(format!("Statut = {}", self.filters.status.label()));
453        self.recompute();
454    }
455
456    pub fn clear_filters(&mut self) {
457        self.filters = TuiFilters::default();
458        self.status_message = Some("Filtres réinitialisés.".to_string());
459        self.recompute();
460    }
461
462    /// Filtre par le projet du dossier de lancement (raccourci `p`).
463    pub fn filter_project_current(&mut self) {
464        match &self.current_project {
465            Some(project) => {
466                self.filters.project = Some(project.clone());
467                self.status_message = Some(format!("Filtre projet courant = {project}"));
468            }
469            None => {
470                self.status_message = Some("Projet courant indéterminé.".to_string());
471            }
472        }
473        self.recompute();
474    }
475
476    /// Filtre par la branche du dossier de lancement (raccourci `b`).
477    pub fn filter_branch_current(&mut self) {
478        match &self.current_branch {
479            Some(branch) => {
480                self.filters.branch = Some(branch.clone());
481                self.status_message = Some(format!("Filtre branche courante = {branch}"));
482            }
483            None => {
484                self.status_message = Some("Branche courante indéterminée.".to_string());
485            }
486        }
487        self.recompute();
488    }
489
490    /// Donne le focus à la recherche (raccourci `/`).
491    pub fn focus_search(&mut self) {
492        self.mode = TuiMode::Search;
493    }
494
495    /// Exporte les résultats filtrés vers un fichier JSON (raccourci `e`).
496    ///
497    /// Le fichier est créé dans le répertoire courant sous un nom horodaté. En
498    /// cas d'erreur, l'export est signalé dans le pied de page sans interrompre
499    /// la TUI.
500    pub fn export_results(&mut self) {
501        if self.filtered.is_empty() {
502            self.status_message = Some("Aucun résultat à exporter.".to_string());
503            return;
504        }
505        let refs: Vec<&CommandRecord> = self.filtered.iter().map(|&i| &self.records[i]).collect();
506        let json = match crate::export::records_to_json(&refs) {
507            Ok(j) => j,
508            Err(e) => {
509                self.status_message = Some(format!("Export impossible : {e}"));
510                return;
511            }
512        };
513        let stamp: String = crate::db::now_timestamp()
514            .chars()
515            .filter(|c| c.is_ascii_digit())
516            .collect();
517        let filename = format!("mnemo-export-{stamp}.json");
518        match std::fs::write(&filename, json) {
519            Ok(()) => {
520                self.status_message = Some(format!(
521                    "{} commande(s) exportée(s) vers {filename}",
522                    refs.len()
523                ));
524            }
525            Err(e) => {
526                self.status_message = Some(format!("Écriture de l'export impossible : {e}"));
527            }
528        }
529    }
530
531    // -- Suppression -------------------------------------------------------
532
533    /// Passe en mode confirmation si une commande est sélectionnée.
534    pub fn request_delete(&mut self) {
535        if self.selected_id().is_some() {
536            self.mode = TuiMode::ConfirmDelete;
537        } else {
538            self.status_message = Some("Aucune commande à supprimer.".to_string());
539        }
540    }
541
542    /// Annule la confirmation de suppression.
543    pub fn cancel_delete(&mut self) {
544        self.mode = TuiMode::Search;
545        self.status_message = Some("Suppression annulée.".to_string());
546    }
547
548    /// Confirme la suppression : crée un backup puis supprime via le backend.
549    /// En cas d'échec du backend (ex. backup impossible), rien n'est retiré.
550    pub fn confirm_delete<B: TuiBackend>(&mut self, backend: &mut B) {
551        let id = match self.selected_id() {
552            Some(id) => id,
553            None => {
554                self.mode = TuiMode::Search;
555                return;
556            }
557        };
558        match backend.backup_and_delete(id) {
559            Ok(()) => {
560                self.records.retain(|r| r.id != id);
561                self.recompute();
562                self.status_message = Some(format!("Commande #{id} supprimée (sauvegarde créée)."));
563            }
564            Err(e) => {
565                self.status_message = Some(format!(
566                    "Suppression impossible : {e}. Aucune donnée modifiée."
567                ));
568            }
569        }
570        self.mode = TuiMode::Search;
571    }
572
573    // -- Copie -------------------------------------------------------------
574
575    /// Met la commande sélectionnée dans le tampon interne et tente une copie
576    /// vers le presse-papiers système (sans dépendance obligatoire).
577    pub fn copy_selection(&mut self) {
578        let cmd = match self.selected_record() {
579            Some(r) => r.command.clone(),
580            None => {
581                self.status_message = Some("Aucune commande à copier.".to_string());
582                return;
583            }
584        };
585        self.copy_buffer = Some(cmd.clone());
586        match crate::tui::clipboard::copy_to_clipboard(&cmd) {
587            Ok(true) => {
588                self.status_message = Some("Commande copiée dans le presse-papiers.".to_string());
589            }
590            _ => {
591                self.status_message = Some(
592                    "Presse-papiers indisponible ; utilisez Entrée pour imprimer la commande."
593                        .to_string(),
594                );
595            }
596        }
597    }
598
599    // -- Rafraîchissement et sélection -------------------------------------
600
601    /// Recharge les commandes depuis le backend (raccourci `r`).
602    pub fn refresh<B: TuiBackend>(&mut self, backend: &mut B) {
603        match backend.reload() {
604            Ok(records) => {
605                self.records = records;
606                self.recompute();
607                self.status_message = Some("Résultats rafraîchis.".to_string());
608            }
609            Err(e) => {
610                self.status_message = Some(format!("Rafraîchissement impossible : {e}"));
611            }
612        }
613    }
614
615    /// Valide la sélection : retient la commande et demande la sortie.
616    pub fn select_and_quit(&mut self) {
617        if let Some(r) = self.selected_record() {
618            self.outcome = Some(r.command.clone());
619        }
620        self.should_quit = true;
621    }
622
623    // -- Dispatch ----------------------------------------------------------
624
625    /// Applique une action issue du clavier.
626    pub fn dispatch<B: TuiBackend>(&mut self, action: Action, backend: &mut B) {
627        match action {
628            Action::Quit => self.should_quit = true,
629            Action::Select => self.select_and_quit(),
630            Action::Up => self.select_previous(),
631            Action::Down => self.select_next(),
632            Action::PageUp => self.page_up(),
633            Action::PageDown => self.page_down(),
634            Action::Home => self.select_first(),
635            Action::End => self.select_last(),
636            Action::Backspace => self.pop_query_char(),
637            Action::Input(c) => self.push_query_char(c),
638            Action::ToggleHelp => self.toggle_help(),
639            Action::ToggleFilters => self.toggle_filters(),
640            Action::ToggleDetailsFocus => self.toggle_details_focus(),
641            Action::FocusSearch => self.focus_search(),
642            Action::Refresh => self.refresh(backend),
643            Action::Copy => self.copy_selection(),
644            Action::ExportResults => self.export_results(),
645            Action::RequestDelete => self.request_delete(),
646            Action::ConfirmYes => self.confirm_delete(backend),
647            Action::ConfirmNo => self.cancel_delete(),
648            Action::FilterProjectFromSelection => self.filter_project_from_selection(),
649            Action::FilterBranchFromSelection => self.filter_branch_from_selection(),
650            Action::FilterCwdFromSelection => self.filter_cwd_from_selection(),
651            Action::FilterProjectCurrent => self.filter_project_current(),
652            Action::FilterBranchCurrent => self.filter_branch_current(),
653            Action::CycleStatusFilter => self.cycle_status_filter(),
654            Action::ClearFilters => self.clear_filters(),
655            Action::None => {}
656        }
657    }
658}
659
660/// Dernier segment d'un chemin (`/a/b/c` -> `c`).
661pub fn last_segment(path: &str) -> &str {
662    path.trim_end_matches('/')
663        .rsplit('/')
664        .next()
665        .unwrap_or(path)
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::db::CommandRecord;
672    use crate::tui::actions::TuiBackend;
673    use anyhow::Result;
674
675    /// Backend factice : enregistre les suppressions et peut simuler un échec.
676    struct FakeBackend {
677        fail: bool,
678        deleted: Vec<i64>,
679        reload_with: Option<Vec<CommandRecord>>,
680    }
681
682    impl FakeBackend {
683        fn ok() -> Self {
684            Self {
685                fail: false,
686                deleted: Vec::new(),
687                reload_with: None,
688            }
689        }
690        fn failing() -> Self {
691            Self {
692                fail: true,
693                deleted: Vec::new(),
694                reload_with: None,
695            }
696        }
697    }
698
699    impl TuiBackend for FakeBackend {
700        fn backup_and_delete(&mut self, id: i64) -> Result<()> {
701            if self.fail {
702                anyhow::bail!("backup simulé en échec");
703            }
704            self.deleted.push(id);
705            Ok(())
706        }
707        fn reload(&mut self) -> Result<Vec<CommandRecord>> {
708            Ok(self.reload_with.clone().unwrap_or_default())
709        }
710    }
711
712    fn rec(id: i64, command: &str) -> CommandRecord {
713        CommandRecord {
714            id,
715            command: command.to_string(),
716            cwd: Some("/home/user".to_string()),
717            shell: Some("bash".to_string()),
718            hostname: Some("host".to_string()),
719            exit_code: Some(0),
720            created_at: "2026-06-14 10:00:00".to_string(),
721            git_root: None,
722            git_branch: None,
723            git_remote: None,
724            session_id: None,
725        }
726    }
727
728    fn with_git(mut r: CommandRecord, root: &str, branch: &str) -> CommandRecord {
729        r.git_root = Some(root.to_string());
730        r.git_branch = Some(branch.to_string());
731        r
732    }
733
734    fn sample() -> Vec<CommandRecord> {
735        vec![
736            with_git(rec(1, "cargo build"), "/home/user/mnemo", "main"),
737            with_git(rec(2, "cargo test"), "/home/user/mnemo", "dev"),
738            with_git(rec(3, "git status"), "/home/user/other", "main"),
739            {
740                let mut r = rec(4, "ls -la");
741                r.cwd = Some("/tmp".to_string());
742                r.exit_code = Some(1);
743                r
744            },
745            {
746                let mut r = rec(5, "false");
747                r.exit_code = Some(2);
748                r
749            },
750        ]
751    }
752
753    fn app() -> TuiApp {
754        TuiApp::new(sample(), TuiFilters::default(), String::new())
755    }
756
757    #[test]
758    fn overview_compte_succes_echecs_projets_et_shell() {
759        let a = app();
760        let ov = a.overview();
761        assert_eq!(ov.total, 5);
762        assert_eq!(ov.visible, 5);
763        assert_eq!(ov.success, 3, "3 commandes en succès");
764        assert_eq!(ov.failed, 2, "2 commandes en échec");
765        // git_root distincts : mnemo + other.
766        assert_eq!(ov.projects, 2);
767        assert_eq!(ov.top_shell.as_deref(), Some("bash"));
768        // 2 échecs sur 5 visibles = 40 %.
769        assert!((ov.failure_rate() - 40.0).abs() < 1e-9);
770    }
771
772    #[test]
773    fn overview_taux_echec_nul_sans_commande_executee() {
774        let ov = Overview::compute(&[], &[]);
775        assert_eq!(ov.total, 0);
776        assert_eq!(ov.visible, 0);
777        assert_eq!(ov.success, 0);
778        assert_eq!(ov.failed, 0);
779        assert_eq!(ov.projects, 0);
780        assert_eq!(ov.failure_rate(), 0.0);
781        assert!(ov.top_shell.is_none());
782    }
783
784    #[test]
785    fn overview_suit_les_filtres_visibles() {
786        let mut a = app();
787        a.cycle_status_filter(); // -> succès uniquement
788        let ov = a.overview();
789        assert_eq!(ov.total, 5, "le total reste l'ensemble chargé");
790        assert_eq!(ov.visible, 3, "seuls les succès sont visibles");
791        assert_eq!(ov.failed, 0);
792        assert_eq!(ov.success, 3);
793        assert!((ov.failure_rate() - 0.0).abs() < 1e-9);
794    }
795
796    #[test]
797    fn overview_taux_echec_huit_succes_deux_echecs() {
798        // 10 commandes : 8 succès, 2 échecs -> 20.0 %.
799        let mut records: Vec<CommandRecord> = (0..10).map(|i| rec(i, "cmd")).collect();
800        records[3].exit_code = Some(1);
801        records[7].exit_code = Some(130);
802        let all: Vec<usize> = (0..records.len()).collect();
803        let ov = Overview::compute(&records, &all);
804        assert_eq!(ov.visible, 10);
805        assert_eq!(ov.success, 8);
806        assert_eq!(ov.failed, 2);
807        assert!((ov.failure_rate() - 20.0).abs() < 1e-9);
808    }
809
810    #[test]
811    fn overview_toutes_en_echec_donne_100_pour_cent() {
812        let records: Vec<CommandRecord> = (0..4)
813            .map(|i| {
814                let mut r = rec(i, "boom");
815                r.exit_code = Some(1);
816                r
817            })
818            .collect();
819        let all: Vec<usize> = (0..records.len()).collect();
820        let ov = Overview::compute(&records, &all);
821        assert_eq!(ov.success, 0);
822        assert_eq!(ov.failed, 4);
823        assert!((ov.failure_rate() - 100.0).abs() < 1e-9);
824    }
825
826    #[test]
827    fn overview_toutes_en_succes_donne_0_pour_cent() {
828        let records: Vec<CommandRecord> = (0..4).map(|i| rec(i, "ok")).collect();
829        let all: Vec<usize> = (0..records.len()).collect();
830        let ov = Overview::compute(&records, &all);
831        assert_eq!(ov.success, 4);
832        assert_eq!(ov.failed, 0);
833        assert!((ov.failure_rate() - 0.0).abs() < 1e-9);
834    }
835
836    #[test]
837    fn overview_exit_inconnu_compte_comme_succes() {
838        // Régression : un grand nombre de statuts inconnus (NULL en base) ne
839        // doit pas faire chuter « Succès » ni gonfler le taux d'échec. Le taux
840        // se calcule sur l'ensemble visible, pas seulement les commandes au
841        // statut connu.
842        let mut records: Vec<CommandRecord> = (0..1241)
843            .map(|i| {
844                let mut r = rec(i, "legacy");
845                r.exit_code = None; // statut inconnu (NULL)
846                r
847            })
848            .collect();
849        // 6 échecs avérés, 75 succès explicites, le reste inconnu.
850        for r in records.iter_mut().take(6) {
851            r.exit_code = Some(1);
852        }
853        for r in records.iter_mut().skip(6).take(75) {
854            r.exit_code = Some(0);
855        }
856        let all: Vec<usize> = (0..records.len()).collect();
857        let ov = Overview::compute(&records, &all);
858        assert_eq!(ov.total, 1241);
859        assert_eq!(ov.visible, 1241);
860        assert_eq!(ov.failed, 6);
861        assert_eq!(
862            ov.success, 1235,
863            "succès = visibles - échecs (inconnu = succès)"
864        );
865        assert_eq!(
866            ov.success + ov.failed,
867            ov.visible,
868            "succès + échecs == visibles"
869        );
870        assert!((ov.failure_rate() - (6.0 / 1241.0 * 100.0)).abs() < 1e-9);
871        assert!(ov.failure_rate() < 1.0, "taux ~0,5 % et non ~7,4 %");
872    }
873
874    #[test]
875    fn overview_projets_et_shell_sur_visibles_seulement() {
876        // Un filtre qui ne laisse voir que le projet « other » ne doit compter
877        // qu'un seul projet et le shell des commandes visibles.
878        let mut records = sample();
879        records[2].shell = Some("zsh".to_string()); // git status, projet other
880                                                    // Visibles : uniquement l'index 2 (projet other, shell zsh).
881        let ov = Overview::compute(&records, &[2]);
882        assert_eq!(ov.total, 5, "total inchangé");
883        assert_eq!(ov.visible, 1);
884        assert_eq!(ov.projects, 1, "un seul projet visible");
885        assert_eq!(ov.top_shell.as_deref(), Some("zsh"));
886    }
887
888    #[test]
889    fn overview_sans_shell_ni_projet_ne_panique_pas() {
890        let mut records: Vec<CommandRecord> = (0..3).map(|i| rec(i, "x")).collect();
891        for r in records.iter_mut() {
892            r.shell = None;
893            r.git_root = None;
894            r.exit_code = Some(-1); // code atypique
895        }
896        let all: Vec<usize> = (0..records.len()).collect();
897        let ov = Overview::compute(&records, &all);
898        assert_eq!(ov.projects, 0);
899        assert!(ov.top_shell.is_none());
900        assert_eq!(ov.failed, 3, "exit_code négatif compte comme échec");
901        assert!((ov.failure_rate() - 100.0).abs() < 1e-9);
902    }
903
904    #[test]
905    fn navigation_haut_bas_bornee() {
906        let mut a = app();
907        assert_eq!(a.selected, 0);
908        a.select_previous();
909        assert_eq!(a.selected, 0, "ne descend pas sous 0");
910        a.select_next();
911        assert_eq!(a.selected, 1);
912        // Va jusqu'au bout puis bute.
913        for _ in 0..20 {
914            a.select_next();
915        }
916        assert_eq!(a.selected, a.filtered.len() - 1);
917    }
918
919    #[test]
920    fn page_up_down_bornees() {
921        let mut a = app();
922        a.page_size = 2;
923        a.page_down();
924        assert_eq!(a.selected, 2);
925        a.page_down();
926        assert_eq!(a.selected, 4);
927        a.page_down();
928        assert_eq!(a.selected, a.filtered.len() - 1);
929        a.page_up();
930        assert_eq!(a.selected, 2);
931        a.page_up();
932        a.page_up();
933        assert_eq!(a.selected, 0);
934    }
935
936    #[test]
937    fn home_end() {
938        let mut a = app();
939        a.select_last();
940        assert_eq!(a.selected, a.filtered.len() - 1);
941        a.select_first();
942        assert_eq!(a.selected, 0);
943    }
944
945    #[test]
946    fn filtrage_par_projet() {
947        let mut a = app();
948        a.filters.project = Some("mnemo".to_string());
949        a.recompute();
950        assert_eq!(a.filtered.len(), 2);
951        assert!(a
952            .filtered
953            .iter()
954            .all(|&i| a.records[i].git_root.as_deref() == Some("/home/user/mnemo")));
955    }
956
957    #[test]
958    fn filtrage_par_branche() {
959        let mut a = app();
960        a.filters.branch = Some("main".to_string());
961        a.recompute();
962        assert_eq!(a.filtered.len(), 2);
963        assert!(a
964            .filtered
965            .iter()
966            .all(|&i| a.records[i].git_branch.as_deref() == Some("main")));
967    }
968
969    #[test]
970    fn filtrage_par_cwd() {
971        let mut a = app();
972        a.filters.cwd = Some("/tmp".to_string());
973        a.recompute();
974        assert_eq!(a.filtered.len(), 1);
975        assert_eq!(a.records[a.filtered[0]].command, "ls -la");
976    }
977
978    #[test]
979    fn filtrage_failed_et_success() {
980        let mut a = app();
981        a.filters.status = StatusFilter::Failure;
982        a.recompute();
983        assert_eq!(a.filtered.len(), 2, "ls -la (1) et false (2)");
984        a.filters.status = StatusFilter::Success;
985        a.recompute();
986        assert_eq!(a.filtered.len(), 3);
987    }
988
989    #[test]
990    fn clear_filters_reinitialise() {
991        let mut a = app();
992        a.filters.project = Some("mnemo".to_string());
993        a.filters.status = StatusFilter::Failure;
994        a.recompute();
995        a.clear_filters();
996        assert!(a.filters.is_empty());
997        assert_eq!(a.filtered.len(), a.records.len());
998    }
999
1000    #[test]
1001    fn recherche_conserve_une_selection_valide() {
1002        let mut a = app();
1003        a.select_last();
1004        let before = a.selected;
1005        assert!(before > 0);
1006        a.push_query_char('c');
1007        a.push_query_char('a');
1008        // "ca" matche cargo build/test : la sélection reste dans les bornes.
1009        assert!(a.selected < a.filtered.len());
1010        assert!(!a.filtered.is_empty());
1011    }
1012
1013    #[test]
1014    fn suppression_confirmee_retire_l_element() {
1015        let mut a = app();
1016        let mut backend = FakeBackend::ok();
1017        a.select_first();
1018        let id = a.selected_id().unwrap();
1019        let before = a.records.len();
1020        a.request_delete();
1021        assert_eq!(a.mode, TuiMode::ConfirmDelete);
1022        a.confirm_delete(&mut backend);
1023        assert_eq!(a.records.len(), before - 1);
1024        assert!(a.records.iter().all(|r| r.id != id));
1025        assert_eq!(backend.deleted, vec![id]);
1026        assert_eq!(a.mode, TuiMode::Search);
1027    }
1028
1029    #[test]
1030    fn suppression_annulee_ne_modifie_rien() {
1031        let mut a = app();
1032        let before = a.records.len();
1033        a.request_delete();
1034        a.cancel_delete();
1035        assert_eq!(a.records.len(), before);
1036        assert_eq!(a.mode, TuiMode::Search);
1037    }
1038
1039    #[test]
1040    fn suppression_echoue_ne_retire_rien() {
1041        let mut a = app();
1042        let mut backend = FakeBackend::failing();
1043        let before = a.records.len();
1044        a.request_delete();
1045        a.confirm_delete(&mut backend);
1046        assert_eq!(
1047            a.records.len(),
1048            before,
1049            "rien n'est supprimé si le backup échoue"
1050        );
1051        assert!(a.status_message.as_deref().unwrap().contains("impossible"));
1052        assert_eq!(a.mode, TuiMode::Search);
1053    }
1054
1055    #[test]
1056    fn etat_vide_ne_panique_pas() {
1057        let mut a = TuiApp::new(Vec::new(), TuiFilters::default(), String::new());
1058        let mut backend = FakeBackend::ok();
1059        a.select_next();
1060        a.select_previous();
1061        a.page_down();
1062        a.page_up();
1063        a.select_first();
1064        a.select_last();
1065        a.request_delete();
1066        assert_eq!(a.mode, TuiMode::Search);
1067        a.confirm_delete(&mut backend);
1068        a.copy_selection();
1069        a.select_and_quit();
1070        assert!(a.selected_record().is_none());
1071        assert!(a.outcome.is_none());
1072    }
1073
1074    #[test]
1075    fn refresh_recharge_les_records() {
1076        let mut a = app();
1077        let mut backend = FakeBackend::ok();
1078        backend.reload_with = Some(vec![rec(99, "echo rafraichi")]);
1079        a.refresh(&mut backend);
1080        assert_eq!(a.records.len(), 1);
1081        assert_eq!(a.records[0].id, 99);
1082    }
1083
1084    #[test]
1085    fn filtre_projet_depuis_selection() {
1086        let mut a = app();
1087        a.select_first();
1088        a.filter_project_from_selection();
1089        assert_eq!(a.filters.project.as_deref(), Some("mnemo"));
1090    }
1091
1092    #[test]
1093    fn select_and_quit_imprime_la_commande() {
1094        let mut a = app();
1095        a.select_first();
1096        a.select_and_quit();
1097        assert!(a.should_quit);
1098        assert_eq!(a.outcome.as_deref(), Some("cargo build"));
1099    }
1100}