1use anyhow::{bail, Context, Result};
13use serde::Serialize;
14use std::io::{self, Write};
15use std::path::PathBuf;
16
17use crate::cli::SessionFormat;
18use crate::config;
19use crate::db::{self, CommandRecord};
20use crate::mdfmt::{
21 display_home, md_code_block, md_inline_code, md_table_cell_code, md_table_cell_text, opt,
22 opt_home, short_datetime, time_part,
23};
24
25struct SessionMeta {
27 session_id: String,
28 count: usize,
29 started_at: String,
30 ended_at: String,
31 git_root: Option<String>,
32 git_branch: Option<String>,
33}
34
35pub fn run_list(limit: Option<usize>) -> Result<()> {
38 let conn = db::open(&config::db_path()?)?;
39 let sessions = db::session_summaries(&conn, limit)?;
40
41 let stdout = io::stdout();
42 let mut out = stdout.lock();
43
44 if sessions.is_empty() {
45 writeln!(out, "Aucune session enregistrée.")?;
46 writeln!(
47 out,
48 "Les commandes importées ou enregistrées sans MNEMO_SESSION_ID ne sont"
49 )?;
50 writeln!(
51 out,
52 "pas rattachées à une session. Réinstallez l'intégration shell"
53 )?;
54 writeln!(out, "(`mnemo init`) pour capturer les prochaines sessions.")?;
55 return Ok(());
56 }
57
58 writeln!(out, "Sessions récentes")?;
59 writeln!(out)?;
60 writeln!(
61 out,
62 "{:<24} {:>9} {:<16} {:<16} PROJET",
63 "SESSION ID", "COMMANDES", "DÉBUT", "FIN"
64 )?;
65 for s in &sessions {
66 let projet = s
67 .git_root
68 .as_deref()
69 .filter(|r| !r.is_empty())
70 .map(display_home)
71 .unwrap_or_else(|| "-".to_string());
72 writeln!(
73 out,
74 "{:<24} {:>9} {:<16} {:<16} {}",
75 s.session_id,
76 s.count,
77 short_datetime(&s.started_at),
78 short_datetime(&s.ended_at),
79 projet
80 )?;
81 }
82 Ok(())
83}
84
85pub fn run_show(session_id: String, limit: Option<usize>) -> Result<()> {
88 let conn = db::open(&config::db_path()?)?;
89 let all = db::session_commands(&conn, &session_id, None)?;
92 if all.is_empty() {
93 bail!("Session introuvable : {session_id}");
94 }
95 let meta = meta_from_commands(&session_id, &all);
96
97 let stdout = io::stdout();
98 let mut out = stdout.lock();
99
100 writeln!(out, "Session {}", meta.session_id)?;
101 writeln!(out, "Projet : {}", opt_home(&meta.git_root))?;
102 writeln!(out, "Branche : {}", opt(&meta.git_branch))?;
103 writeln!(out, "Commandes : {}", meta.count)?;
104 writeln!(out, "Début : {}", short_datetime(&meta.started_at))?;
105 writeln!(out, "Fin : {}", short_datetime(&meta.ended_at))?;
106 writeln!(out)?;
107
108 let shown = match limit {
109 Some(n) => &all[..all.len().min(n)],
110 None => &all[..],
111 };
112 for c in shown {
113 match c.exit_code {
116 Some(code) if code != 0 => {
117 writeln!(
118 out,
119 "[{}] {} (exit {code})",
120 time_part(&c.created_at),
121 c.command
122 )?;
123 }
124 _ => writeln!(out, "[{}] {}", time_part(&c.created_at), c.command)?,
125 }
126 }
127 Ok(())
128}
129
130pub fn run_export(
132 session_id: Option<String>,
133 last: bool,
134 format: SessionFormat,
135 output: Option<PathBuf>,
136 force: bool,
137) -> Result<()> {
138 let conn = db::open(&config::db_path()?)?;
139
140 let session_id = resolve_session_id(&conn, session_id, last)?;
141 let cmds = db::session_commands(&conn, &session_id, None)?;
142 if cmds.is_empty() {
143 bail!("Session introuvable : {session_id}");
144 }
145 let meta = meta_from_commands(&session_id, &cmds);
146
147 let content = match format {
148 SessionFormat::Markdown => render_markdown(&meta, &cmds),
149 SessionFormat::Json => render_json(&meta, &cmds)?,
150 };
151
152 match output {
153 Some(path) => {
154 if path.exists() && !force {
155 bail!(
156 "Le fichier {} existe déjà. Utilisez --force pour l'écraser.",
157 path.display()
158 );
159 }
160 std::fs::write(&path, content.as_bytes())
161 .with_context(|| format!("écriture de l'export {}", path.display()))?;
162 eprintln!(
163 "Session {} exportée dans {} ({} commandes).",
164 meta.session_id,
165 path.display(),
166 meta.count
167 );
168 }
169 None => {
170 let stdout = io::stdout();
171 let mut out = stdout.lock();
172 out.write_all(content.as_bytes())?;
173 }
174 }
175 Ok(())
176}
177
178fn resolve_session_id(
180 conn: &rusqlite::Connection,
181 session_id: Option<String>,
182 last: bool,
183) -> Result<String> {
184 if last {
185 return match db::latest_session_id(conn)? {
186 Some(id) => Ok(id),
187 None => bail!(
188 "Aucune session trouvée. Les commandes importées ou enregistrées \
189 sans MNEMO_SESSION_ID ne sont pas rattachées à une session."
190 ),
191 };
192 }
193 match session_id {
194 Some(id) => Ok(id),
195 None => bail!("Préciser un identifiant de session ou utiliser --last."),
196 }
197}
198
199fn meta_from_commands(session_id: &str, cmds: &[CommandRecord]) -> SessionMeta {
202 let started_at = cmds
203 .first()
204 .map(|c| c.created_at.clone())
205 .unwrap_or_default();
206 let ended_at = cmds
207 .last()
208 .map(|c| c.created_at.clone())
209 .unwrap_or_default();
210 let last = cmds.last();
211 SessionMeta {
212 session_id: session_id.to_string(),
213 count: cmds.len(),
214 started_at,
215 ended_at,
216 git_root: last
217 .and_then(|c| c.git_root.clone())
218 .filter(|s| !s.is_empty()),
219 git_branch: last
220 .and_then(|c| c.git_branch.clone())
221 .filter(|s| !s.is_empty()),
222 }
223}
224
225fn render_markdown(meta: &SessionMeta, cmds: &[CommandRecord]) -> String {
228 let mut out = String::new();
229 out.push_str("# Session mnemo\n\n");
230 out.push_str(&format!(
231 "- Session : {}\n",
232 md_inline_code(&meta.session_id)
233 ));
234 out.push_str(&format!("- Début : {}\n", short_datetime(&meta.started_at)));
235 out.push_str(&format!("- Fin : {}\n", short_datetime(&meta.ended_at)));
236 out.push_str(&format!("- Commandes : {}\n", meta.count));
237 out.push_str(&format!("- Projet : {}\n", opt_home(&meta.git_root)));
238 out.push_str(&format!("- Branche : {}\n", opt(&meta.git_branch)));
239 out.push('\n');
240
241 out.push_str("## Commandes\n\n");
242 let commands: Vec<String> = cmds.iter().map(|c| c.command.clone()).collect();
243 out.push_str(&md_code_block(&commands));
244 out.push('\n');
245
246 out.push_str("## Détail chronologique\n\n");
247 out.push_str("| Heure | Code retour | Dossier | Commande |\n");
248 out.push_str("| --- | ---: | --- | --- |\n");
249 for c in cmds {
250 let code = c
251 .exit_code
252 .map(|c| c.to_string())
253 .unwrap_or_else(|| "-".to_string());
254 let dossier = c
255 .cwd
256 .as_deref()
257 .or(c.git_root.as_deref())
258 .filter(|s| !s.is_empty())
259 .map(display_home)
260 .unwrap_or_else(|| "-".to_string());
261 out.push_str(&format!(
262 "| {} | {} | {} | {} |\n",
263 time_part(&c.created_at),
264 code,
265 md_table_cell_text(&dossier),
266 md_table_cell_code(&c.command)
267 ));
268 }
269 out
270}
271
272#[derive(Serialize)]
274struct SessionJson<'a> {
275 session_id: &'a str,
276 started_at: &'a str,
277 ended_at: &'a str,
278 command_count: usize,
279 git_root: Option<&'a str>,
280 git_branch: Option<&'a str>,
281 commands: Vec<SessionCommandJson<'a>>,
282}
283
284#[derive(Serialize)]
286struct SessionCommandJson<'a> {
287 created_at: &'a str,
288 cwd: Option<&'a str>,
289 exit_code: Option<i64>,
290 git_branch: Option<&'a str>,
291 command: &'a str,
292}
293
294fn render_json(meta: &SessionMeta, cmds: &[CommandRecord]) -> Result<String> {
296 let commands = cmds
297 .iter()
298 .map(|c| SessionCommandJson {
299 created_at: &c.created_at,
300 cwd: c.cwd.as_deref(),
301 exit_code: c.exit_code,
302 git_branch: c.git_branch.as_deref(),
303 command: &c.command,
304 })
305 .collect();
306 let doc = SessionJson {
307 session_id: &meta.session_id,
308 started_at: &meta.started_at,
309 ended_at: &meta.ended_at,
310 command_count: meta.count,
311 git_root: meta.git_root.as_deref(),
312 git_branch: meta.git_branch.as_deref(),
313 commands,
314 };
315 Ok(serde_json::to_string_pretty(&doc)?)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn meta_depuis_commandes_prend_les_bornes() {
324 let cmds = vec![
325 CommandRecord {
326 id: 1,
327 command: "a".into(),
328 cwd: None,
329 shell: None,
330 hostname: None,
331 exit_code: Some(0),
332 created_at: "2026-06-23 10:00:00".into(),
333 git_root: Some("/home/u/proj".into()),
334 git_branch: Some("main".into()),
335 git_remote: None,
336 session_id: Some("s1".into()),
337 },
338 CommandRecord {
339 id: 2,
340 command: "b".into(),
341 cwd: None,
342 shell: None,
343 hostname: None,
344 exit_code: Some(1),
345 created_at: "2026-06-23 10:05:00".into(),
346 git_root: Some("/home/u/proj".into()),
347 git_branch: Some("main".into()),
348 git_remote: None,
349 session_id: Some("s1".into()),
350 },
351 ];
352 let meta = meta_from_commands("s1", &cmds);
353 assert_eq!(meta.count, 2);
354 assert_eq!(meta.started_at, "2026-06-23 10:00:00");
355 assert_eq!(meta.ended_at, "2026-06-23 10:05:00");
356 assert_eq!(meta.git_branch.as_deref(), Some("main"));
357 }
358}