Skip to main content

mnemo/
session.rs

1//! Commande `mnemo session` : navigation, consultation et export des sessions
2//! de travail.
3//!
4//! Une session regroupe les commandes partageant un même `session_id`, capturé
5//! par l'intégration shell via `MNEMO_SESSION_ID`. Les commandes importées ou
6//! enregistrées sans cet identifiant ne sont pas rattachées à une session et
7//! sont ignorées par ces commandes (jamais de session artificielle).
8//!
9//! Cette feature est en lecture seule : elle n'écrit dans la base aucune donnée
10//! et ne modifie pas le schéma.
11
12use 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
25/// Métadonnées agrégées d'une session, dérivées de ses commandes.
26struct 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
35/// `mnemo session list` : liste les sessions, de la plus récente à la plus
36/// ancienne.
37pub 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
85/// `mnemo session show <id>` : affiche les commandes d'une session dans l'ordre
86/// chronologique.
87pub fn run_show(session_id: String, limit: Option<usize>) -> Result<()> {
88    let conn = db::open(&config::db_path()?)?;
89    // On charge toutes les commandes pour des métadonnées exactes (nombre,
90    // bornes temporelles), puis on n'affiche que les `limit` premières.
91    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        // Le code de sortie n'est affiché que pour les échecs, afin de garder la
114        // liste lisible (le succès est le cas courant).
115        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
130/// `mnemo session export` : exporte une session en Markdown (défaut) ou JSON.
131pub 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
178/// Détermine la session ciblée à partir d'un identifiant explicite ou `--last`.
179fn 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
199/// Construit les métadonnées d'une session à partir de ses commandes triées par
200/// ordre chronologique croissant.
201fn 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
225/// Rendu Markdown d'une session, directement réutilisable (documentation,
226/// audit, procédure).
227fn 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/// Document JSON exporté pour une session.
273#[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/// Commande sérialisée dans l'export JSON d'une session.
285#[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
294/// Rendu JSON stable et lisible d'une session.
295fn 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}