1use 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
14pub const DEFAULT_PAGE_SIZE: usize = 10;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum StatusFilter {
21 #[default]
23 All,
24 Success,
26 Failure,
28}
29
30impl StatusFilter {
31 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 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#[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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct Overview {
77 pub total: usize,
79 pub visible: usize,
81 pub success: usize,
83 pub failed: usize,
85 pub projects: usize,
87 pub top_shell: Option<String>,
89}
90
91impl Overview {
92 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 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
144fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum TuiMode {
170 Search,
172 Help,
174 ConfirmDelete,
176 Filters,
178 Details,
181}
182
183pub struct TuiApp {
185 pub query: String,
187 pub records: Vec<CommandRecord>,
189 pub filtered: Vec<usize>,
191 pub selected: usize,
193 pub filters: TuiFilters,
195 pub mode: TuiMode,
197 pub status_message: Option<String>,
199 pub outcome: Option<String>,
201 pub should_quit: bool,
203 pub copy_buffer: Option<String>,
205 pub page_size: usize,
207 pub current_project: Option<String>,
209 pub current_branch: Option<String>,
211 matcher: Matcher,
212}
213
214impl TuiApp {
215 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 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 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 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 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 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 pub fn selected_record(&self) -> Option<&CommandRecord> {
359 self.filtered
360 .get(self.selected)
361 .map(|&idx| &self.records[idx])
362 }
363
364 pub fn selected_id(&self) -> Option<i64> {
366 self.selected_record().map(|r| r.id)
367 }
368
369 pub fn overview(&self) -> Overview {
373 Overview::compute(&self.records, &self.filtered)
374 }
375
376 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 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 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 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 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 pub fn focus_search(&mut self) {
492 self.mode = TuiMode::Search;
493 }
494
495 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 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 pub fn cancel_delete(&mut self) {
544 self.mode = TuiMode::Search;
545 self.status_message = Some("Suppression annulée.".to_string());
546 }
547
548 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 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 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 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 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
660pub 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 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 assert_eq!(ov.projects, 2);
767 assert_eq!(ov.top_shell.as_deref(), Some("bash"));
768 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(); 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 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 let mut records: Vec<CommandRecord> = (0..1241)
843 .map(|i| {
844 let mut r = rec(i, "legacy");
845 r.exit_code = None; r
847 })
848 .collect();
849 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 let mut records = sample();
879 records[2].shell = Some("zsh".to_string()); 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); }
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 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 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}