Skip to main content

mnemo/
show.rs

1//! Consultation détaillée (`mnemo show`) et récupération brute (`mnemo print`)
2//! d'une commande de l'historique, par identifiant.
3//!
4//! Ces deux sous-commandes sont en lecture seule : elles lisent la base et
5//! n'exécutent **jamais** la commande stockée. Si une commande a déjà été
6//! redactée (`mnemo secrets redact`), c'est sa forme redactée enregistrée qui
7//! est affichée, sans nouvelle analyse de secrets.
8
9use anyhow::{bail, Result};
10use std::io::Write;
11
12use crate::config;
13use crate::db::{self, CommandRecord};
14
15/// `mnemo show <id>` : affiche le détail complet d'une commande.
16pub fn run_show(id: i64) -> Result<()> {
17    let conn = db::open(&config::db_path()?)?;
18    match db::get_command(&conn, id)? {
19        Some(record) => {
20            // stdout verrouillé : un `BrokenPipe` (sortie pipée) remonte comme
21            // une erreur propre plutôt que de faire paniquer `print!`.
22            let stdout = std::io::stdout();
23            let mut out = stdout.lock();
24            write!(out, "{}", format_record(&record))?;
25            Ok(())
26        }
27        None => bail!("Aucune commande avec l'ID {id}."),
28    }
29}
30
31/// `mnemo print <id>` : imprime uniquement la commande brute sur stdout.
32///
33/// La sortie contient exactement la commande suivie d'un saut de ligne, sans
34/// label ni couleur. En cas d'ID inexistant, l'erreur part sur stderr et le
35/// code de sortie est non nul (via la remontée d'erreur de `main`).
36pub fn run_print(id: i64) -> Result<()> {
37    let conn = db::open(&config::db_path()?)?;
38    match db::get_command(&conn, id)? {
39        Some(record) => {
40            let stdout = std::io::stdout();
41            let mut out = stdout.lock();
42            writeln!(out, "{}", record.command)?;
43            Ok(())
44        }
45        None => bail!("Aucune commande avec l'ID {id}."),
46    }
47}
48
49/// Met en forme l'affichage détaillé de `mnemo show`.
50///
51/// Les champs absents (ou vides) sont **omis** plutôt qu'inventés ou remplis
52/// d'une valeur factice. Le bloc commande reproduit fidèlement la valeur
53/// stockée (éventuellement déjà redactée).
54fn format_record(r: &CommandRecord) -> String {
55    let mut out = String::new();
56    out.push_str(&format!("Commande {}\n\n", r.id));
57    out.push_str(&format!("Date : {}\n", r.created_at));
58    if let Some(cwd) = field(&r.cwd) {
59        out.push_str(&format!("Dossier : {cwd}\n"));
60    }
61    if let Some(root) = field(&r.git_root) {
62        out.push_str(&format!("Projet Git : {root}\n"));
63    }
64    if let Some(branch) = field(&r.git_branch) {
65        out.push_str(&format!("Branche : {branch}\n"));
66    }
67    if let Some(remote) = field(&r.git_remote) {
68        out.push_str(&format!("Remote : {remote}\n"));
69    }
70    if let Some(shell) = field(&r.shell) {
71        out.push_str(&format!("Shell : {shell}\n"));
72    }
73    if let Some(session) = field(&r.session_id) {
74        out.push_str(&format!("Session : {session}\n"));
75    }
76    if let Some(code) = r.exit_code {
77        out.push_str(&format!("Code retour : {code}\n"));
78    }
79    out.push_str(&format!("\nCommande :\n{}\n", r.command));
80    out
81}
82
83/// Renvoie la valeur si elle est présente et non vide (après trim), sinon
84/// `None`. Évite d'afficher des lignes vides pour des champs non renseignés.
85fn field(value: &Option<String>) -> Option<&str> {
86    value.as_deref().filter(|s| !s.trim().is_empty())
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn rec() -> CommandRecord {
94        CommandRecord {
95            id: 128,
96            command: "cargo test --locked".to_string(),
97            cwd: Some("/home/u/projects/mnemo".to_string()),
98            shell: Some("bash".to_string()),
99            hostname: None,
100            exit_code: Some(0),
101            created_at: "2026-06-23 14:22:10".to_string(),
102            git_root: Some("/home/u/projects/mnemo".to_string()),
103            git_branch: Some("main".to_string()),
104            git_remote: None,
105            session_id: Some("20260623T141200-12345".to_string()),
106        }
107    }
108
109    #[test]
110    fn format_affiche_les_champs_disponibles() {
111        let out = format_record(&rec());
112        assert!(out.contains("Commande 128"));
113        assert!(out.contains("Date : 2026-06-23 14:22:10"));
114        assert!(out.contains("Dossier : /home/u/projects/mnemo"));
115        assert!(out.contains("Projet Git : /home/u/projects/mnemo"));
116        assert!(out.contains("Branche : main"));
117        assert!(out.contains("Session : 20260623T141200-12345"));
118        assert!(out.contains("Code retour : 0"));
119        assert!(out.contains("Commande :\ncargo test --locked\n"));
120    }
121
122    #[test]
123    fn format_omet_les_champs_absents() {
124        let mut r = rec();
125        r.git_remote = None;
126        r.git_branch = None;
127        r.session_id = None;
128        r.git_root = Some("   ".to_string()); // vide après trim -> omis
129        let out = format_record(&r);
130        assert!(!out.contains("Branche :"));
131        assert!(!out.contains("Remote :"));
132        assert!(!out.contains("Session :"));
133        assert!(!out.contains("Projet Git :"));
134    }
135
136    #[test]
137    fn format_preserve_une_commande_redactee() {
138        let mut r = rec();
139        r.command = "curl -H 'Authorization: Bearer [REDACTED]'".to_string();
140        let out = format_record(&r);
141        assert!(out.contains("[REDACTED]"));
142    }
143}