Skip to main content

mnemo/
export.rs

1//! Export des commandes (`mnemo export`) en JSON ou CSV.
2//!
3//! Réutilise les filtres de contexte Git `--project` / `--branch`. Sans
4//! `--output`, l'export est écrit sur stdout ; sinon dans le fichier indiqué.
5
6use anyhow::{Context, Result};
7use flate2::write::GzEncoder;
8use flate2::Compression;
9use serde::Serialize;
10use std::io::Write;
11use std::path::PathBuf;
12
13use crate::config;
14use crate::db::{self, CommandRecord, SearchFilter};
15
16/// Format d'export demandé.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
18pub enum ExportFormat {
19    Json,
20    Csv,
21}
22
23/// Ligne d'export sérialisable (tous les champs utiles d'une commande).
24#[derive(Serialize)]
25struct ExportRow<'a> {
26    id: i64,
27    command: &'a str,
28    cwd: Option<&'a str>,
29    shell: Option<&'a str>,
30    hostname: Option<&'a str>,
31    exit_code: Option<i64>,
32    created_at: &'a str,
33    git_root: Option<&'a str>,
34    git_branch: Option<&'a str>,
35    git_remote: Option<&'a str>,
36    session_id: Option<&'a str>,
37}
38
39impl<'a> From<&'a CommandRecord> for ExportRow<'a> {
40    fn from(r: &'a CommandRecord) -> Self {
41        ExportRow {
42            id: r.id,
43            command: &r.command,
44            cwd: r.cwd.as_deref(),
45            shell: r.shell.as_deref(),
46            hostname: r.hostname.as_deref(),
47            exit_code: r.exit_code,
48            created_at: &r.created_at,
49            git_root: r.git_root.as_deref(),
50            git_branch: r.git_branch.as_deref(),
51            git_remote: r.git_remote.as_deref(),
52            session_id: r.session_id.as_deref(),
53        }
54    }
55}
56
57/// Point d'entrée de `mnemo export`.
58pub fn run(
59    format: ExportFormat,
60    project: Option<String>,
61    branch: Option<String>,
62    output: Option<PathBuf>,
63    gzip: bool,
64) -> Result<()> {
65    let conn = db::open(&config::db_path()?)?;
66    let filter = SearchFilter { project, branch };
67    let records = db::all_commands(&conn, &filter)?;
68
69    let content = match format {
70        ExportFormat::Json => render_json(&records)?,
71        ExportFormat::Csv => render_csv(&records),
72    };
73
74    match output {
75        Some(path) => {
76            let path = if gzip { gz_path(path) } else { path };
77            let bytes = if gzip {
78                gzip_bytes(content.as_bytes())?
79            } else {
80                content.into_bytes()
81            };
82            std::fs::write(&path, &bytes)
83                .with_context(|| format!("écriture de l'export {}", path.display()))?;
84            eprintln!(
85                "Export écrit dans {} ({} commandes).",
86                path.display(),
87                records.len()
88            );
89        }
90        None => {
91            let mut stdout = std::io::stdout().lock();
92            if gzip {
93                stdout.write_all(&gzip_bytes(content.as_bytes())?)?;
94            } else {
95                stdout.write_all(content.as_bytes())?;
96            }
97        }
98    }
99    Ok(())
100}
101
102/// Ajoute l'extension `.gz` à un chemin de sortie si elle est absente.
103fn gz_path(path: PathBuf) -> PathBuf {
104    match path.extension().and_then(|e| e.to_str()) {
105        Some("gz") => path,
106        _ => {
107            let mut name = path.into_os_string();
108            name.push(".gz");
109            PathBuf::from(name)
110        }
111    }
112}
113
114/// Compresse des octets au format gzip.
115fn gzip_bytes(data: &[u8]) -> Result<Vec<u8>> {
116    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
117    encoder.write_all(data)?;
118    Ok(encoder.finish()?)
119}
120
121/// Sérialise les commandes en tableau JSON.
122fn render_json(records: &[CommandRecord]) -> Result<String> {
123    let rows: Vec<ExportRow> = records.iter().map(ExportRow::from).collect();
124    Ok(serde_json::to_string_pretty(&rows)?)
125}
126
127/// Sérialise une sélection de commandes en JSON stable (réutilisé par
128/// `mnemo search --print --json`).
129pub fn records_to_json(records: &[&CommandRecord]) -> Result<String> {
130    let rows: Vec<ExportRow> = records.iter().map(|r| ExportRow::from(*r)).collect();
131    Ok(serde_json::to_string_pretty(&rows)?)
132}
133
134/// En-têtes CSV, dans l'ordre des colonnes.
135const CSV_HEADERS: &[&str] = &[
136    "id",
137    "command",
138    "cwd",
139    "shell",
140    "hostname",
141    "exit_code",
142    "created_at",
143    "git_root",
144    "git_branch",
145    "git_remote",
146    "session_id",
147];
148
149/// Sérialise les commandes en CSV (RFC 4180 : guillemets doublés, champs
150/// contenant `,`/`"`/saut de ligne entre guillemets).
151fn render_csv(records: &[CommandRecord]) -> String {
152    let mut out = String::new();
153    out.push_str(&CSV_HEADERS.join(","));
154    out.push('\n');
155
156    for r in records {
157        let fields = [
158            r.id.to_string(),
159            r.command.clone(),
160            r.cwd.clone().unwrap_or_default(),
161            r.shell.clone().unwrap_or_default(),
162            r.hostname.clone().unwrap_or_default(),
163            r.exit_code.map(|c| c.to_string()).unwrap_or_default(),
164            r.created_at.clone(),
165            r.git_root.clone().unwrap_or_default(),
166            r.git_branch.clone().unwrap_or_default(),
167            r.git_remote.clone().unwrap_or_default(),
168            r.session_id.clone().unwrap_or_default(),
169        ];
170        let escaped: Vec<String> = fields.iter().map(|f| csv_escape(f)).collect();
171        out.push_str(&escaped.join(","));
172        out.push('\n');
173    }
174    out
175}
176
177/// Échappe un champ CSV selon RFC 4180.
178fn csv_escape(field: &str) -> String {
179    if field.contains([',', '"', '\n', '\r']) {
180        format!("\"{}\"", field.replace('"', "\"\""))
181    } else {
182        field.to_string()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn csv_echappe_virgules_et_guillemets() {
192        assert_eq!(csv_escape("simple"), "simple");
193        assert_eq!(csv_escape("a,b"), "\"a,b\"");
194        assert_eq!(csv_escape("dit \"bonjour\""), "\"dit \"\"bonjour\"\"\"");
195        assert_eq!(csv_escape("ligne1\nligne2"), "\"ligne1\nligne2\"");
196    }
197
198    #[test]
199    fn csv_a_un_entete_propre() {
200        let out = render_csv(&[]);
201        assert_eq!(
202            out.trim(),
203            "id,command,cwd,shell,hostname,exit_code,created_at,git_root,git_branch,git_remote,session_id"
204        );
205    }
206
207    #[test]
208    fn gzip_roundtrip_conserve_le_contenu() {
209        use flate2::read::GzDecoder;
210        use std::io::Read;
211
212        let original = b"id,command\n1,ls -la\n";
213        let compressed = gzip_bytes(original).unwrap();
214        assert_ne!(compressed, original, "les octets doivent être compressés");
215
216        let mut decoder = GzDecoder::new(&compressed[..]);
217        let mut restored = Vec::new();
218        decoder.read_to_end(&mut restored).unwrap();
219        assert_eq!(restored, original);
220    }
221
222    #[test]
223    fn gz_path_ajoute_extension_si_absente() {
224        assert_eq!(gz_path(PathBuf::from("e.json")), PathBuf::from("e.json.gz"));
225        assert_eq!(
226            gz_path(PathBuf::from("e.json.gz")),
227            PathBuf::from("e.json.gz")
228        );
229    }
230}