Skip to main content

mnemo/
doctor.rs

1//! Commande `mnemo doctor` : diagnostic de l'installation locale.
2//!
3//! En mode simple, `doctor` ne modifie **jamais** le système : il se contente
4//! d'inspecter la configuration, la base, le `PATH` et le `.bashrc`. Le mode
5//! `--fix` répare les éléments manquants de façon non destructive (création de
6//! la config / base, ajout du bloc `.bashrc` avec sauvegarde) ; il ne supprime
7//! jamais de données.
8
9use anyhow::Result;
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12
13use crate::{backup, config, db, migrations, shell};
14
15/// Niveau de chaque contrôle.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Status {
19    Ok,
20    Warn,
21    Error,
22    Info,
23    /// Correction appliquée par `--fix` (non bloquante, distincte d'un simple OK).
24    Fix,
25}
26
27impl Status {
28    fn label(self) -> &'static str {
29        match self {
30            Status::Ok => "OK",
31            Status::Warn => "WARN",
32            Status::Error => "ERROR",
33            Status::Info => "INFO",
34            Status::Fix => "FIX",
35        }
36    }
37}
38
39/// Résultat d'un contrôle individuel.
40#[derive(Debug, Clone, Serialize)]
41pub struct Check {
42    pub name: String,
43    pub status: Status,
44    pub message: String,
45}
46
47impl Check {
48    fn new(name: &str, status: Status, message: impl Into<String>) -> Self {
49        Self {
50            name: name.to_string(),
51            status,
52            message: message.into(),
53        }
54    }
55}
56
57/// Rapport complet (liste de contrôles).
58#[derive(Debug, Default)]
59pub struct Report {
60    pub checks: Vec<Check>,
61}
62
63impl Report {
64    fn push(&mut self, name: &str, status: Status, message: impl Into<String>) {
65        self.checks.push(Check::new(name, status, message));
66    }
67
68    fn count(&self, status: Status) -> usize {
69        self.checks.iter().filter(|c| c.status == status).count()
70    }
71
72    /// Code de sortie : 1 si au moins une erreur bloquante, sinon 0.
73    pub fn exit_code(&self) -> i32 {
74        if self.count(Status::Error) > 0 {
75            1
76        } else {
77            0
78        }
79    }
80}
81
82/// Point d'entrée de la commande. Retourne le code de sortie attendu.
83pub fn run(fix: bool, json: bool) -> Result<i32> {
84    let mut report = Report::default();
85
86    if fix {
87        apply_fixes(&mut report)?;
88    }
89
90    collect_checks(&mut report)?;
91
92    if json {
93        println!("{}", render_json(&report));
94    } else {
95        render_text(&report);
96    }
97
98    Ok(report.exit_code())
99}
100
101// ---------------------------------------------------------------------------
102// Mode --fix (non destructif).
103// ---------------------------------------------------------------------------
104
105fn apply_fixes(report: &mut Report) -> Result<()> {
106    let mut fixes = 0usize;
107
108    // 0. Dossiers de données / configuration (créés en amont si absents).
109    for (name, dir) in [
110        (
111            "fix.dir.config",
112            config::config_path()?.parent().map(Path::to_path_buf),
113        ),
114        (
115            "fix.dir.data",
116            config::db_path()?.parent().map(Path::to_path_buf),
117        ),
118    ] {
119        if let Some(dir) = dir {
120            if dir.as_os_str().is_empty() || dir.exists() {
121                continue;
122            }
123            std::fs::create_dir_all(&dir)?;
124            config::harden_dir(&dir);
125            fixes += 1;
126            report.push(
127                name,
128                Status::Ok,
129                format!("Dossier créé : {}", dir.display()),
130            );
131        }
132    }
133
134    // 1. Config.
135    let cfg_path = config::config_path()?;
136    if cfg_path.exists() {
137        report.push("fix.config", Status::Info, "Configuration déjà présente");
138    } else {
139        config::Config::default().save(&cfg_path)?;
140        fixes += 1;
141        report.push(
142            "fix.config",
143            Status::Ok,
144            format!("Configuration créée : {}", cfg_path.display()),
145        );
146    }
147
148    // 2. Base de données (création du schéma si absente).
149    let db_path = config::db_path()?;
150    let db_existait = db_path.exists();
151    // `db::open` durcit silencieusement la base à 600 ; on capture donc l'état
152    // des permissions AVANT ouverture pour pouvoir rapporter explicitement la
153    // correction dans le résumé.
154    #[cfg(unix)]
155    let db_trop_ouverte = db_existait && is_too_open(&db_path);
156    db::open(&db_path)?;
157    if db_existait {
158        report.push("fix.db", Status::Info, "Base de données déjà présente");
159    } else {
160        fixes += 1;
161        report.push(
162            "fix.db",
163            Status::Ok,
164            format!("Base de données créée : {}", db_path.display()),
165        );
166    }
167
168    // 3. Permissions trop permissives sur la config et la base (chmod 600).
169    fixes += fix_permissions(report, "fix.config.perms", &cfg_path);
170    // La base a déjà été resserrée par `db::open` : on rend la correction
171    // explicite plutôt que silencieuse.
172    #[cfg(unix)]
173    if db_trop_ouverte {
174        report.push(
175            "fix.db.perms",
176            Status::Fix,
177            format!("Permissions corrigées : {} → 600", db_path.display()),
178        );
179        fixes += 1;
180    }
181    #[cfg(not(unix))]
182    {
183        fixes += fix_permissions(report, "fix.db.perms", &db_path);
184    }
185
186    // 4. Sauvegardes existantes trop ouvertes (chmod 600, résumé unique).
187    fixes += fix_backups_permissions(report);
188
189    // 5. Bloc .bashrc : ajout / déduplication / restauration du Ctrl+R.
190    if let Some(bashrc) = bashrc_path() {
191        match shell::repair_block(&bashrc) {
192            Ok(shell::BlockRepair::Created) => {
193                fixes += 1;
194                report.push(
195                    "fix.bashrc",
196                    Status::Ok,
197                    "Bloc mnemo ajouté au .bashrc (sauvegarde créée)",
198                );
199            }
200            Ok(shell::BlockRepair::Deduplicated) => {
201                fixes += 1;
202                report.push(
203                    "fix.bashrc",
204                    Status::Ok,
205                    "Bloc mnemo dupliqué supprimé, un seul conservé (sauvegarde créée)",
206                );
207            }
208            Ok(shell::BlockRepair::CtrlRRestored) => {
209                fixes += 1;
210                report.push(
211                    "fix.bashrc",
212                    Status::Ok,
213                    "Raccourci Ctrl+R restauré dans le bloc mnemo (sauvegarde créée)",
214                );
215            }
216            Ok(shell::BlockRepair::Upgraded) => {
217                fixes += 1;
218                report.push(
219                    "fix.bashrc",
220                    Status::Ok,
221                    "Bloc mnemo obsolète mis à niveau (sessions activées, sauvegarde créée)",
222                );
223            }
224            Ok(shell::BlockRepair::AlreadyOk) => report.push(
225                "fix.bashrc",
226                Status::Info,
227                "Bloc mnemo déjà présent et complet (aucune modification)",
228            ),
229            Err(e) => report.push(
230                "fix.bashrc",
231                Status::Warn,
232                format!("Impossible de modifier le .bashrc : {e}"),
233            ),
234        }
235    }
236
237    // 6. PATH : message clair, jamais de modification automatique.
238    if let Some(local_bin) = local_bin_dir() {
239        if !path_contains(&local_bin) {
240            report.push(
241                "fix.path",
242                Status::Warn,
243                format!(
244                    "{} n'est pas dans le PATH. Ajoutez à votre ~/.bashrc : export PATH=\"$HOME/.local/bin:$PATH\"",
245                    local_bin.display()
246                ),
247            );
248        }
249    }
250
251    // Résumé des corrections appliquées.
252    if fixes > 0 {
253        report.push(
254            "fix.summary",
255            Status::Ok,
256            format!("Corrections appliquées : {fixes}"),
257        );
258    } else {
259        report.push("fix.summary", Status::Info, "Aucune correction nécessaire");
260    }
261
262    Ok(())
263}
264
265/// Resserre les permissions d'un fichier à `0o600` s'il est accessible au
266/// groupe ou aux autres. Retourne `1` si une correction a été appliquée.
267fn fix_permissions(report: &mut Report, name: &str, path: &Path) -> usize {
268    #[cfg(unix)]
269    {
270        use std::os::unix::fs::PermissionsExt;
271        if !path.exists() {
272            return 0;
273        }
274        let Ok(meta) = std::fs::metadata(path) else {
275            return 0;
276        };
277        let mode = meta.permissions().mode() & 0o777;
278        if mode & 0o077 == 0 {
279            return 0;
280        }
281        let mut perms = meta.permissions();
282        perms.set_mode(0o600);
283        if let Err(e) = std::fs::set_permissions(path, perms) {
284            report.push(name, Status::Warn, format!("Permissions inchangées : {e}"));
285            return 0;
286        }
287        report.push(
288            name,
289            Status::Fix,
290            format!("Permissions corrigées : {} → 600", path.display()),
291        );
292        1
293    }
294    #[cfg(not(unix))]
295    {
296        let _ = (report, name, path);
297        0
298    }
299}
300
301/// Indique si un fichier est accessible au groupe ou aux autres (mode `0o077`).
302#[cfg(unix)]
303fn is_too_open(path: &Path) -> bool {
304    use std::os::unix::fs::PermissionsExt;
305    std::fs::metadata(path)
306        .map(|m| m.permissions().mode() & 0o077 != 0)
307        .unwrap_or(false)
308}
309
310/// Resserre à `600` toutes les archives de sauvegarde trop ouvertes.
311///
312/// Best-effort et non destructif : ne lit ni ne modifie le contenu des
313/// archives, ne supprime rien, n'échoue pas si le dossier `backups` est absent.
314/// Pousse un unique résumé `[FIX]` indiquant le nombre d'archives corrigées et
315/// retourne `1` si au moins une correction a eu lieu (pour le compteur global).
316fn fix_backups_permissions(report: &mut Report) -> usize {
317    #[cfg(unix)]
318    {
319        let Ok(dir) = backup::backups_dir() else {
320            return 0;
321        };
322        let archives = backup::list_archives(&dir);
323        let mut corrected = 0usize;
324        for archive in &archives {
325            if is_too_open(archive) {
326                config::harden_file(archive);
327                corrected += 1;
328            }
329        }
330        if corrected == 0 {
331            return 0;
332        }
333        report.push(
334            "fix.backups.perms",
335            Status::Fix,
336            format!("Permissions corrigées : {corrected} backup(s) → 600"),
337        );
338        1
339    }
340    #[cfg(not(unix))]
341    {
342        let _ = report;
343        0
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Contrôles de diagnostic (lecture seule).
349// ---------------------------------------------------------------------------
350
351fn collect_checks(report: &mut Report) -> Result<()> {
352    check_binary(report);
353    check_local_bin_path(report);
354    check_config(report)?;
355    check_database(report)?;
356    check_backups(report);
357    check_bashrc(report);
358    check_shell(report);
359    check_histtimeformat(report);
360    Ok(())
361}
362
363fn check_binary(report: &mut Report) {
364    report.push(
365        "binary.version",
366        Status::Info,
367        format!("mnemo version {}", env!("CARGO_PKG_VERSION")),
368    );
369
370    match find_in_path("mnemo") {
371        Some(p) => report.push(
372            "binary.path",
373            Status::Ok,
374            format!("Binaire trouvé dans le PATH : {}", p.display()),
375        ),
376        None => report.push(
377            "binary.path",
378            Status::Warn,
379            "Binaire mnemo introuvable dans le PATH (installez-le dans ~/.local/bin)",
380        ),
381    }
382}
383
384fn check_local_bin_path(report: &mut Report) {
385    if let Some(local_bin) = local_bin_dir() {
386        if path_contains(&local_bin) {
387            report.push(
388                "path.local_bin",
389                Status::Ok,
390                format!("{} est dans le PATH", local_bin.display()),
391            );
392        } else {
393            report.push(
394                "path.local_bin",
395                Status::Warn,
396                format!("{} n'est pas dans le PATH", local_bin.display()),
397            );
398        }
399    }
400}
401
402fn check_config(report: &mut Report) -> Result<()> {
403    let cfg_path = config::config_path()?;
404    if cfg_path.exists() {
405        report.push(
406            "config.file",
407            Status::Ok,
408            format!("Configuration présente : {}", cfg_path.display()),
409        );
410        check_permissions(report, "config.perms", &cfg_path);
411    } else {
412        report.push(
413            "config.file",
414            Status::Warn,
415            format!(
416                "Configuration absente : {} (lancez `mnemo init` ou `mnemo doctor --fix`)",
417                cfg_path.display()
418            ),
419        );
420    }
421
422    let cfg = config::Config::load()?;
423    if cfg.stats.ignored_commands.is_empty() {
424        report.push(
425            "config.stats_ignore",
426            Status::Info,
427            "Aucune commande ignorée dans stats".to_string(),
428        );
429    } else {
430        report.push(
431            "config.stats_ignore",
432            Status::Info,
433            format!(
434                "Commandes ignorées dans stats : {}",
435                cfg.stats.ignored_commands.join(", ")
436            ),
437        );
438    }
439    Ok(())
440}
441
442fn check_database(report: &mut Report) -> Result<()> {
443    let db_path = config::db_path()?;
444    if !db_path.exists() {
445        report.push(
446            "db.file",
447            Status::Warn,
448            format!(
449                "Base absente : {} (lancez `mnemo import` ou `mnemo doctor --fix`)",
450                db_path.display()
451            ),
452        );
453        return Ok(());
454    }
455
456    report.push(
457        "db.file",
458        Status::Ok,
459        format!("Base présente : {}", db_path.display()),
460    );
461    check_permissions(report, "db.perms", &db_path);
462
463    // Ouverture en lecture seule (ne modifie jamais la base).
464    let conn = match db::open_readonly(&db_path) {
465        Ok(c) => c,
466        Err(e) => {
467            report.push(
468                "db.open",
469                Status::Error,
470                format!("Base illisible / corrompue : {e}"),
471            );
472            return Ok(());
473        }
474    };
475
476    match db::table_exists(&conn, "commands") {
477        Ok(true) => {
478            report.push("db.table", Status::Ok, "Table `commands` présente");
479            check_schema_version(report, &conn);
480            match db::count(&conn) {
481                Ok(n) => report.push(
482                    "db.count",
483                    Status::Info,
484                    format!("{n} commande(s) enregistrée(s)"),
485                ),
486                Err(e) => report.push(
487                    "db.count",
488                    Status::Error,
489                    format!("Lecture du nombre de commandes impossible : {e}"),
490                ),
491            }
492        }
493        Ok(false) => report.push(
494            "db.table",
495            Status::Error,
496            "Table `commands` absente (base invalide)",
497        ),
498        Err(e) => report.push(
499            "db.open",
500            Status::Error,
501            format!("Base illisible / corrompue : {e}"),
502        ),
503    }
504
505    Ok(())
506}
507
508/// Contrôle (lecture seule) des permissions des archives de sauvegarde.
509///
510/// Non bruyant : n'affiche rien si aucune archive n'existe, un résumé `OK`
511/// discret si toutes sont en `600`, et un unique résumé `WARN` agrégé (sans
512/// lister chaque fichier) si certaines sont trop ouvertes.
513fn check_backups(report: &mut Report) {
514    #[cfg(unix)]
515    {
516        let Ok(dir) = backup::backups_dir() else {
517            return;
518        };
519        let archives = backup::list_archives(&dir);
520        if archives.is_empty() {
521            return;
522        }
523        let open = archives.iter().filter(|p| is_too_open(p)).count();
524        if open > 0 {
525            report.push(
526                "backups.perms",
527                Status::Warn,
528                format!("Backups trop ouverts : {open} fichier(s), attendu 600"),
529            );
530        } else {
531            report.push(
532                "backups.perms",
533                Status::Ok,
534                format!("Sauvegardes : {} archive(s) en 600", archives.len()),
535            );
536        }
537    }
538    #[cfg(not(unix))]
539    {
540        let _ = report;
541    }
542}
543
544/// Vérifie la version du schéma SQLite (`PRAGMA user_version`) sans jamais
545/// migrer : `doctor` reste en lecture seule. Signale si une migration est
546/// nécessaire, ou si la base provient d'une version plus récente de mnemo.
547fn check_schema_version(report: &mut Report, conn: &rusqlite::Connection) {
548    let expected = migrations::SCHEMA_VERSION;
549    match migrations::schema_version(conn) {
550        Ok(current) => {
551            report.push(
552                "db.schema",
553                Status::Info,
554                format!("Schéma SQLite : v{current} (attendu v{expected})"),
555            );
556            if current < expected {
557                report.push(
558                    "db.schema.migration",
559                    Status::Warn,
560                    "Migration nécessaire : lancez `mnemo migrate` (ou toute commande mnemo l'applique automatiquement)",
561                );
562            } else if current > expected {
563                report.push(
564                    "db.schema.migration",
565                    Status::Error,
566                    format!(
567                        "Base créée par une version plus récente (schéma v{current} > v{expected}) : mettez mnemo à jour"
568                    ),
569                );
570            } else {
571                report.push(
572                    "db.schema.migration",
573                    Status::Ok,
574                    "Schéma à jour, aucune migration nécessaire",
575                );
576            }
577        }
578        Err(e) => report.push(
579            "db.schema",
580            Status::Error,
581            format!("Lecture de la version de schéma impossible : {e}"),
582        ),
583    }
584}
585
586fn check_bashrc(report: &mut Report) {
587    let Some(bashrc) = bashrc_path() else {
588        return;
589    };
590
591    if !bashrc.exists() {
592        report.push(
593            "bashrc.file",
594            Status::Warn,
595            format!("{} introuvable", bashrc.display()),
596        );
597        return;
598    }
599    report.push(
600        "bashrc.file",
601        Status::Ok,
602        format!("{} présent", bashrc.display()),
603    );
604
605    let content = std::fs::read_to_string(&bashrc).unwrap_or_default();
606
607    if shell::has_block(&content) {
608        report.push(
609            "bashrc.block",
610            Status::Ok,
611            "Bloc d'intégration mnemo présent",
612        );
613        let n = shell::count_blocks(&content);
614        if n > 1 {
615            report.push(
616                "bashrc.duplicate",
617                Status::Warn,
618                format!("Bloc mnemo dupliqué {n} fois (gardez-en un seul)"),
619            );
620        } else {
621            report.push("bashrc.duplicate", Status::Ok, "Bloc mnemo unique");
622        }
623
624        if shell::has_ctrl_r_bind(&content) {
625            report.push("bashrc.ctrl_r", Status::Ok, "Raccourci Ctrl+R configuré");
626        } else {
627            report.push(
628                "bashrc.ctrl_r",
629                Status::Warn,
630                "Raccourci Ctrl+R absent du bloc mnemo",
631            );
632        }
633
634        if shell::block_state(&content) == shell::BlockState::Legacy {
635            report.push(
636                "bashrc.version",
637                Status::Warn,
638                "Bloc d'intégration mnemo obsolète : il ne capture pas MNEMO_SESSION_ID, requis par `mnemo session`. Lancez `mnemo shell upgrade`.",
639            );
640        } else {
641            report.push(
642                "bashrc.version",
643                Status::Ok,
644                "Intégration Bash à jour (sessions activées)",
645            );
646        }
647    } else {
648        report.push(
649            "bashrc.block",
650            Status::Warn,
651            "Bloc d'intégration mnemo absent (lancez `mnemo doctor --fix`)",
652        );
653    }
654}
655
656fn check_shell(report: &mut Report) {
657    match std::env::var("SHELL") {
658        Ok(sh) if sh.ends_with("bash") => {
659            report.push("shell.current", Status::Ok, format!("Shell courant : {sh}"))
660        }
661        Ok(sh) => report.push(
662            "shell.current",
663            Status::Warn,
664            format!("Shell courant : {sh} (mnemo ne supporte que Bash pour l'instant)"),
665        ),
666        Err(_) => report.push("shell.current", Status::Warn, "Variable $SHELL non définie"),
667    }
668}
669
670fn check_histtimeformat(report: &mut Report) {
671    match std::env::var("HISTTIMEFORMAT") {
672        Ok(v) if !v.trim().is_empty() => {
673            report.push("shell.histtime", Status::Ok, "HISTTIMEFORMAT est configuré")
674        }
675        _ => report.push(
676            "shell.histtime",
677            Status::Info,
678            "HISTTIMEFORMAT non configuré : les horodatages d'import seront approximatifs",
679        ),
680    }
681}
682
683/// Vérifie que le fichier n'est pas modifiable par le groupe ou les autres.
684fn check_permissions(report: &mut Report, name: &str, path: &Path) {
685    #[cfg(unix)]
686    {
687        use std::os::unix::fs::PermissionsExt;
688        match std::fs::metadata(path) {
689            Ok(meta) => {
690                let mode = meta.permissions().mode() & 0o777;
691                if mode & 0o077 != 0 {
692                    report.push(
693                        name,
694                        Status::Warn,
695                        format!(
696                            "Permissions trop ouvertes : {} (actuel {:o}, attendu 600)",
697                            path.display(),
698                            mode
699                        ),
700                    );
701                } else {
702                    report.push(
703                        name,
704                        Status::Ok,
705                        format!("Permissions correctes ({:o})", mode),
706                    );
707                }
708            }
709            Err(e) => report.push(name, Status::Warn, format!("Permissions illisibles : {e}")),
710        }
711    }
712    #[cfg(not(unix))]
713    {
714        let _ = (path,);
715        report.push(name, Status::Info, "Vérification des permissions ignorée");
716    }
717}
718
719// ---------------------------------------------------------------------------
720// Helpers PATH / chemins.
721// ---------------------------------------------------------------------------
722
723fn bashrc_path() -> Option<PathBuf> {
724    dirs::home_dir().map(|h| h.join(".bashrc"))
725}
726
727fn local_bin_dir() -> Option<PathBuf> {
728    dirs::home_dir().map(|h| h.join(".local").join("bin"))
729}
730
731fn path_contains(dir: &Path) -> bool {
732    std::env::var_os("PATH")
733        .map(|paths| std::env::split_paths(&paths).any(|p| p == dir))
734        .unwrap_or(false)
735}
736
737fn find_in_path(exe: &str) -> Option<PathBuf> {
738    let paths = std::env::var_os("PATH")?;
739    for dir in std::env::split_paths(&paths) {
740        let candidate = dir.join(exe);
741        if is_executable(&candidate) {
742            return Some(candidate);
743        }
744    }
745    None
746}
747
748fn is_executable(path: &Path) -> bool {
749    #[cfg(unix)]
750    {
751        use std::os::unix::fs::PermissionsExt;
752        std::fs::metadata(path)
753            .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
754            .unwrap_or(false)
755    }
756    #[cfg(not(unix))]
757    {
758        path.is_file()
759    }
760}
761
762// ---------------------------------------------------------------------------
763// Rendu.
764// ---------------------------------------------------------------------------
765
766fn render_text(report: &Report) {
767    println!("mnemo doctor - rapport de diagnostic");
768    println!("------------------------------------");
769    for c in &report.checks {
770        println!("[{:^5}] {}", c.status.label(), c.message);
771    }
772    println!("------------------------------------");
773    println!(
774        "Résumé : {} OK, {} WARN, {} ERROR, {} INFO, {} FIX",
775        report.count(Status::Ok),
776        report.count(Status::Warn),
777        report.count(Status::Error),
778        report.count(Status::Info),
779        report.count(Status::Fix),
780    );
781    if report.exit_code() == 0 {
782        println!("État global : sain (code 0)");
783    } else {
784        println!("État global : erreurs détectées (code 1)");
785    }
786}
787
788fn render_json(report: &Report) -> String {
789    #[derive(Serialize)]
790    struct Summary {
791        ok: usize,
792        warn: usize,
793        error: usize,
794        info: usize,
795        fix: usize,
796        exit_code: i32,
797    }
798    #[derive(Serialize)]
799    struct Output<'a> {
800        summary: Summary,
801        checks: &'a [Check],
802    }
803
804    let output = Output {
805        summary: Summary {
806            ok: report.count(Status::Ok),
807            warn: report.count(Status::Warn),
808            error: report.count(Status::Error),
809            info: report.count(Status::Info),
810            fix: report.count(Status::Fix),
811            exit_code: report.exit_code(),
812        },
813        checks: &report.checks,
814    };
815
816    // La sérialisation ne peut échouer pour ces types simples ; on retombe sur
817    // un objet vide par prudence plutôt que de paniquer.
818    serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn exit_code_depend_des_erreurs() {
827        let mut r = Report::default();
828        r.push("a", Status::Ok, "ok");
829        r.push("b", Status::Warn, "warn");
830        assert_eq!(r.exit_code(), 0);
831        r.push("c", Status::Error, "boom");
832        assert_eq!(r.exit_code(), 1);
833    }
834
835    #[test]
836    fn json_echappe_les_caracteres_speciaux() {
837        let mut r = Report::default();
838        r.push("x", Status::Ok, "a\"b\\c\nfin");
839        let s = render_json(&r);
840        // serde_json doit produire un JSON parseable et correctement échappé.
841        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
842        assert_eq!(parsed["checks"][0]["message"], "a\"b\\c\nfin");
843    }
844
845    #[test]
846    fn json_est_bien_forme() {
847        let mut r = Report::default();
848        r.push("x", Status::Ok, "tout va bien");
849        let s = render_json(&r);
850        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
851        assert_eq!(parsed["summary"]["ok"], 1);
852        assert_eq!(parsed["summary"]["exit_code"], 0);
853        assert_eq!(parsed["checks"][0]["status"], "ok");
854        assert_eq!(parsed["checks"][0]["name"], "x");
855    }
856}