Skip to main content

mnemo/
stats.rs

1//! Commande `mnemo stats` : statistiques d'usage (texte ou JSON).
2//!
3//! Le calcul est isolé dans [`compute`] (fonction pure sur une tranche de
4//! [`CommandRecord`]) afin d'être testable sans base de données. Le nom des
5//! commandes est normalisé par [`normalize_command_name`] pour produire un
6//! « Top commandes » utile, débarrassé des tokens parasites (`-`, `|`, `#`…),
7//! des préfixes de variables d'environnement et des wrappers (`sudo`, `env`,
8//! `time`…).
9
10use anyhow::Result;
11use serde::Serialize;
12use std::io::Write;
13
14use crate::config;
15use crate::db::{self, CommandRecord};
16
17/// Nombre d'entrées affichées dans chaque palmarès.
18const TOP_N: usize = 10;
19
20/// Filtres appliqués au calcul (repris pour l'affichage et le JSON).
21#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
22pub struct Filters {
23    pub project: Option<String>,
24    pub branch: Option<String>,
25    pub since: Option<String>,
26}
27
28impl Filters {
29    fn is_empty(&self) -> bool {
30        self.project.is_none() && self.branch.is_none() && self.since.is_none()
31    }
32}
33
34/// Statistiques agrégées.
35#[derive(Debug, Default, Clone, PartialEq, Eq)]
36pub struct Stats {
37    pub total: usize,
38    pub git_projects: usize,
39    pub failed: usize,
40    /// Nombre de commandes non comptées dans le Top commandes (tokens parasites,
41    /// commentaires, lignes vides…).
42    pub ignored_for_top_commands: usize,
43    pub top_commands: Vec<(String, usize)>,
44    pub top_dirs: Vec<(String, usize)>,
45    pub top_projects: Vec<(String, usize)>,
46    /// Top shells utilisés (bash, zsh…).
47    pub top_shells: Vec<(String, usize)>,
48    /// Nombre de commandes par jour (`YYYY-MM-DD` → total), triées par date.
49    pub daily: Vec<(String, usize)>,
50}
51
52/// Taux d'échec (commandes en échec / total), dans `[0.0, 1.0]`.
53pub fn failure_rate(stats: &Stats) -> f64 {
54    if stats.total == 0 {
55        0.0
56    } else {
57        stats.failed as f64 / stats.total as f64
58    }
59}
60
61/// Point d'entrée de la commande.
62pub fn run(
63    project: Option<String>,
64    branch: Option<String>,
65    since: Option<String>,
66    json: bool,
67) -> Result<()> {
68    let cfg = config::Config::load()?;
69    let ignored = &cfg.stats.ignored_commands;
70    let conn = db::open(&config::db_path()?)?;
71
72    // `--project current` → nom du projet du dossier courant.
73    let project = match project {
74        Some(p) if p.eq_ignore_ascii_case("current") => crate::project::current_name(),
75        other => other,
76    };
77
78    // Borne temporelle optionnelle (durée ou date), ignorée proprement si
79    // invalide (pas de panique).
80    let since_bound = match since.as_deref() {
81        Some(spec) => match db::resolve_since(spec) {
82            Some(bound) => Some(bound),
83            None => {
84                eprintln!("Valeur --since invalide ({spec:?}) : filtre temporel ignoré.");
85                None
86            }
87        },
88        None => None,
89    };
90
91    let filter = db::QueryFilter {
92        project: project.clone(),
93        branch: branch.clone(),
94        since: since_bound,
95        ..Default::default()
96    };
97    let records = db::fetch_query(&conn, &filter, None)?;
98    let stats = compute(&records, ignored);
99    let filters = Filters {
100        project,
101        branch,
102        since,
103    };
104
105    // Sortie via un stdout verrouillé : un `BrokenPipe` (sortie pipée vers
106    // `head`, `less`…) remonte comme une erreur propre au lieu de faire
107    // paniquer `println!`.
108    let stdout = std::io::stdout();
109    let mut out = stdout.lock();
110
111    if json {
112        writeln!(out, "{}", render_json(&stats, &filters, ignored))?;
113        return Ok(());
114    }
115
116    // En mode texte, un filtre sans résultat mérite un message explicite.
117    if records.is_empty() && !filters.is_empty() {
118        writeln!(out, "Aucune commande trouvée pour ces filtres.")?;
119        return Ok(());
120    }
121
122    render_text(&mut out, &stats, &filters)?;
123    Ok(())
124}
125
126/// Calcule les statistiques à partir des commandes (fonction pure).
127///
128/// `ignored_commands` liste les noms (déjà normalisés en minuscules) à exclure
129/// du « Top commandes » ; ces commandes restent comptées dans le total et dans
130/// les autres sections, et sont ajoutées à `ignored_for_top_commands`.
131pub fn compute(records: &[CommandRecord], ignored_commands: &[String]) -> Stats {
132    use std::collections::{HashMap, HashSet};
133
134    let total = records.len();
135    let mut git_roots = HashSet::new();
136    let mut failed = 0usize;
137    let mut ignored = 0usize;
138
139    let mut cmd_counts: HashMap<String, usize> = HashMap::new();
140    let mut dir_counts: HashMap<String, usize> = HashMap::new();
141    let mut proj_counts: HashMap<String, usize> = HashMap::new();
142    let mut shell_counts: HashMap<String, usize> = HashMap::new();
143    let mut day_counts: HashMap<String, usize> = HashMap::new();
144
145    for r in records {
146        match normalize_command_name(&r.command) {
147            Some(name) if is_ignored(&name, ignored_commands) => ignored += 1,
148            Some(name) => *cmd_counts.entry(name).or_insert(0) += 1,
149            None => ignored += 1,
150        }
151        if let Some(cwd) = r.cwd.as_deref().filter(|s| !s.is_empty()) {
152            *dir_counts.entry(cwd.to_string()).or_insert(0) += 1;
153        }
154        if let Some(root) = r.git_root.as_deref().filter(|s| !s.is_empty()) {
155            git_roots.insert(root.to_string());
156            *proj_counts.entry(project_name(root)).or_insert(0) += 1;
157        }
158        if let Some(shell) = r.shell.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
159            *shell_counts.entry(shell.to_string()).or_insert(0) += 1;
160        }
161        // Activité quotidienne : préfixe date `YYYY-MM-DD` de `created_at`.
162        if r.created_at.len() >= 10 {
163            *day_counts
164                .entry(r.created_at[..10].to_string())
165                .or_insert(0) += 1;
166        }
167        if matches!(r.exit_code, Some(code) if code != 0) {
168            failed += 1;
169        }
170    }
171
172    let mut daily: Vec<(String, usize)> = day_counts.into_iter().collect();
173    daily.sort_by(|a, b| a.0.cmp(&b.0));
174
175    Stats {
176        total,
177        git_projects: git_roots.len(),
178        failed,
179        ignored_for_top_commands: ignored,
180        top_commands: top_n(cmd_counts, TOP_N),
181        top_dirs: top_n(dir_counts, TOP_N),
182        top_projects: top_n(proj_counts, TOP_N),
183        top_shells: top_n(shell_counts, TOP_N),
184        daily,
185    }
186}
187
188/// Tokens parasites : opérateurs shell et mots-clés de structure qui ne sont
189/// jamais des noms de programmes.
190const JUNK_TOKENS: &[&str] = &[
191    "-", "|", "||", "&&", "&", ";", ";;", "}", "{", ")", "(", "then", "fi", "done", "do", "else",
192    "elif", "function", "in", "esac",
193];
194
195/// Wrappers qui précèdent la vraie commande et qu'il faut « traverser ».
196const WRAPPERS: &[&str] = &["command", "builtin", "exec", "time", "nohup"];
197
198/// Normalise une ligne de commande en nom de programme exploitable.
199///
200/// Renvoie `None` pour les lignes vides, les commentaires et les tokens
201/// parasites. Sinon renvoie le nom du programme réellement invoqué, après avoir
202/// retiré les affectations de variables d'environnement, les wrappers (`sudo`,
203/// `env`, `time`…) et le chemin éventuel (`/usr/bin/git` → `git`).
204pub fn normalize_command_name(command: &str) -> Option<String> {
205    let trimmed = command.trim();
206    if trimmed.is_empty() || trimmed.starts_with('#') {
207        return None;
208    }
209
210    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
211    let mut i = 0;
212
213    loop {
214        // 1. Sauter les affectations d'environnement en tête (`FOO=bar`).
215        while i < tokens.len() && is_env_assignment(tokens[i]) {
216            i += 1;
217        }
218        let tok = match tokens.get(i) {
219            Some(t) => *t,
220            None => return None,
221        };
222
223        // 2. Traverser les wrappers connus.
224        match tok {
225            "sudo" => {
226                i += 1;
227                // Options de sudo (`-E`, `-H`, `-n`…). On ne gère pas les
228                // options à argument (rare en pratique pour l'enrichissement).
229                while i < tokens.len() && tokens[i].starts_with('-') {
230                    i += 1;
231                }
232                continue;
233            }
234            "env" => {
235                i += 1;
236                while i < tokens.len() && tokens[i].starts_with('-') {
237                    i += 1;
238                }
239                continue;
240            }
241            _ if WRAPPERS.contains(&tok) => {
242                i += 1;
243                continue;
244            }
245            _ => {}
246        }
247
248        // 3. Token réel : on retire le chemin éventuel, puis on filtre le bruit.
249        let name = basename(tok);
250        if name.is_empty() || JUNK_TOKENS.contains(&name) {
251            return None;
252        }
253        return Some(name.to_string());
254    }
255}
256
257/// Vrai si le token est une affectation `IDENT=...` (variable d'environnement).
258fn is_env_assignment(token: &str) -> bool {
259    let Some((key, _)) = token.split_once('=') else {
260        return false;
261    };
262    if key.is_empty() {
263        return false;
264    }
265    let mut chars = key.chars();
266    let Some(first) = chars.next() else {
267        return false;
268    };
269    if !(first.is_ascii_alphabetic() || first == '_') {
270        return false;
271    }
272    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
273}
274
275/// Dernier segment d'un chemin (`/usr/bin/git` → `git`, `./x/mnemo` → `mnemo`).
276fn basename(token: &str) -> &str {
277    token.rsplit('/').next().unwrap_or(token)
278}
279
280/// Vrai si un nom de commande normalisé figure dans la liste d'exclusion
281/// (comparaison exacte, insensible à la casse).
282fn is_ignored(name: &str, ignored_commands: &[String]) -> bool {
283    let lowered = name.to_lowercase();
284    ignored_commands.iter().any(|c| c == &lowered)
285}
286
287/// Nom de projet = dernier segment du chemin de la racine Git.
288fn project_name(root: &str) -> String {
289    root.trim_end_matches('/')
290        .rsplit('/')
291        .next()
292        .unwrap_or(root)
293        .to_string()
294}
295
296/// Trie une table de comptage par occurrences décroissantes (puis clé pour un
297/// ordre déterministe) et garde les `n` premières entrées.
298fn top_n(counts: std::collections::HashMap<String, usize>, n: usize) -> Vec<(String, usize)> {
299    let mut v: Vec<(String, usize)> = counts.into_iter().collect();
300    v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
301    v.truncate(n);
302    v
303}
304
305/// Secondes Unix courantes (UTC).
306fn now_secs() -> u64 {
307    std::time::SystemTime::now()
308        .duration_since(std::time::UNIX_EPOCH)
309        .map(|d| d.as_secs())
310        .unwrap_or(0)
311}
312
313/// Construit la fenêtre d'activité des `days` derniers jours (aujourd'hui
314/// inclus), en remplissant à 0 les jours sans activité. Résultat trié du plus
315/// ancien au plus récent. Fonction pure (référence temporelle injectée).
316fn activity_window(
317    daily: &[(String, usize)],
318    today_secs: u64,
319    days: usize,
320) -> Vec<(String, usize)> {
321    use std::collections::HashMap;
322    let map: HashMap<&str, usize> = daily.iter().map(|(d, c)| (d.as_str(), *c)).collect();
323    let mut out = Vec::with_capacity(days);
324    for k in (0..days).rev() {
325        let secs = today_secs.saturating_sub(k as u64 * 86_400);
326        let date = db::format_timestamp(secs)[..10].to_string();
327        let count = map.get(date.as_str()).copied().unwrap_or(0);
328        out.push((date, count));
329    }
330    out
331}
332
333// ---------------------------------------------------------------------------
334// Rendu texte.
335// ---------------------------------------------------------------------------
336
337fn render_text<W: Write>(out: &mut W, stats: &Stats, filters: &Filters) -> std::io::Result<()> {
338    writeln!(out, "mnemo stats - statistiques d'usage")?;
339    writeln!(out, "----------------------------------")?;
340    if !filters.is_empty() {
341        writeln!(
342            out,
343            "Filtres                : projet={}, branche={}, depuis={}",
344            filters.project.as_deref().unwrap_or("-"),
345            filters.branch.as_deref().unwrap_or("-"),
346            filters.since.as_deref().unwrap_or("-"),
347        )?;
348    }
349    writeln!(out, "Commandes enregistrées : {}", stats.total)?;
350    writeln!(out, "Projets Git détectés   : {}", stats.git_projects)?;
351    writeln!(
352        out,
353        "Commandes en échec     : {} (exit_code ≠ 0)",
354        stats.failed
355    )?;
356    writeln!(
357        out,
358        "Taux d'échec           : {:.1} %",
359        failure_rate(stats) * 100.0
360    )?;
361
362    render_section(out, "Top commandes", &stats.top_commands)?;
363    if stats.ignored_for_top_commands > 0 {
364        writeln!(
365            out,
366            "  Entrées ignorées dans le Top commandes : {}",
367            stats.ignored_for_top_commands
368        )?;
369    }
370    render_section(out, "Top dossiers", &stats.top_dirs)?;
371    render_section(out, "Top projets Git", &stats.top_projects)?;
372    render_section(out, "Top shells", &stats.top_shells)?;
373    render_activity(out, stats)?;
374    Ok(())
375}
376
377/// Affiche l'activité quotidienne des 7 derniers jours (barres) et le total des
378/// 30 derniers jours.
379fn render_activity<W: Write>(out: &mut W, stats: &Stats) -> std::io::Result<()> {
380    let now = now_secs();
381    let week = activity_window(&stats.daily, now, 7);
382    let month = activity_window(&stats.daily, now, 30);
383    let month_total: usize = month.iter().map(|(_, c)| c).sum();
384    let max = week.iter().map(|(_, c)| *c).max().unwrap_or(0);
385
386    writeln!(out)?;
387    writeln!(out, "Activité (7 derniers jours) :")?;
388    for (date, count) in &week {
389        let bar_len = (count * 20).checked_div(max).unwrap_or(0);
390        let bar = "#".repeat(bar_len);
391        writeln!(out, "  {date}  {count:>4}  {bar}")?;
392    }
393    writeln!(out, "  30 derniers jours : {month_total} commande(s)")?;
394    Ok(())
395}
396
397fn render_section<W: Write>(
398    out: &mut W,
399    title: &str,
400    entries: &[(String, usize)],
401) -> std::io::Result<()> {
402    writeln!(out)?;
403    writeln!(out, "{title} :")?;
404    if entries.is_empty() {
405        writeln!(out, "  (aucune donnée)")?;
406        return Ok(());
407    }
408    for (label, count) in entries {
409        writeln!(out, "  {count:>5}  {label}")?;
410    }
411    Ok(())
412}
413
414// ---------------------------------------------------------------------------
415// Rendu JSON.
416// ---------------------------------------------------------------------------
417
418#[derive(Serialize)]
419struct NamedCount {
420    name: String,
421    count: usize,
422}
423
424#[derive(Serialize)]
425struct PathCount {
426    path: String,
427    count: usize,
428}
429
430#[derive(Serialize)]
431struct DayCount {
432    date: String,
433    count: usize,
434}
435
436#[derive(Serialize)]
437struct JsonOutput {
438    total_commands: usize,
439    git_projects: usize,
440    failed_commands: usize,
441    failure_rate: f64,
442    ignored_for_top_commands: usize,
443    ignored_commands_config: Vec<String>,
444    filters: Filters,
445    top_commands: Vec<NamedCount>,
446    top_directories: Vec<PathCount>,
447    top_projects: Vec<NamedCount>,
448    top_shells: Vec<NamedCount>,
449    activity_last_7_days: Vec<DayCount>,
450    activity_last_30_days: Vec<DayCount>,
451}
452
453fn render_json(stats: &Stats, filters: &Filters, ignored_commands: &[String]) -> String {
454    let now = now_secs();
455    let output = JsonOutput {
456        total_commands: stats.total,
457        git_projects: stats.git_projects,
458        failed_commands: stats.failed,
459        failure_rate: failure_rate(stats),
460        ignored_for_top_commands: stats.ignored_for_top_commands,
461        ignored_commands_config: ignored_commands.to_vec(),
462        filters: filters.clone(),
463        top_commands: stats
464            .top_commands
465            .iter()
466            .map(|(name, count)| NamedCount {
467                name: name.clone(),
468                count: *count,
469            })
470            .collect(),
471        top_directories: stats
472            .top_dirs
473            .iter()
474            .map(|(path, count)| PathCount {
475                path: path.clone(),
476                count: *count,
477            })
478            .collect(),
479        top_projects: stats
480            .top_projects
481            .iter()
482            .map(|(name, count)| NamedCount {
483                name: name.clone(),
484                count: *count,
485            })
486            .collect(),
487        top_shells: stats
488            .top_shells
489            .iter()
490            .map(|(name, count)| NamedCount {
491                name: name.clone(),
492                count: *count,
493            })
494            .collect(),
495        activity_last_7_days: activity_window(&stats.daily, now, 7)
496            .into_iter()
497            .map(|(date, count)| DayCount { date, count })
498            .collect(),
499        activity_last_30_days: activity_window(&stats.daily, now, 30)
500            .into_iter()
501            .map(|(date, count)| DayCount { date, count })
502            .collect(),
503    };
504    serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn rec(
512        command: &str,
513        cwd: Option<&str>,
514        git_root: Option<&str>,
515        exit: Option<i64>,
516    ) -> CommandRecord {
517        CommandRecord {
518            id: 0,
519            command: command.to_string(),
520            cwd: cwd.map(str::to_string),
521            shell: None,
522            hostname: None,
523            exit_code: exit,
524            created_at: "2026-06-14 10:00:00".to_string(),
525            git_root: git_root.map(str::to_string),
526            git_branch: None,
527            git_remote: None,
528            session_id: None,
529        }
530    }
531
532    #[test]
533    fn normalize_commandes_valides() {
534        assert_eq!(normalize_command_name("git status").as_deref(), Some("git"));
535        assert_eq!(
536            normalize_command_name("cargo build --release").as_deref(),
537            Some("cargo")
538        );
539        assert_eq!(
540            normalize_command_name("docker compose up -d").as_deref(),
541            Some("docker")
542        );
543        assert_eq!(
544            normalize_command_name("kubectl get pods").as_deref(),
545            Some("kubectl")
546        );
547        assert_eq!(
548            normalize_command_name("npx release-it").as_deref(),
549            Some("npx")
550        );
551        assert_eq!(
552            normalize_command_name("npm run build").as_deref(),
553            Some("npm")
554        );
555    }
556
557    #[test]
558    fn normalize_sudo_env_wrappers() {
559        assert_eq!(
560            normalize_command_name("sudo apt update").as_deref(),
561            Some("apt")
562        );
563        assert_eq!(
564            normalize_command_name("sudo -E apt update").as_deref(),
565            Some("apt")
566        );
567        assert_eq!(
568            normalize_command_name("sudo env FOO=bar cargo test").as_deref(),
569            Some("cargo")
570        );
571        assert_eq!(
572            normalize_command_name("env RUST_LOG=debug cargo test").as_deref(),
573            Some("cargo")
574        );
575        assert_eq!(
576            normalize_command_name("RUST_LOG=debug cargo test").as_deref(),
577            Some("cargo")
578        );
579        assert_eq!(
580            normalize_command_name("FOO=bar BAR=baz npm run build").as_deref(),
581            Some("npm")
582        );
583        assert_eq!(
584            normalize_command_name("time cargo test").as_deref(),
585            Some("cargo")
586        );
587        assert_eq!(
588            normalize_command_name("command git status").as_deref(),
589            Some("git")
590        );
591    }
592
593    #[test]
594    fn normalize_chemins() {
595        assert_eq!(
596            normalize_command_name("/usr/bin/git status").as_deref(),
597            Some("git")
598        );
599        assert_eq!(
600            normalize_command_name("./target/release/mnemo doctor").as_deref(),
601            Some("mnemo")
602        );
603    }
604
605    #[test]
606    fn normalize_rejette_le_bruit() {
607        assert_eq!(normalize_command_name("# commentaire"), None);
608        assert_eq!(normalize_command_name("-"), None);
609        assert_eq!(normalize_command_name("|"), None);
610        assert_eq!(normalize_command_name("||"), None);
611        assert_eq!(normalize_command_name("&&"), None);
612        assert_eq!(normalize_command_name(";"), None);
613        assert_eq!(normalize_command_name("then"), None);
614        assert_eq!(normalize_command_name("fi"), None);
615        assert_eq!(normalize_command_name("done"), None);
616        assert_eq!(normalize_command_name("function"), None);
617        assert_eq!(normalize_command_name(""), None);
618        assert_eq!(normalize_command_name("    "), None);
619    }
620
621    #[test]
622    fn stats_sur_base_vide() {
623        let stats = compute(&[], &[]);
624        assert_eq!(stats.total, 0);
625        assert_eq!(stats.git_projects, 0);
626        assert_eq!(stats.failed, 0);
627        assert_eq!(stats.ignored_for_top_commands, 0);
628        assert!(stats.top_commands.is_empty());
629        assert!(stats.top_dirs.is_empty());
630        assert!(stats.top_projects.is_empty());
631    }
632
633    #[test]
634    fn stats_ignore_le_bruit_dans_le_top_commandes() {
635        let records = vec![
636            rec("git status", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
637            rec(
638                "sudo apt update",
639                Some("/p/mnemo"),
640                Some("/p/mnemo"),
641                Some(0),
642            ),
643            rec("-", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
644            rec("| grep x", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
645            rec(
646                "# un commentaire",
647                Some("/p/mnemo"),
648                Some("/p/mnemo"),
649                Some(0),
650            ),
651        ];
652        let stats = compute(&records, &[]);
653
654        assert_eq!(stats.total, 5);
655        // 3 entrées bruitées : "-", "| grep x", "# un commentaire".
656        assert_eq!(stats.ignored_for_top_commands, 3);
657        let names: Vec<&str> = stats.top_commands.iter().map(|(n, _)| n.as_str()).collect();
658        assert!(names.contains(&"git"));
659        assert!(names.contains(&"apt"));
660        assert!(!names.contains(&"-"));
661        assert!(!names.contains(&"|"));
662        assert!(!names.contains(&"#"));
663    }
664
665    #[test]
666    fn stats_sur_base_remplie() {
667        let records = vec![
668            rec(
669                "cargo build",
670                Some("/home/u/proj/mnemo"),
671                Some("/home/u/proj/mnemo"),
672                Some(0),
673            ),
674            rec(
675                "cargo test",
676                Some("/home/u/proj/mnemo"),
677                Some("/home/u/proj/mnemo"),
678                Some(1),
679            ),
680            rec(
681                "git status",
682                Some("/home/u/proj/mnemo"),
683                Some("/home/u/proj/mnemo"),
684                Some(0),
685            ),
686            rec("ls -la", Some("/tmp"), None, Some(0)),
687            rec(
688                "cargo run",
689                Some("/home/u/proj/autre"),
690                Some("/home/u/proj/autre"),
691                Some(0),
692            ),
693        ];
694        let stats = compute(&records, &[]);
695
696        assert_eq!(stats.total, 5);
697        assert_eq!(stats.git_projects, 2);
698        assert_eq!(stats.failed, 1);
699        assert_eq!(
700            stats.top_commands.first().unwrap(),
701            &("cargo".to_string(), 3)
702        );
703        assert_eq!(
704            stats.top_projects.first().unwrap(),
705            &("mnemo".to_string(), 3)
706        );
707    }
708
709    #[test]
710    fn stats_respecte_la_config_ignored_commands() {
711        let records = vec![
712            rec(
713                "create_dir foo",
714                Some("/p/mnemo"),
715                Some("/p/mnemo"),
716                Some(0),
717            ),
718            rec(
719                "create_dir bar",
720                Some("/p/mnemo"),
721                Some("/p/mnemo"),
722                Some(0),
723            ),
724            rec("cargo build", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
725            rec("git status", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
726        ];
727        let ignored = vec!["create_dir".to_string()];
728        let stats = compute(&records, &ignored);
729
730        // Le total reste complet ; seules les stats du Top commandes changent.
731        assert_eq!(stats.total, 4);
732        // Les 2 `create_dir` sont comptés comme ignorés, pas dans le Top.
733        assert_eq!(stats.ignored_for_top_commands, 2);
734        let names: Vec<&str> = stats.top_commands.iter().map(|(n, _)| n.as_str()).collect();
735        assert!(names.contains(&"cargo"));
736        assert!(names.contains(&"git"));
737        assert!(!names.contains(&"create_dir"));
738    }
739
740    #[test]
741    fn ignored_commands_insensible_a_la_casse() {
742        let records = vec![rec("Create_Dir foo", None, None, Some(0))];
743        // La liste est normalisée en minuscules par la config.
744        let stats = compute(&records, &["create_dir".to_string()]);
745        assert_eq!(stats.ignored_for_top_commands, 1);
746        assert!(stats.top_commands.is_empty());
747    }
748
749    #[test]
750    fn json_est_bien_forme() {
751        let records = vec![rec(
752            "cargo build",
753            Some("/home/u/proj/mnemo"),
754            Some("/home/u/proj/mnemo"),
755            Some(0),
756        )];
757        let stats = compute(&records, &[]);
758        let filters = Filters {
759            project: Some("mnemo".to_string()),
760            branch: None,
761            since: None,
762        };
763        let s = render_json(&stats, &filters, &["create_dir".to_string()]);
764        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
765        assert_eq!(parsed["total_commands"], 1);
766        assert_eq!(parsed["filters"]["project"], "mnemo");
767        assert!(parsed["filters"]["branch"].is_null());
768        assert_eq!(parsed["top_commands"][0]["name"], "cargo");
769        assert_eq!(parsed["top_commands"][0]["count"], 1);
770        assert_eq!(parsed["top_directories"][0]["path"], "/home/u/proj/mnemo");
771        assert_eq!(parsed["ignored_commands_config"][0], "create_dir");
772    }
773
774    #[test]
775    fn nom_de_projet() {
776        assert_eq!(project_name("/home/u/proj/mnemo"), "mnemo");
777        assert_eq!(project_name("/home/u/proj/mnemo/"), "mnemo");
778    }
779
780    fn rec_shell(
781        command: &str,
782        shell: Option<&str>,
783        date: &str,
784        exit: Option<i64>,
785    ) -> CommandRecord {
786        CommandRecord {
787            id: 0,
788            command: command.to_string(),
789            cwd: None,
790            shell: shell.map(str::to_string),
791            hostname: None,
792            exit_code: exit,
793            created_at: date.to_string(),
794            git_root: None,
795            git_branch: None,
796            git_remote: None,
797            session_id: None,
798        }
799    }
800
801    #[test]
802    fn top_shells_et_taux_echec() {
803        let records = vec![
804            rec_shell("a", Some("bash"), "2026-06-10 10:00:00", Some(0)),
805            rec_shell("b", Some("bash"), "2026-06-10 11:00:00", Some(1)),
806            rec_shell("c", Some("zsh"), "2026-06-11 09:00:00", Some(0)),
807            rec_shell("d", None, "2026-06-11 09:30:00", Some(0)),
808        ];
809        let stats = compute(&records, &[]);
810        assert_eq!(stats.top_shells[0], ("bash".to_string(), 2));
811        assert_eq!(stats.failed, 1);
812        assert!((failure_rate(&stats) - 0.25).abs() < 1e-9);
813        // Activité regroupée par jour.
814        assert_eq!(stats.daily.len(), 2);
815    }
816
817    #[test]
818    fn activity_window_remplit_les_jours_vides() {
819        // 2026-06-18 12:00:00 UTC = 1781784000 secondes.
820        let today = 1_781_784_000u64;
821        let daily = vec![("2026-06-18".to_string(), 3)];
822        let window = activity_window(&daily, today, 7);
823        assert_eq!(window.len(), 7);
824        assert_eq!(window.last().unwrap(), &("2026-06-18".to_string(), 3));
825        assert_eq!(window.first().unwrap().1, 0);
826    }
827}