1use 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#[derive(Debug, Clone)]
20pub struct RunbookEntry {
21 pub command: String,
23 pub cwd: String,
25 pub timestamp: String,
27}
28
29pub 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
50pub 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
66pub 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 let records = db::project_records(conn, root, None, None, false, None)?;
84 all.extend(records);
85 }
86 all.sort_by(|a, b| {
88 a.created_at
89 .cmp(&b.created_at)
90 .then_with(|| a.id.cmp(&b.id))
91 });
92 if let Some(n) = limit {
94 all.truncate(n as usize);
95 }
96 Ok(records_to_entries(all))
97}
98
99fn 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
118pub 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
157pub 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#[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 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}