Skip to main content

mnemo/
runbook.rs

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