1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
17pub enum RunbookFormat {
18 Markdown,
20 Json,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
26pub enum GroupBy {
27 None,
29 Cwd,
31 Project,
33}
34
35#[derive(Debug, Clone)]
37pub struct RunbookEntry {
38 pub command: String,
40 pub cwd: String,
42 pub timestamp: String,
44 pub git_root: Option<String>,
46}
47
48pub 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
65pub 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
81pub 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
111fn 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
131pub 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#[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#[derive(Serialize)]
200struct JsonRunbook<'a> {
201 title: &'a str,
202 source: &'a str,
203 generated_at: String,
204 commands: Vec<JsonCommand<'a>>,
205}
206
207pub 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
261fn 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#[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}