1use anyhow::{bail, Context, Result};
14use flate2::read::GzDecoder;
15use flate2::write::GzEncoder;
16use flate2::Compression;
17use serde::{Deserialize, Serialize};
18use std::fs::{self, File};
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use crate::{config, confirm, db, migrations};
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BackupMetadata {
26 pub mnemo_version: String,
27 pub created_at: String,
28 pub db_path: String,
29 pub config_path: String,
30 pub db_size_bytes: u64,
31 pub command_count: i64,
32 pub schema_version: i64,
33}
34
35#[derive(Debug, Clone)]
37pub struct BackupInfo {
38 pub path: PathBuf,
39 pub metadata: BackupMetadata,
40}
41
42fn timestamp_parts() -> (u64, String) {
44 let secs = SystemTime::now()
45 .duration_since(UNIX_EPOCH)
46 .map(|d| d.as_secs())
47 .unwrap_or(0);
48 (secs, db::format_timestamp(secs))
49}
50
51fn iso_from(formatted: &str) -> String {
53 format!("{}Z", formatted.replacen(' ', "T", 1))
54}
55
56fn archive_filename(formatted: &str) -> String {
58 let digits: String = formatted.chars().filter(|c| c.is_ascii_digit()).collect();
59 let (date, time) = digits.split_at(8.min(digits.len()));
60 format!("mnemo-backup-{date}-{time}.tar.gz")
61}
62
63fn unique_path(path: PathBuf) -> PathBuf {
66 if !path.exists() {
67 return path;
68 }
69 let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
70 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
71 let stem = name.strip_suffix(".tar.gz").unwrap_or(name);
72 for i in 1.. {
73 let candidate = parent.join(format!("{stem}-{i}.tar.gz"));
74 if !candidate.exists() {
75 return candidate;
76 }
77 }
78 path
79}
80
81pub fn create_backup(dest_dir: Option<&Path>) -> Result<BackupInfo> {
85 let db_path = config::db_path()?;
86 let config_path = config::config_path()?;
87
88 let dest = match dest_dir {
89 Some(d) => d.to_path_buf(),
90 None => config::data_dir()?.join("backups"),
91 };
92 fs::create_dir_all(&dest)
93 .with_context(|| format!("création du dossier de sauvegarde {}", dest.display()))?;
94 config::harden_dir(&dest);
95
96 let (secs, formatted) = timestamp_parts();
97
98 let (command_count, schema_version) = {
101 let conn = db::open(&db_path)?;
102 let count = db::count(&conn)?;
103 let ver = migrations::schema_version(&conn)?;
104 (count, ver)
105 };
106 let db_size_bytes = fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
107
108 let metadata = BackupMetadata {
109 mnemo_version: env!("CARGO_PKG_VERSION").to_string(),
110 created_at: iso_from(&formatted),
111 db_path: db_path.display().to_string(),
112 config_path: config_path.display().to_string(),
113 db_size_bytes,
114 command_count,
115 schema_version,
116 };
117
118 let archive_path = dest.join(archive_filename(&formatted));
119 let archive_path = unique_path(archive_path);
120 write_archive(&archive_path, &db_path, &config_path, &metadata, secs)?;
121 config::harden_file(&archive_path);
123 Ok(BackupInfo {
124 path: archive_path,
125 metadata,
126 })
127}
128
129pub fn backups_dir() -> Result<PathBuf> {
131 Ok(config::data_dir()?.join("backups"))
132}
133
134pub fn list_archives(dir: &Path) -> Vec<PathBuf> {
139 let mut archives = Vec::new();
140 if let Ok(entries) = fs::read_dir(dir) {
141 for entry in entries.flatten() {
142 let path = entry.path();
143 let is_archive = path.is_file()
144 && path
145 .file_name()
146 .and_then(|n| n.to_str())
147 .map(|n| n.ends_with(".tar.gz"))
148 .unwrap_or(false);
149 if is_archive {
150 archives.push(path);
151 }
152 }
153 }
154 archives
155}
156
157fn write_archive(
159 archive_path: &Path,
160 db_path: &Path,
161 config_path: &Path,
162 metadata: &BackupMetadata,
163 mtime: u64,
164) -> Result<()> {
165 let file = File::create(archive_path)
166 .with_context(|| format!("création de l'archive {}", archive_path.display()))?;
167 let enc = GzEncoder::new(file, Compression::default());
168 let mut builder = tar::Builder::new(enc);
169
170 builder
171 .append_path_with_name(db_path, "history.db")
172 .context("ajout de history.db à l'archive")?;
173
174 if config_path.exists() {
175 builder
176 .append_path_with_name(config_path, "config.toml")
177 .context("ajout de config.toml à l'archive")?;
178 }
179
180 let meta_json = serde_json::to_vec_pretty(metadata)?;
181 let mut header = tar::Header::new_gnu();
182 header.set_size(meta_json.len() as u64);
183 header.set_mode(0o600);
184 header.set_mtime(mtime);
185 header.set_cksum();
186 builder
187 .append_data(&mut header, "metadata.json", meta_json.as_slice())
188 .context("ajout de metadata.json à l'archive")?;
189
190 let enc = builder.into_inner().context("finalisation du tar")?;
191 enc.finish()
192 .context("finalisation de la compression gzip")?;
193 Ok(())
194}
195
196pub fn run(output: Option<PathBuf>, json: bool) -> Result<()> {
198 let info = create_backup(output.as_deref())?;
199
200 if json {
201 let value = serde_json::json!({
202 "backup_path": info.path.display().to_string(),
203 "metadata": info.metadata,
204 });
205 println!("{}", serde_json::to_string_pretty(&value)?);
206 } else {
207 println!("Sauvegarde créée : {}", info.path.display());
208 println!(" Commandes : {}", info.metadata.command_count);
209 println!(
210 " Taille DB : {} octets",
211 info.metadata.db_size_bytes
212 );
213 println!(" Version schéma : {}", info.metadata.schema_version);
214 }
215 Ok(())
216}
217
218struct ExtractedBackup {
220 dir: PathBuf,
221 db: PathBuf,
222 config: Option<PathBuf>,
223 metadata: Option<BackupMetadata>,
224}
225
226impl Drop for ExtractedBackup {
227 fn drop(&mut self) {
228 let _ = fs::remove_dir_all(&self.dir);
230 }
231}
232
233fn extract_archive(archive: &Path) -> Result<ExtractedBackup> {
235 if !archive.exists() {
236 bail!("archive introuvable : {}", archive.display());
237 }
238
239 let (secs, _) = timestamp_parts();
240 let pid = std::process::id();
241 let dir = std::env::temp_dir().join(format!("mnemo-restore-{pid}-{secs}"));
242 fs::create_dir_all(&dir)
243 .with_context(|| format!("création du dossier temporaire {}", dir.display()))?;
244
245 let file = File::open(archive)
246 .with_context(|| format!("ouverture de l'archive {}", archive.display()))?;
247 let dec = GzDecoder::new(file);
248 let ar = tar::Archive::new(dec);
249 crate::archive::safe_unpack(ar, &dir)
250 .with_context(|| format!("extraction de l'archive {}", archive.display()))?;
251
252 let db = dir.join("history.db");
253 if !db.exists() {
254 bail!("archive invalide : history.db manquant");
255 }
256 let config = {
257 let c = dir.join("config.toml");
258 c.exists().then_some(c)
259 };
260 let metadata = {
261 let m = dir.join("metadata.json");
262 if m.exists() {
263 let raw = fs::read_to_string(&m)?;
264 serde_json::from_str::<BackupMetadata>(&raw).ok()
265 } else {
266 None
267 }
268 };
269
270 Ok(ExtractedBackup {
271 dir,
272 db,
273 config,
274 metadata,
275 })
276}
277
278fn validate_db(db_path: &Path) -> Result<i64> {
280 let conn = db::open_readonly(db_path)
281 .with_context(|| "la base restaurée n'est pas ouvrable (archive invalide)")?;
282 if !db::table_exists(&conn, "commands")? {
283 bail!("archive invalide : table `commands` absente");
284 }
285 let schema = migrations::schema_version(&conn)?;
286 if schema > migrations::SCHEMA_VERSION {
287 bail!(
288 "archive incompatible : schéma v{} > schéma supporté v{} (mnemo trop ancien)",
289 schema,
290 migrations::SCHEMA_VERSION
291 );
292 }
293 Ok(schema)
294}
295
296fn replace_file(src: &Path, dst: &Path) -> Result<()> {
298 if let Some(parent) = dst.parent() {
299 fs::create_dir_all(parent)?;
300 }
301 let tmp = dst.with_extension("mnemo-tmp");
302 fs::copy(src, &tmp).with_context(|| format!("copie vers {}", tmp.display()))?;
303 fs::rename(&tmp, dst).with_context(|| format!("remplacement de {}", dst.display()))?;
304 Ok(())
305}
306
307pub fn restore_run(archive: &Path, dry_run: bool, assume_yes: bool) -> Result<()> {
309 let extracted = extract_archive(archive)?;
310 let schema = validate_db(&extracted.db)?;
311
312 println!("Archive : {}", archive.display());
314 if let Some(meta) = &extracted.metadata {
315 println!(" Version mnemo : {}", meta.mnemo_version);
316 println!(" Date sauvegarde : {}", meta.created_at);
317 println!(" Commandes : {}", meta.command_count);
318 }
319 println!(" Version schéma : {schema}");
320 println!(
321 " Configuration : {}",
322 if extracted.config.is_some() {
323 "incluse"
324 } else {
325 "absente"
326 }
327 );
328 println!("Cibles :");
329 println!(" DB -> {}", config::db_path()?.display());
330 println!(" Config -> {}", config::config_path()?.display());
331
332 if dry_run {
333 println!("\n[dry-run] Aucune modification effectuée.");
334 return Ok(());
335 }
336
337 if !confirm::confirm(
338 "Restaurer cette sauvegarde remplacera la base et la config actuelles. Continuer ?",
339 assume_yes,
340 )? {
341 println!("Restauration annulée.");
342 return Ok(());
343 }
344
345 let safety = create_backup(None)?;
347 println!("Sauvegarde de sécurité créée : {}", safety.path.display());
348
349 replace_file(&extracted.db, &config::db_path()?)?;
350 if let Some(cfg) = &extracted.config {
351 replace_file(cfg, &config::config_path()?)?;
352 }
353
354 println!("Restauration terminée.");
355 Ok(())
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn nom_archive_bien_forme() {
364 assert_eq!(
365 archive_filename("2026-06-14 12:34:56"),
366 "mnemo-backup-20260614-123456.tar.gz"
367 );
368 }
369
370 #[test]
371 fn iso_bien_forme() {
372 assert_eq!(iso_from("2026-06-14 12:34:56"), "2026-06-14T12:34:56Z");
373 }
374}