1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
18pub enum ExportFormat {
19 Json,
20 Csv,
21}
22
23#[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
57pub 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
102fn 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
114fn 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
121fn 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
127pub 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
134const 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
149fn 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
177fn 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}