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
9use anyhow::{bail, Context, Result};
10use rusqlite::Connection;
11use std::io::{self, Write};
12use std::path::PathBuf;
13
14use crate::config;
15use crate::db;
16use crate::mdfmt::{display_home, md_code_block};
17
18/// Une entrée du runbook : une commande avec son contexte minimal.
19#[derive(Debug, Clone)]
20pub struct RunbookEntry {
21    /// Commande shell (toujours non vide après filtrage).
22    pub command: String,
23    /// Répertoire de travail au moment de l'exécution (raccourci `~/…`).
24    pub cwd: String,
25    /// Horodatage de la commande (`YYYY-MM-DD HH:MM:SS`).
26    pub timestamp: String,
27}
28
29// ---------------------------------------------------------------------------
30// Fonctions de récupération
31// ---------------------------------------------------------------------------
32
33/// Charge les commandes de la dernière session, en ordre chronologique croissant.
34/// Exclut les commandes vides (après trim).
35///
36/// Renvoie une erreur si aucune session n'est enregistrée.
37pub fn fetch_last_session_commands(
38    conn: &Connection,
39    limit: Option<u32>,
40) -> Result<Vec<RunbookEntry>> {
41    let session_id = db::latest_session_id(conn)?.ok_or_else(|| {
42        anyhow::anyhow!(
43            "Aucune session trouvée. Les commandes importées ou enregistrées \
44             sans MNEMO_SESSION_ID ne sont pas rattachées à une session."
45        )
46    })?;
47    fetch_session_commands(conn, &session_id, limit)
48}
49
50/// Charge les commandes d'une session explicite, en ordre chronologique croissant.
51///
52/// Renvoie une erreur si la session est introuvable.
53pub fn fetch_session_commands(
54    conn: &Connection,
55    session_id: &str,
56    limit: Option<u32>,
57) -> Result<Vec<RunbookEntry>> {
58    let lim = limit.map(|n| n as usize);
59    let records = db::session_commands(conn, session_id, lim)?;
60    if records.is_empty() {
61        bail!("Session introuvable : {session_id}");
62    }
63    Ok(records_to_entries(records))
64}
65
66/// Charge les commandes d'un projet (par nom court ou chemin `git_root`), en
67/// ordre chronologique croissant. Toutes les racines correspondantes sont
68/// agrégées avant tri.
69///
70/// Renvoie une erreur si aucune racine ne correspond à `name_or_path`.
71pub fn fetch_project_commands(
72    conn: &Connection,
73    name_or_path: &str,
74    limit: Option<u32>,
75) -> Result<Vec<RunbookEntry>> {
76    let roots = db::match_project_roots(conn, name_or_path)?;
77    if roots.is_empty() {
78        bail!("Projet introuvable : {name_or_path}");
79    }
80    let mut all: Vec<db::CommandRecord> = Vec::new();
81    for root in &roots {
82        // project_records retourne DESC ; on collecte tout et on trie ensuite.
83        let records = db::project_records(conn, root, None, None, false, None)?;
84        all.extend(records);
85    }
86    // Tri chronologique croissant (le plus ancien en premier).
87    all.sort_by(|a, b| {
88        a.created_at
89            .cmp(&b.created_at)
90            .then_with(|| a.id.cmp(&b.id))
91    });
92    // Appliquer la limite après le tri.
93    if let Some(n) = limit {
94        all.truncate(n as usize);
95    }
96    Ok(records_to_entries(all))
97}
98
99/// Convertit des [`db::CommandRecord`] en [`RunbookEntry`], en excluant les
100/// commandes vides (après trim).
101fn records_to_entries(records: Vec<db::CommandRecord>) -> Vec<RunbookEntry> {
102    records
103        .into_iter()
104        .filter(|r| !r.command.trim().is_empty())
105        .map(|r| RunbookEntry {
106            command: r.command,
107            cwd: r
108                .cwd
109                .as_deref()
110                .filter(|s| !s.is_empty())
111                .map(display_home)
112                .unwrap_or_else(|| "-".to_string()),
113            timestamp: r.created_at,
114        })
115        .collect()
116}
117
118// ---------------------------------------------------------------------------
119// Rendu Markdown
120// ---------------------------------------------------------------------------
121
122/// Génère le document Markdown d'un runbook.
123///
124/// - `title` : titre du runbook (remplace la section `# Runbook - …`).
125/// - `source_desc` : description lisible de la source (session, projet…).
126/// - `entries` : commandes à inclure (les vides ont déjà été exclues en amont).
127///
128/// Quand `entries` est vide, le document reste cohérent (section Commands avec
129/// un message explicite, pas de panic).
130pub fn render_markdown(title: &str, source_desc: &str, entries: &[RunbookEntry]) -> String {
131    let generated_at = db::now_timestamp();
132    let mut out = String::new();
133
134    out.push_str(&format!("# Runbook - {title}\n\n"));
135
136    out.push_str("## Metadata\n\n");
137    out.push_str(&format!("- Source: {source_desc}\n"));
138    out.push_str(&format!("- Generated at: {generated_at}\n"));
139    out.push_str(&format!("- Commands: {}\n\n", entries.len()));
140
141    out.push_str("## Commands\n\n");
142
143    if entries.is_empty() {
144        out.push_str("_Aucune commande._\n");
145        return out;
146    }
147
148    for (i, entry) in entries.iter().enumerate() {
149        out.push_str(&format!("### {}. {}\n\n", i + 1, entry.cwd));
150        out.push_str(&md_code_block(std::slice::from_ref(&entry.command)));
151        out.push('\n');
152    }
153
154    out
155}
156
157// ---------------------------------------------------------------------------
158// Point d'entrée de la commande
159// ---------------------------------------------------------------------------
160
161/// Point d'entrée de `mnemo runbook`.
162///
163/// Exactement un des drapeaux `last`, `session`, `project` doit être fourni
164/// (mutuellement exclusifs côté clap). Si aucun n'est fourni, une erreur claire
165/// est retournée.
166pub fn run(
167    last: bool,
168    session: Option<String>,
169    project: Option<String>,
170    output: Option<PathBuf>,
171    force: bool,
172    limit: Option<u32>,
173    title: Option<String>,
174) -> Result<()> {
175    let conn = db::open(&config::db_path()?)?;
176
177    let (entries, source_desc, default_title): (Vec<RunbookEntry>, String, String) = if last {
178        let sid = db::latest_session_id(&conn)?.ok_or_else(|| {
179            anyhow::anyhow!(
180                "Aucune session trouvée. Les commandes importées ou enregistrées \
181                     sans MNEMO_SESSION_ID ne sont pas rattachées à une session."
182            )
183        })?;
184        let e = fetch_session_commands(&conn, &sid, limit)?;
185        let desc = format!("dernière session ({sid})");
186        let dtitle = sid.clone();
187        (e, desc, dtitle)
188    } else if let Some(ref sid) = session {
189        let e = fetch_session_commands(&conn, sid, limit)?;
190        (e, format!("session {sid}"), sid.clone())
191    } else if let Some(ref proj) = project {
192        let e = fetch_project_commands(&conn, proj, limit)?;
193        (e, format!("projet {proj}"), proj.clone())
194    } else {
195        bail!(
196            "Préciser une source : --last, --session <ID> ou --project <NOM>.\n\
197                 Utilisez `mnemo runbook --help` pour voir les options disponibles."
198        );
199    };
200
201    let resolved_title = title.unwrap_or(default_title);
202    let content = render_markdown(&resolved_title, &source_desc, &entries);
203
204    match output {
205        Some(ref path) => {
206            if path.exists() && !force {
207                bail!(
208                    "Le fichier {} existe déjà. Utilisez --force pour l'écraser.",
209                    path.display()
210                );
211            }
212            std::fs::write(path, content.as_bytes())
213                .with_context(|| format!("écriture du runbook {}", path.display()))?;
214            eprintln!(
215                "Runbook écrit dans {} ({} commandes).",
216                path.display(),
217                entries.len()
218            );
219        }
220        None => {
221            let stdout = io::stdout();
222            let mut out = stdout.lock();
223            out.write_all(content.as_bytes())?;
224        }
225    }
226    Ok(())
227}
228
229// ---------------------------------------------------------------------------
230// Tests unitaires
231// ---------------------------------------------------------------------------
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn make_entries(cmds: &[(&str, &str)]) -> Vec<RunbookEntry> {
238        cmds.iter()
239            .enumerate()
240            .map(|(i, (cmd, cwd))| RunbookEntry {
241                command: cmd.to_string(),
242                cwd: cwd.to_string(),
243                timestamp: format!("2026-01-01 10:{i:02}:00"),
244            })
245            .collect()
246    }
247
248    #[test]
249    fn render_contient_titre_et_sections() {
250        let entries = make_entries(&[("cargo build", "~/proj")]);
251        let md = render_markdown("mon runbook", "session s1", &entries);
252        assert!(md.contains("# Runbook - mon runbook"));
253        assert!(md.contains("## Metadata"));
254        assert!(md.contains("## Commands"));
255        assert!(md.contains("Source: session s1"));
256        assert!(md.contains("Commands: 1"));
257    }
258
259    #[test]
260    fn render_numerote_les_sections() {
261        let entries = make_entries(&[("git pull", "~/a"), ("cargo test", "~/b")]);
262        let md = render_markdown("test", "session s1", &entries);
263        assert!(md.contains("### 1. ~/a"));
264        assert!(md.contains("### 2. ~/b"));
265        assert!(md.contains("git pull"));
266        assert!(md.contains("cargo test"));
267    }
268
269    #[test]
270    fn render_zero_commandes_reste_coherent() {
271        let md = render_markdown("vide", "session s1", &[]);
272        assert!(md.contains("# Runbook - vide"));
273        assert!(md.contains("## Commands"));
274        assert!(md.contains("Commands: 0"));
275        assert!(md.contains("_Aucune commande._"));
276    }
277
278    #[test]
279    fn render_echappe_les_backticks_dans_les_blocs() {
280        let entries = make_entries(&[("echo `date`", "~/proj")]);
281        let md = render_markdown("bt", "session s1", &entries);
282        // La clôture du bloc doit être plus longue que 1 backtick.
283        let fences: Vec<&str> = md.lines().filter(|l| l.starts_with("```")).collect();
284        assert_eq!(fences.len() % 2, 0, "blocs non équilibrés");
285    }
286
287    #[test]
288    fn records_to_entries_exclut_les_commandes_vides() {
289        let records = vec![
290            db::CommandRecord {
291                id: 1,
292                command: "  ".to_string(),
293                cwd: Some("/tmp".to_string()),
294                shell: None,
295                hostname: None,
296                exit_code: Some(0),
297                created_at: "2026-01-01 10:00:00".to_string(),
298                git_root: None,
299                git_branch: None,
300                git_remote: None,
301                session_id: Some("s1".to_string()),
302            },
303            db::CommandRecord {
304                id: 2,
305                command: "ls".to_string(),
306                cwd: Some("/tmp".to_string()),
307                shell: None,
308                hostname: None,
309                exit_code: Some(0),
310                created_at: "2026-01-01 10:01:00".to_string(),
311                git_root: None,
312                git_branch: None,
313                git_remote: None,
314                session_id: Some("s1".to_string()),
315            },
316        ];
317        let entries = records_to_entries(records);
318        assert_eq!(entries.len(), 1);
319        assert_eq!(entries[0].command, "ls");
320    }
321}