1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
29pub enum RunbookFormat {
30 Markdown,
32 Json,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
38pub enum GroupBy {
39 None,
41 Cwd,
43 Project,
45}
46
47#[derive(Debug, Clone)]
53pub struct RunbookEntry {
54 pub command: String,
56 pub cwd: String,
58 pub timestamp: String,
60 pub git_root: Option<String>,
62}
63
64pub 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
85pub 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
101pub 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 let records = db::project_records(conn, root, None, None, false, None)?;
119 all.extend(records);
120 }
121 all.sort_by(|a, b| {
123 a.created_at
124 .cmp(&b.created_at)
125 .then_with(|| a.id.cmp(&b.id))
126 });
127 if let Some(n) = limit {
129 all.truncate(n as usize);
130 }
131 Ok(records_to_entries(all))
132}
133
134fn 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
154pub 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#[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#[derive(Serialize)]
231struct JsonRunbook<'a> {
232 title: &'a str,
233 source: &'a str,
234 generated_at: String,
235 commands: Vec<JsonCommand<'a>>,
236}
237
238pub 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
292fn 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#[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 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#[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 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 assert!(md.contains("## ~/proj/bar"));
556 assert!(md.contains("## ~/proj/foo"));
557 assert!(md.contains("### 1."));
559 assert!(md.contains("### 2."));
560 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}