1use anyhow::Result;
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12
13use crate::{backup, config, db, migrations, shell};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Status {
19 Ok,
20 Warn,
21 Error,
22 Info,
23 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#[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#[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 pub fn exit_code(&self) -> i32 {
74 if self.count(Status::Error) > 0 {
75 1
76 } else {
77 0
78 }
79 }
80}
81
82pub 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
101fn apply_fixes(report: &mut Report) -> Result<()> {
106 let mut fixes = 0usize;
107
108 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 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 let db_path = config::db_path()?;
150 let db_existait = db_path.exists();
151 #[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 fixes += fix_permissions(report, "fix.config.perms", &cfg_path);
170 #[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 fixes += fix_backups_permissions(report);
188
189 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 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 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
265fn 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#[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
310fn 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
347fn 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 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
508fn 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
544fn 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
683fn 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
719fn 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
762fn 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 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 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}