Skip to main content

mnemo/
runbook.rs

1//! Commande `mnemo runbook` : génère un runbook Markdown réutilisable à partir
2//! des commandes d'une session ou d'un projet Git.
3//!
4//! Le runbook liste chaque commande dans une section Markdown numérotée avec son
5//! répertoire de travail, triées par ordre chronologique (le plus ancien en
6//! premier). Les lignes vides sont exclues. Le résultat est stable et
7//! déterministe : idéal pour une documentation ou un wiki.
8//!
9//! Les secrets sont redactés par défaut (`--no-redact` pour désactiver).
10
11use anyhow::{bail, Context, Result};
12use rusqlite::Connection;
13use serde::Serialize;
14use std::collections::BTreeMap;
15use std::io::{self, Write};
16use std::path::PathBuf;
17
18use crate::config;
19use crate::db;
20use crate::mdfmt::{display_home, md_code_block};
21use crate::secrets;
22
23// ---------------------------------------------------------------------------
24// Enums publics (réexportés pour cli.rs)
25// ---------------------------------------------------------------------------
26
27/// Format de sortie du runbook.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
29pub enum RunbookFormat {
30    /// Rendu Markdown (défaut).
31    Markdown,
32    /// JSON structuré, stable et déterministe.
33    Json,
34}
35
36/// Mode de groupement des commandes dans le runbook.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
38pub enum GroupBy {
39    /// Liste plate, une section numérotée par commande (défaut).
40    None,
41    /// Sections par répertoire de travail.
42    Cwd,
43    /// Sections par racine Git (`git_root`).
44    Project,
45}
46
47// ---------------------------------------------------------------------------
48// Structures publiques
49// ---------------------------------------------------------------------------
50
51/// Une entrée du runbook : une commande avec son contexte minimal.
52#[derive(Debug, Clone)]
53pub struct RunbookEntry {
54    /// Commande shell (toujours non vide après filtrage).
55    pub command: String,
56    /// Répertoire de travail au moment de l'exécution (raccourci `~/…`).
57    pub cwd: String,
58    /// Horodatage de la commande (`YYYY-MM-DD HH:MM:SS`).
59    pub timestamp: String,
60    /// Racine Git (`git_root`), utilisée pour le groupement par projet.
61    pub git_root: Option<String>,
62}
63
64// ---------------------------------------------------------------------------
65// Fonctions de récupération
66// ---------------------------------------------------------------------------
67
68/// Charge les commandes de la dernière session, en ordre chronologique croissant.
69/// Exclut les commandes vides (après trim).
70///
71/// Renvoie une erreur si aucune session n'est enregistrée.
72pub fn fetch_last_session_commands(
73    conn: &Connection,
74    limit: Option<u32>,
75) -> Result<Vec<RunbookEntry>> {
76    let session_id = db::latest_session_id(conn)?.ok_or_else(|| {
77        anyhow::anyhow!(
78            "Aucune session trouvée. Les commandes importées ou enregistrées \
79             sans MNEMO_SESSION_ID ne sont pas rattachées à une session."
80        )
81    })?;
82    fetch_session_commands(conn, &session_id, limit)
83}
84
85/// Charge les commandes d'une session explicite, en ordre chronologique croissant.
86///
87/// Renvoie une erreur si la session est introuvable.
88pub fn fetch_session_commands(
89    conn: &Connection,
90    session_id: &str,
91    limit: Option<u32>,
92) -> Result<Vec<RunbookEntry>> {
93    let lim = limit.map(|n| n as usize);
94    let records = db::session_commands(conn, session_id, lim)?;
95    if records.is_empty() {
96        bail!("Session introuvable : {session_id}");
97    }
98    Ok(records_to_entries(records))
99}
100
101/// Charge les commandes d'un projet (par nom court ou chemin `git_root`), en
102/// ordre chronologique croissant. Toutes les racines correspondantes sont
103/// agrégées avant tri.
104///
105/// Renvoie une erreur si aucune racine ne correspond à `name_or_path`.
106pub fn fetch_project_commands(
107    conn: &Connection,
108    name_or_path: &str,
109    limit: Option<u32>,
110) -> Result<Vec<RunbookEntry>> {
111    let roots = db::match_project_roots(conn, name_or_path)?;
112    if roots.is_empty() {
113        bail!("Projet introuvable : {name_or_path}");
114    }
115    let mut all: Vec<db::CommandRecord> = Vec::new();
116    for root in &roots {
117        // project_records retourne DESC ; on collecte tout et on trie ensuite.
118        let records = db::project_records(conn, root, None, None, false, None)?;
119        all.extend(records);
120    }
121    // Tri chronologique croissant (le plus ancien en premier).
122    all.sort_by(|a, b| {
123        a.created_at
124            .cmp(&b.created_at)
125            .then_with(|| a.id.cmp(&b.id))
126    });
127    // Appliquer la limite après le tri.
128    if let Some(n) = limit {
129        all.truncate(n as usize);
130    }
131    Ok(records_to_entries(all))
132}
133
134/// Convertit des [`db::CommandRecord`] en [`RunbookEntry`], en excluant les
135/// commandes vides (après trim).
136fn records_to_entries(records: Vec<db::CommandRecord>) -> Vec<RunbookEntry> {
137    records
138        .into_iter()
139        .filter(|r| !r.command.trim().is_empty())
140        .map(|r| RunbookEntry {
141            command: r.command,
142            cwd: r
143                .cwd
144                .as_deref()
145                .filter(|s| !s.is_empty())
146                .map(display_home)
147                .unwrap_or_else(|| "-".to_string()),
148            timestamp: r.created_at,
149            git_root: r.git_root,
150        })
151        .collect()
152}
153
154// ---------------------------------------------------------------------------
155// Rendu Markdown
156// ---------------------------------------------------------------------------
157
158/// Génère le document Markdown d'un runbook.
159///
160/// - `title` : titre du runbook (remplace la section `# Runbook - …`).
161/// - `source_desc` : description lisible de la source (session, projet…).
162/// - `entries` : commandes à inclure (les vides ont déjà été exclues en amont).
163/// - `group_by` : mode de groupement des commandes.
164///
165/// Quand `entries` est vide, le document reste cohérent (section Commands avec
166/// un message explicite, pas de panic).
167pub fn render_markdown(
168    title: &str,
169    source_desc: &str,
170    entries: &[RunbookEntry],
171    group_by: GroupBy,
172) -> String {
173    let generated_at = db::now_timestamp();
174    let mut out = String::new();
175
176    out.push_str(&format!("# Runbook - {title}\n\n"));
177
178    out.push_str("## Metadata\n\n");
179    out.push_str(&format!("- Source: {source_desc}\n"));
180    out.push_str(&format!("- Generated at: {generated_at}\n"));
181    out.push_str(&format!("- Commands: {}\n\n", entries.len()));
182
183    out.push_str("## Commands\n\n");
184
185    if entries.is_empty() {
186        out.push_str("_Aucune commande._\n");
187        return out;
188    }
189
190    match group_by {
191        GroupBy::None => {
192            for (i, entry) in entries.iter().enumerate() {
193                out.push_str(&format!("### {}. {}\n\n", i + 1, entry.cwd));
194                out.push_str(&md_code_block(std::slice::from_ref(&entry.command)));
195                out.push('\n');
196            }
197        }
198        GroupBy::Cwd | GroupBy::Project => {
199            let grouped = group_entries(entries, group_by);
200            for (group_key, group_entries) in &grouped {
201                out.push_str(&format!("## {group_key}\n\n"));
202                for (i, entry) in group_entries.iter().enumerate() {
203                    out.push_str(&format!("### {}.\n\n", i + 1));
204                    out.push_str(&md_code_block(std::slice::from_ref(&entry.command)));
205                    out.push('\n');
206                }
207            }
208        }
209    }
210
211    out
212}
213
214// ---------------------------------------------------------------------------
215// Rendu JSON
216// ---------------------------------------------------------------------------
217
218/// Ligne JSON pour une commande du runbook.
219#[derive(Serialize)]
220struct JsonCommand<'a> {
221    n: usize,
222    cwd: &'a str,
223    timestamp: &'a str,
224    command: &'a str,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    group: Option<String>,
227}
228
229/// Document JSON racine du runbook.
230#[derive(Serialize)]
231struct JsonRunbook<'a> {
232    title: &'a str,
233    source: &'a str,
234    generated_at: String,
235    commands: Vec<JsonCommand<'a>>,
236}
237
238/// Génère la représentation JSON d'un runbook.
239///
240/// Structure stable et déterministe : même entrées → même JSON.
241/// Avec `group_by` ≠ `None`, chaque commande porte un champ `"group"`.
242pub fn render_json(
243    title: &str,
244    source_desc: &str,
245    entries: &[RunbookEntry],
246    group_by: GroupBy,
247) -> Result<String> {
248    let generated_at = db::now_timestamp();
249
250    let commands: Vec<JsonCommand<'_>> = match group_by {
251        GroupBy::None => entries
252            .iter()
253            .enumerate()
254            .map(|(i, e)| JsonCommand {
255                n: i + 1,
256                cwd: &e.cwd,
257                timestamp: &e.timestamp,
258                command: &e.command,
259                group: None,
260            })
261            .collect(),
262        GroupBy::Cwd | GroupBy::Project => {
263            let grouped = group_entries(entries, group_by);
264            let mut cmds = Vec::with_capacity(entries.len());
265            let mut n = 1usize;
266            for (group_key, group_entries) in &grouped {
267                for entry in group_entries {
268                    cmds.push(JsonCommand {
269                        n,
270                        cwd: &entry.cwd,
271                        timestamp: &entry.timestamp,
272                        command: &entry.command,
273                        group: Some(group_key.clone()),
274                    });
275                    n += 1;
276                }
277            }
278            cmds
279        }
280    };
281
282    let doc = JsonRunbook {
283        title,
284        source: source_desc,
285        generated_at,
286        commands,
287    };
288
289    serde_json::to_string_pretty(&doc).context("sérialisation JSON du runbook")
290}
291
292// ---------------------------------------------------------------------------
293// Helpers de groupement
294// ---------------------------------------------------------------------------
295
296/// Retourne les entrées groupées par clé (alphabétique), les commandes dans
297/// chaque groupe étant dans l'ordre de `entries` (chronologique).
298fn group_entries<'a>(
299    entries: &'a [RunbookEntry],
300    group_by: GroupBy,
301) -> BTreeMap<String, Vec<&'a RunbookEntry>> {
302    let mut map: BTreeMap<String, Vec<&'a RunbookEntry>> = BTreeMap::new();
303    for entry in entries {
304        let key = match group_by {
305            GroupBy::None => unreachable!(),
306            GroupBy::Cwd => entry.cwd.clone(),
307            GroupBy::Project => entry
308                .git_root
309                .as_deref()
310                .filter(|s| !s.is_empty())
311                .map(display_home)
312                .unwrap_or_else(|| "(sans projet)".to_string()),
313        };
314        map.entry(key).or_default().push(entry);
315    }
316    map
317}
318
319// ---------------------------------------------------------------------------
320// Point d'entrée de la commande
321// ---------------------------------------------------------------------------
322
323/// Point d'entrée de `mnemo runbook`.
324///
325/// Exactement un des drapeaux `last`, `session`, `project` doit être fourni
326/// (mutuellement exclusifs côté clap). Si aucun n'est fourni, une erreur claire
327/// est retournée.
328///
329/// Les secrets sont redactés par défaut ; `no_redact = true` désactive ce
330/// comportement.
331#[allow(clippy::too_many_arguments)]
332pub fn run(
333    last: bool,
334    session: Option<String>,
335    project: Option<String>,
336    output: Option<PathBuf>,
337    force: bool,
338    limit: Option<u32>,
339    title: Option<String>,
340    format: RunbookFormat,
341    no_redact: bool,
342    group_by: GroupBy,
343) -> Result<()> {
344    let conn = db::open(&config::db_path()?)?;
345
346    let (entries, source_desc, default_title): (Vec<RunbookEntry>, String, String) = if last {
347        let sid = db::latest_session_id(&conn)?.ok_or_else(|| {
348            anyhow::anyhow!(
349                "Aucune session trouvée. Les commandes importées ou enregistrées \
350                     sans MNEMO_SESSION_ID ne sont pas rattachées à une session."
351            )
352        })?;
353        let e = fetch_session_commands(&conn, &sid, limit)?;
354        let desc = format!("dernière session ({sid})");
355        let dtitle = sid.clone();
356        (e, desc, dtitle)
357    } else if let Some(ref sid) = session {
358        let e = fetch_session_commands(&conn, sid, limit)?;
359        (e, format!("session {sid}"), sid.clone())
360    } else if let Some(ref proj) = project {
361        let e = fetch_project_commands(&conn, proj, limit)?;
362        (e, format!("projet {proj}"), proj.clone())
363    } else {
364        bail!(
365            "Préciser une source : --last, --session <ID> ou --project <NOM>.\n\
366                 Utilisez `mnemo runbook --help` pour voir les options disponibles."
367        );
368    };
369
370    // Redaction des secrets (activée par défaut).
371    let entries = if no_redact {
372        entries
373    } else {
374        let cfg = config::Config::load()?;
375        entries
376            .into_iter()
377            .map(|mut e| {
378                if let Some(finding) = secrets::analyze(&e.command, &cfg.sensitive_keywords) {
379                    e.command = finding.redacted;
380                }
381                e
382            })
383            .collect()
384    };
385
386    let resolved_title = title.unwrap_or(default_title);
387    let content = match format {
388        RunbookFormat::Markdown => {
389            render_markdown(&resolved_title, &source_desc, &entries, group_by)
390        }
391        RunbookFormat::Json => render_json(&resolved_title, &source_desc, &entries, group_by)?,
392    };
393
394    match output {
395        Some(ref path) => {
396            if path.exists() && !force {
397                bail!(
398                    "Le fichier {} existe déjà. Utilisez --force pour l'écraser.",
399                    path.display()
400                );
401            }
402            std::fs::write(path, content.as_bytes())
403                .with_context(|| format!("écriture du runbook {}", path.display()))?;
404            eprintln!(
405                "Runbook écrit dans {} ({} commandes).",
406                path.display(),
407                entries.len()
408            );
409        }
410        None => {
411            let stdout = io::stdout();
412            let mut out = stdout.lock();
413            out.write_all(content.as_bytes())?;
414        }
415    }
416    Ok(())
417}
418
419// ---------------------------------------------------------------------------
420// Tests unitaires
421// ---------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    fn make_entries(cmds: &[(&str, &str)]) -> Vec<RunbookEntry> {
428        cmds.iter()
429            .enumerate()
430            .map(|(i, (cmd, cwd))| RunbookEntry {
431                command: cmd.to_string(),
432                cwd: cwd.to_string(),
433                timestamp: format!("2026-01-01 10:{i:02}:00"),
434                git_root: None,
435            })
436            .collect()
437    }
438
439    #[test]
440    fn render_contient_titre_et_sections() {
441        let entries = make_entries(&[("cargo build", "~/proj")]);
442        let md = render_markdown("mon runbook", "session s1", &entries, GroupBy::None);
443        assert!(md.contains("# Runbook - mon runbook"));
444        assert!(md.contains("## Metadata"));
445        assert!(md.contains("## Commands"));
446        assert!(md.contains("Source: session s1"));
447        assert!(md.contains("Commands: 1"));
448    }
449
450    #[test]
451    fn render_numerote_les_sections() {
452        let entries = make_entries(&[("git pull", "~/a"), ("cargo test", "~/b")]);
453        let md = render_markdown("test", "session s1", &entries, GroupBy::None);
454        assert!(md.contains("### 1. ~/a"));
455        assert!(md.contains("### 2. ~/b"));
456        assert!(md.contains("git pull"));
457        assert!(md.contains("cargo test"));
458    }
459
460    #[test]
461    fn render_zero_commandes_reste_coherent() {
462        let md = render_markdown("vide", "session s1", &[], GroupBy::None);
463        assert!(md.contains("# Runbook - vide"));
464        assert!(md.contains("## Commands"));
465        assert!(md.contains("Commands: 0"));
466        assert!(md.contains("_Aucune commande._"));
467    }
468
469    #[test]
470    fn render_echappe_les_backticks_dans_les_blocs() {
471        let entries = make_entries(&[("echo `date`", "~/proj")]);
472        let md = render_markdown("bt", "session s1", &entries, GroupBy::None);
473        // La clôture du bloc doit être plus longue que 1 backtick.
474        let fences: Vec<&str> = md.lines().filter(|l| l.starts_with("```")).collect();
475        assert_eq!(fences.len() % 2, 0, "blocs non équilibrés");
476    }
477
478    #[test]
479    fn records_to_entries_exclut_les_commandes_vides() {
480        let records = vec![
481            db::CommandRecord {
482                id: 1,
483                command: "  ".to_string(),
484                cwd: Some("/tmp".to_string()),
485                shell: None,
486                hostname: None,
487                exit_code: Some(0),
488                created_at: "2026-01-01 10:00:00".to_string(),
489                git_root: None,
490                git_branch: None,
491                git_remote: None,
492                session_id: Some("s1".to_string()),
493            },
494            db::CommandRecord {
495                id: 2,
496                command: "ls".to_string(),
497                cwd: Some("/tmp".to_string()),
498                shell: None,
499                hostname: None,
500                exit_code: Some(0),
501                created_at: "2026-01-01 10:01:00".to_string(),
502                git_root: None,
503                git_branch: None,
504                git_remote: None,
505                session_id: Some("s1".to_string()),
506            },
507        ];
508        let entries = records_to_entries(records);
509        assert_eq!(entries.len(), 1);
510        assert_eq!(entries[0].command, "ls");
511    }
512
513    #[test]
514    fn format_json_produit_un_json_valide() {
515        let entries = make_entries(&[("cargo build", "~/proj"), ("cargo test", "~/proj")]);
516        let json = render_json("mon runbook", "session s1", &entries, GroupBy::None).unwrap();
517        let v: serde_json::Value = serde_json::from_str(&json).expect("JSON invalide");
518        assert!(v["title"].is_string());
519        assert!(v["source"].is_string());
520        assert!(v["generated_at"].is_string());
521        assert!(v["commands"].is_array());
522        assert_eq!(v["commands"].as_array().unwrap().len(), 2);
523    }
524
525    #[test]
526    fn format_json_champ_command_present() {
527        let entries = make_entries(&[("git status", "~/repo")]);
528        let json = render_json("t", "s", &entries, GroupBy::None).unwrap();
529        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
530        let cmd = &v["commands"][0];
531        assert!(cmd["command"].is_string());
532        assert!(cmd["cwd"].is_string());
533        assert!(cmd["timestamp"].is_string());
534        assert_eq!(cmd["n"], 1);
535    }
536
537    #[test]
538    fn group_by_none_liste_plate() {
539        let entries = make_entries(&[("cmd1", "~/a"), ("cmd2", "~/b"), ("cmd3", "~/a")]);
540        let md = render_markdown("t", "s", &entries, GroupBy::None);
541        assert!(md.contains("### 1. ~/a"));
542        assert!(md.contains("### 2. ~/b"));
543        assert!(md.contains("### 3. ~/a"));
544    }
545
546    #[test]
547    fn group_by_cwd_groupe_les_sections() {
548        let entries = make_entries(&[
549            ("cmd1", "~/proj/foo"),
550            ("cmd2", "~/proj/bar"),
551            ("cmd3", "~/proj/foo"),
552        ]);
553        let md = render_markdown("t", "s", &entries, GroupBy::Cwd);
554        // Deux sections de niveau 2 : une pour chaque cwd distinct
555        assert!(md.contains("## ~/proj/bar"));
556        assert!(md.contains("## ~/proj/foo"));
557        // Dans le groupe foo, les commandes sont numérotées depuis 1
558        assert!(md.contains("### 1."));
559        assert!(md.contains("### 2."));
560        // Le contenu est présent
561        assert!(md.contains("cmd1"));
562        assert!(md.contains("cmd2"));
563        assert!(md.contains("cmd3"));
564    }
565
566    #[test]
567    fn group_by_project_groupe_par_git_root() {
568        let entries = vec![
569            RunbookEntry {
570                command: "cargo build".to_string(),
571                cwd: "~/proj/a".to_string(),
572                timestamp: "2026-01-01 10:00:00".to_string(),
573                git_root: Some("/home/user/proj/a".to_string()),
574            },
575            RunbookEntry {
576                command: "npm test".to_string(),
577                cwd: "~/proj/b".to_string(),
578                timestamp: "2026-01-01 10:01:00".to_string(),
579                git_root: Some("/home/user/proj/b".to_string()),
580            },
581            RunbookEntry {
582                command: "make".to_string(),
583                cwd: "~/proj/a".to_string(),
584                timestamp: "2026-01-01 10:02:00".to_string(),
585                git_root: None,
586            },
587        ];
588        let md = render_markdown("t", "s", &entries, GroupBy::Project);
589        assert!(md.contains("(sans projet)"), "groupe sans projet attendu");
590        assert!(md.contains("cargo build"));
591        assert!(md.contains("npm test"));
592        assert!(md.contains("make"));
593    }
594
595    #[test]
596    fn group_by_json_ajoute_champ_group() {
597        let entries = make_entries(&[("cmd1", "~/a"), ("cmd2", "~/b")]);
598        let json = render_json("t", "s", &entries, GroupBy::Cwd).unwrap();
599        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
600        let cmds = v["commands"].as_array().unwrap();
601        for cmd in cmds {
602            assert!(cmd["group"].is_string(), "champ group manquant");
603        }
604    }
605
606    #[test]
607    fn group_by_json_none_pas_de_champ_group() {
608        let entries = make_entries(&[("cmd1", "~/a")]);
609        let json = render_json("t", "s", &entries, GroupBy::None).unwrap();
610        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
611        let cmd = &v["commands"][0];
612        assert!(
613            cmd.get("group").is_none() || cmd["group"].is_null(),
614            "group ne doit pas être présent en mode None"
615        );
616    }
617}