Skip to main content

oxirs_tdb/
backup_engine.rs

1//! BackupEngine and IncrementalBackup for OxiRS TDB
2//!
3//! Provides triple-level backup/restore with format and compression options,
4//! SHA-256 checksum verification, and delta-based incremental backups.
5
6use crate::error::{Result, TdbError};
7use sha2::{Digest, Sha256};
8use std::fs;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::SystemTime;
12
13// ─── Enumerations ────────────────────────────────────────────────────────────
14
15/// Serialization format for backup data
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub enum BackupFormat {
18    /// N-Quads text format
19    NQuads,
20    /// Turtle text format
21    Turtle,
22    /// JSON-LD format
23    JsonLd,
24    /// Binary (compact length-prefixed) format
25    Binary,
26}
27
28/// Compression algorithm applied to backup data
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30pub enum BackupCompression {
31    /// No compression
32    None,
33    /// Zstandard compression
34    Zstd,
35    /// Gzip compression
36    Gzip,
37}
38
39// ─── BackupManifest ──────────────────────────────────────────────────────────
40
41/// Metadata describing a completed backup
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub struct BackupManifest {
44    /// Unique identifier for this backup
45    pub backup_id: String,
46    /// Unix timestamp (milliseconds) when backup was created
47    pub created_at_ms: i64,
48    /// Name of the dataset that was backed up
49    pub dataset_name: String,
50    /// Number of triples in the backup
51    pub triple_count: usize,
52    /// Serialization format used
53    pub format: BackupFormat,
54    /// Compression algorithm used
55    pub compression: BackupCompression,
56    /// SHA-256 hex digest of the (possibly compressed) backup data file
57    pub checksum: String,
58    /// Total size of the backup data file in bytes
59    pub size_bytes: usize,
60}
61
62// ─── BackupEngine ────────────────────────────────────────────────────────────
63
64/// Engine that creates, lists, restores, verifies and prunes backups.
65///
66/// Each backup is stored as a pair of files:
67/// - `<id>.dat` — the (possibly compressed) triple data
68/// - `<id>.json` — the [`BackupManifest`]
69pub struct BackupEngine {
70    /// Root directory where backup files are written
71    backup_dir: PathBuf,
72    /// Advisory limit: call [`prune_old`] to enforce
73    max_backups: usize,
74}
75
76impl BackupEngine {
77    /// Create a new `BackupEngine` rooted at `backup_dir`.
78    pub fn new(backup_dir: PathBuf, max_backups: usize) -> Self {
79        Self {
80            backup_dir,
81            max_backups,
82        }
83    }
84
85    /// Create a backup of `triples` and write it to the backup directory.
86    pub fn create_backup(
87        &self,
88        triples: &[(String, String, String)],
89        dataset_name: &str,
90        format: BackupFormat,
91        compression: BackupCompression,
92    ) -> Result<BackupManifest> {
93        fs::create_dir_all(&self.backup_dir).map_err(TdbError::Io)?;
94
95        let backup_id = Self::new_id();
96        let raw = Self::serialize(triples, format)?;
97        let data = Self::compress(raw, compression)?;
98        let checksum = Self::sha256_hex(&data);
99        let size_bytes = data.len();
100
101        let data_path = self.backup_dir.join(format!("{}.dat", backup_id));
102        fs::write(&data_path, &data).map_err(TdbError::Io)?;
103
104        let manifest = BackupManifest {
105            backup_id: backup_id.clone(),
106            created_at_ms: Self::now_ms(),
107            dataset_name: dataset_name.to_string(),
108            triple_count: triples.len(),
109            format,
110            compression,
111            checksum,
112            size_bytes,
113        };
114
115        let manifest_path = self.backup_dir.join(format!("{}.json", backup_id));
116        let json = serde_json::to_string_pretty(&manifest)
117            .map_err(|e| TdbError::Serialization(e.to_string()))?;
118        fs::write(&manifest_path, json).map_err(TdbError::Io)?;
119
120        Ok(manifest)
121    }
122
123    /// List all manifests in the backup directory, sorted newest-first.
124    pub fn list_backups(&self) -> Vec<BackupManifest> {
125        let mut manifests = Vec::new();
126        let entries = match fs::read_dir(&self.backup_dir) {
127            Ok(e) => e,
128            Err(_) => return manifests,
129        };
130        for entry in entries.flatten() {
131            let path = entry.path();
132            if path.extension().and_then(|e| e.to_str()) == Some("json") {
133                if let Ok(json) = fs::read_to_string(&path) {
134                    if let Ok(m) = serde_json::from_str::<BackupManifest>(&json) {
135                        manifests.push(m);
136                    }
137                }
138            }
139        }
140        manifests.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms));
141        manifests
142    }
143
144    /// Restore triples from the backup identified by `backup_id`.
145    pub fn restore(&self, backup_id: &str) -> Result<Vec<(String, String, String)>> {
146        let manifest = self.load_manifest(backup_id)?;
147        let data_path = self.backup_dir.join(format!("{}.dat", backup_id));
148        let data = fs::read(&data_path).map_err(TdbError::Io)?;
149        let raw = Self::decompress(data, manifest.compression)?;
150        Self::deserialize(&raw, manifest.format)
151    }
152
153    /// Verify a backup by re-computing its SHA-256 checksum.
154    ///
155    /// Returns `true` if the stored checksum matches.
156    pub fn verify(&self, backup_id: &str) -> Result<bool> {
157        let manifest = self.load_manifest(backup_id)?;
158        let data_path = self.backup_dir.join(format!("{}.dat", backup_id));
159        let data = fs::read(&data_path).map_err(TdbError::Io)?;
160        Ok(Self::sha256_hex(&data) == manifest.checksum)
161    }
162
163    /// Delete the backup identified by `backup_id`.
164    pub fn delete_backup(&self, backup_id: &str) -> Result<()> {
165        let data_path = self.backup_dir.join(format!("{}.dat", backup_id));
166        let manifest_path = self.backup_dir.join(format!("{}.json", backup_id));
167        if data_path.exists() {
168            fs::remove_file(&data_path).map_err(TdbError::Io)?;
169        }
170        if manifest_path.exists() {
171            fs::remove_file(&manifest_path).map_err(TdbError::Io)?;
172        }
173        Ok(())
174    }
175
176    /// Delete the oldest backups, keeping only the `keep_count` most recent.
177    ///
178    /// Returns the number of backups deleted.
179    pub fn prune_old(&self, keep_count: usize) -> usize {
180        let manifests = self.list_backups(); // sorted newest-first
181        if manifests.len() <= keep_count {
182            return 0;
183        }
184        let mut deleted = 0usize;
185        for m in &manifests[keep_count..] {
186            if self.delete_backup(&m.backup_id).is_ok() {
187                deleted += 1;
188            }
189        }
190        deleted
191    }
192
193    // ── Private helpers ──────────────────────────────────────────────────────
194
195    fn load_manifest(&self, backup_id: &str) -> Result<BackupManifest> {
196        let path = self.backup_dir.join(format!("{}.json", backup_id));
197        let json = fs::read_to_string(&path).map_err(TdbError::Io)?;
198        serde_json::from_str(&json).map_err(|e| TdbError::Deserialization(e.to_string()))
199    }
200
201    fn new_id() -> String {
202        static COUNTER: AtomicU64 = AtomicU64::new(0);
203        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
204        let t = SystemTime::now()
205            .duration_since(SystemTime::UNIX_EPOCH)
206            .unwrap_or_default();
207        format!("bkp_{}_{}_{}", t.as_millis(), t.subsec_nanos(), seq)
208    }
209
210    fn now_ms() -> i64 {
211        SystemTime::now()
212            .duration_since(SystemTime::UNIX_EPOCH)
213            .map(|d| d.as_millis() as i64)
214            .unwrap_or(0)
215    }
216
217    fn sha256_hex(data: &[u8]) -> String {
218        let mut hasher = Sha256::new();
219        hasher.update(data);
220        format!("{:x}", hasher.finalize())
221    }
222
223    fn serialize(triples: &[(String, String, String)], format: BackupFormat) -> Result<Vec<u8>> {
224        match format {
225            BackupFormat::NQuads | BackupFormat::Turtle => {
226                let mut out = String::new();
227                for (s, p, o) in triples {
228                    out.push_str(&format!("<{}> <{}> <{}> .\n", s, p, o));
229                }
230                Ok(out.into_bytes())
231            }
232            BackupFormat::JsonLd => {
233                let mut items = Vec::new();
234                for (s, p, o) in triples {
235                    items.push(serde_json::json!({
236                        "@id": s,
237                        p: [{ "@id": o }]
238                    }));
239                }
240                serde_json::to_vec(&items).map_err(|e| TdbError::Serialization(e.to_string()))
241            }
242            BackupFormat::Binary => {
243                // Layout: u64-LE count, then each triple as three u32-LE-prefixed UTF-8 strings
244                let mut buf = Vec::new();
245                buf.extend_from_slice(&(triples.len() as u64).to_le_bytes());
246                for (s, p, o) in triples {
247                    for part in &[s, p, o] {
248                        let bytes = part.as_bytes();
249                        buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
250                        buf.extend_from_slice(bytes);
251                    }
252                }
253                Ok(buf)
254            }
255        }
256    }
257
258    fn deserialize(data: &[u8], format: BackupFormat) -> Result<Vec<(String, String, String)>> {
259        match format {
260            BackupFormat::NQuads | BackupFormat::Turtle => {
261                let text = std::str::from_utf8(data)
262                    .map_err(|e| TdbError::Deserialization(e.to_string()))?;
263                let mut triples = Vec::new();
264                for line in text.lines() {
265                    let line = line.trim();
266                    if line.is_empty() || line.starts_with('#') {
267                        continue;
268                    }
269                    // Parse "<s> <p> <o> ."
270                    let parts: Vec<&str> = line.splitn(4, "> <").collect();
271                    if parts.len() < 3 {
272                        continue;
273                    }
274                    let s = parts[0].trim_start_matches('<').to_string();
275                    let p = parts[1].to_string();
276                    let o = parts[2]
277                        .trim_end_matches("> .")
278                        .trim_end_matches(" .")
279                        .trim_end_matches('>')
280                        .to_string();
281                    triples.push((s, p, o));
282                }
283                Ok(triples)
284            }
285            BackupFormat::JsonLd => {
286                let items: Vec<serde_json::Value> = serde_json::from_slice(data)
287                    .map_err(|e| TdbError::Deserialization(e.to_string()))?;
288                let mut triples = Vec::new();
289                for item in items {
290                    let s = item["@id"].as_str().unwrap_or("").to_string();
291                    if let Some(obj) = item.as_object() {
292                        for (key, val) in obj {
293                            if key == "@id" {
294                                continue;
295                            }
296                            if let Some(arr) = val.as_array() {
297                                for v in arr {
298                                    let o = v["@id"].as_str().unwrap_or("").to_string();
299                                    triples.push((s.clone(), key.clone(), o));
300                                }
301                            }
302                        }
303                    }
304                }
305                Ok(triples)
306            }
307            BackupFormat::Binary => {
308                if data.len() < 8 {
309                    return Err(TdbError::Deserialization(
310                        "Binary data too short".to_string(),
311                    ));
312                }
313                let count = u64::from_le_bytes(
314                    data[..8]
315                        .try_into()
316                        .map_err(|_| TdbError::Deserialization("count slice".to_string()))?,
317                ) as usize;
318                let mut pos = 8usize;
319                let mut triples = Vec::with_capacity(count);
320                for _ in 0..count {
321                    let s = read_str_at(data, &mut pos)?;
322                    let p = read_str_at(data, &mut pos)?;
323                    let o = read_str_at(data, &mut pos)?;
324                    triples.push((s, p, o));
325                }
326                Ok(triples)
327            }
328        }
329    }
330
331    fn compress(data: Vec<u8>, compression: BackupCompression) -> Result<Vec<u8>> {
332        match compression {
333            BackupCompression::None => Ok(data),
334            BackupCompression::Zstd => oxiarc_zstd::encode_all(&data, 3)
335                .map_err(|e| TdbError::Other(format!("zstd compress: {}", e))),
336            BackupCompression::Gzip => {
337                use flate2::write::GzEncoder;
338                use flate2::Compression;
339                use std::io::Write;
340                let mut enc = GzEncoder::new(Vec::new(), Compression::default());
341                enc.write_all(&data)
342                    .map_err(|e| TdbError::Other(format!("gzip write: {}", e)))?;
343                enc.finish()
344                    .map_err(|e| TdbError::Other(format!("gzip finish: {}", e)))
345            }
346        }
347    }
348
349    fn decompress(data: Vec<u8>, compression: BackupCompression) -> Result<Vec<u8>> {
350        match compression {
351            BackupCompression::None => Ok(data),
352            BackupCompression::Zstd => oxiarc_zstd::decode_all(&data)
353                .map_err(|e| TdbError::Other(format!("zstd decompress: {}", e))),
354            BackupCompression::Gzip => {
355                use flate2::read::GzDecoder;
356                use std::io::Read;
357                let mut dec = GzDecoder::new(&data[..]);
358                let mut out = Vec::new();
359                dec.read_to_end(&mut out)
360                    .map_err(|e| TdbError::Other(format!("gzip decode: {}", e)))?;
361                Ok(out)
362            }
363        }
364    }
365}
366
367/// Read a u32-length-prefixed UTF-8 string from `data` at `pos`, advancing `pos`.
368fn read_str_at(data: &[u8], pos: &mut usize) -> Result<String> {
369    if *pos + 4 > data.len() {
370        return Err(TdbError::Deserialization("EOF reading length".to_string()));
371    }
372    let len = u32::from_le_bytes(
373        data[*pos..*pos + 4]
374            .try_into()
375            .map_err(|_| TdbError::Deserialization("len slice".to_string()))?,
376    ) as usize;
377    *pos += 4;
378    if *pos + len > data.len() {
379        return Err(TdbError::Deserialization("EOF reading string".to_string()));
380    }
381    let s = std::str::from_utf8(&data[*pos..*pos + len])
382        .map_err(|e| TdbError::Deserialization(e.to_string()))?
383        .to_string();
384    *pos += len;
385    Ok(s)
386}
387
388// ─── IncrementalBackup ───────────────────────────────────────────────────────
389
390/// A single delta (set of added and removed triples) within an incremental backup.
391#[derive(Debug, Clone)]
392pub struct BackupDelta {
393    /// Unique identifier for this delta
394    pub delta_id: String,
395    /// Unix timestamp (milliseconds) when the delta was created
396    pub created_at_ms: i64,
397    /// Triples added in this delta
398    pub added: Vec<(String, String, String)>,
399    /// Triples removed in this delta
400    pub removed: Vec<(String, String, String)>,
401}
402
403/// An incremental backup layered on top of a full base backup.
404///
405/// Deltas are applied in order to reconstruct the current dataset state.
406pub struct IncrementalBackup {
407    /// The base full-backup manifest
408    pub base_manifest: BackupManifest,
409    /// Ordered list of deltas applied since the base backup
410    pub deltas: Vec<BackupDelta>,
411}
412
413impl IncrementalBackup {
414    /// Create a new incremental backup anchored to `base`.
415    pub fn new(base: BackupManifest) -> Self {
416        Self {
417            base_manifest: base,
418            deltas: Vec::new(),
419        }
420    }
421
422    /// Append a new delta and return a reference to it.
423    pub fn add_delta(
424        &mut self,
425        added: Vec<(String, String, String)>,
426        removed: Vec<(String, String, String)>,
427    ) -> &BackupDelta {
428        static SEQ: AtomicU64 = AtomicU64::new(0);
429        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
430        let t = SystemTime::now()
431            .duration_since(SystemTime::UNIX_EPOCH)
432            .unwrap_or_default();
433        let delta = BackupDelta {
434            delta_id: format!("delta_{}_{}_{}", t.as_millis(), t.subsec_nanos(), seq),
435            created_at_ms: t.as_millis() as i64,
436            added,
437            removed,
438        };
439        self.deltas.push(delta);
440        self.deltas.last().expect("just pushed")
441    }
442
443    /// Reconstruct the full triple set by replaying all deltas in order.
444    ///
445    /// This applies additions and removals cumulatively across all deltas.
446    pub fn reconstruct(&self) -> Vec<(String, String, String)> {
447        let mut current: std::collections::HashSet<(String, String, String)> =
448            std::collections::HashSet::new();
449        for delta in &self.deltas {
450            for t in &delta.removed {
451                current.remove(t);
452            }
453            for t in &delta.added {
454                current.insert(t.clone());
455            }
456        }
457        current.into_iter().collect()
458    }
459
460    /// Return the number of deltas recorded so far.
461    pub fn delta_count(&self) -> usize {
462        self.deltas.len()
463    }
464
465    /// Return the total number of triple operations (adds + removes) across all deltas.
466    pub fn total_size(&self) -> usize {
467        self.deltas
468            .iter()
469            .map(|d| d.added.len() + d.removed.len())
470            .sum()
471    }
472}
473
474// ─── Tests ───────────────────────────────────────────────────────────────────
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn sample_triples(n: usize) -> Vec<(String, String, String)> {
481        (0..n)
482            .map(|i| {
483                (
484                    format!("http://ex.org/s{}", i),
485                    format!("http://ex.org/p{}", i),
486                    format!("http://ex.org/o{}", i),
487                )
488            })
489            .collect()
490    }
491
492    fn tmp_dir(name: &str) -> PathBuf {
493        let d = std::env::temp_dir().join(format!("oxirs_tdb_bkpeng_{}", name));
494        fs::remove_dir_all(&d).ok();
495        fs::create_dir_all(&d).unwrap();
496        d
497    }
498
499    fn dummy_manifest() -> BackupManifest {
500        BackupManifest {
501            backup_id: "base_001".to_string(),
502            created_at_ms: 0,
503            dataset_name: "test".to_string(),
504            triple_count: 0,
505            format: BackupFormat::NQuads,
506            compression: BackupCompression::None,
507            checksum: "abc123".to_string(),
508            size_bytes: 0,
509        }
510    }
511
512    // ── BackupEngine ─────────────────────────────────────────────────────────
513
514    #[test]
515    fn test_backup_engine_new() {
516        let dir = tmp_dir("new");
517        let engine = BackupEngine::new(dir.clone(), 5);
518        assert_eq!(engine.max_backups, 5);
519        assert_eq!(engine.backup_dir, dir);
520        fs::remove_dir_all(&dir).ok();
521    }
522
523    #[test]
524    fn test_create_backup_nquads_none() {
525        let dir = tmp_dir("create_nq");
526        let engine = BackupEngine::new(dir.clone(), 10);
527        let triples = sample_triples(5);
528        let m = engine
529            .create_backup(
530                &triples,
531                "test_ds",
532                BackupFormat::NQuads,
533                BackupCompression::None,
534            )
535            .unwrap();
536        assert_eq!(m.triple_count, 5);
537        assert_eq!(m.format, BackupFormat::NQuads);
538        assert_eq!(m.compression, BackupCompression::None);
539        assert!(!m.checksum.is_empty());
540        assert!(m.size_bytes > 0);
541        assert!(!m.backup_id.is_empty());
542        fs::remove_dir_all(&dir).ok();
543    }
544
545    #[test]
546    fn test_create_backup_binary_zstd() {
547        let dir = tmp_dir("create_bin_zstd");
548        let engine = BackupEngine::new(dir.clone(), 10);
549        let triples = sample_triples(10);
550        let m = engine
551            .create_backup(
552                &triples,
553                "ds",
554                BackupFormat::Binary,
555                BackupCompression::Zstd,
556            )
557            .unwrap();
558        assert_eq!(m.triple_count, 10);
559        assert_eq!(m.format, BackupFormat::Binary);
560        assert_eq!(m.compression, BackupCompression::Zstd);
561        fs::remove_dir_all(&dir).ok();
562    }
563
564    #[test]
565    fn test_create_backup_jsonld_gzip() {
566        let dir = tmp_dir("create_jsonld_gz");
567        let engine = BackupEngine::new(dir.clone(), 10);
568        let triples = sample_triples(3);
569        let m = engine
570            .create_backup(
571                &triples,
572                "ds",
573                BackupFormat::JsonLd,
574                BackupCompression::Gzip,
575            )
576            .unwrap();
577        assert_eq!(m.format, BackupFormat::JsonLd);
578        assert_eq!(m.compression, BackupCompression::Gzip);
579        fs::remove_dir_all(&dir).ok();
580    }
581
582    #[test]
583    fn test_create_backup_turtle_none() {
584        let dir = tmp_dir("create_turtle");
585        let engine = BackupEngine::new(dir.clone(), 10);
586        let triples = sample_triples(2);
587        let m = engine
588            .create_backup(
589                &triples,
590                "ds",
591                BackupFormat::Turtle,
592                BackupCompression::None,
593            )
594            .unwrap();
595        assert_eq!(m.format, BackupFormat::Turtle);
596        assert_eq!(m.triple_count, 2);
597        fs::remove_dir_all(&dir).ok();
598    }
599
600    #[test]
601    fn test_restore_nquads() {
602        let dir = tmp_dir("restore_nq");
603        let engine = BackupEngine::new(dir.clone(), 10);
604        let triples = sample_triples(4);
605        let m = engine
606            .create_backup(
607                &triples,
608                "ds",
609                BackupFormat::NQuads,
610                BackupCompression::None,
611            )
612            .unwrap();
613        let restored = engine.restore(&m.backup_id).unwrap();
614        assert_eq!(restored.len(), 4);
615        for t in &triples {
616            assert!(restored.contains(t), "missing triple {:?}", t);
617        }
618        fs::remove_dir_all(&dir).ok();
619    }
620
621    #[test]
622    fn test_restore_binary_zstd() {
623        let dir = tmp_dir("restore_bin_zstd");
624        let engine = BackupEngine::new(dir.clone(), 10);
625        let triples = sample_triples(7);
626        let m = engine
627            .create_backup(
628                &triples,
629                "ds",
630                BackupFormat::Binary,
631                BackupCompression::Zstd,
632            )
633            .unwrap();
634        let restored = engine.restore(&m.backup_id).unwrap();
635        assert_eq!(restored.len(), 7);
636        for t in &triples {
637            assert!(restored.contains(t));
638        }
639        fs::remove_dir_all(&dir).ok();
640    }
641
642    #[test]
643    fn test_restore_binary_gzip() {
644        let dir = tmp_dir("restore_bin_gz");
645        let engine = BackupEngine::new(dir.clone(), 10);
646        let triples = sample_triples(6);
647        let m = engine
648            .create_backup(
649                &triples,
650                "ds",
651                BackupFormat::Binary,
652                BackupCompression::Gzip,
653            )
654            .unwrap();
655        let restored = engine.restore(&m.backup_id).unwrap();
656        assert_eq!(restored.len(), 6);
657        fs::remove_dir_all(&dir).ok();
658    }
659
660    #[test]
661    fn test_verify_intact() {
662        let dir = tmp_dir("verify_ok");
663        let engine = BackupEngine::new(dir.clone(), 10);
664        let triples = sample_triples(3);
665        let m = engine
666            .create_backup(
667                &triples,
668                "ds",
669                BackupFormat::NQuads,
670                BackupCompression::None,
671            )
672            .unwrap();
673        assert!(engine.verify(&m.backup_id).unwrap());
674        fs::remove_dir_all(&dir).ok();
675    }
676
677    #[test]
678    fn test_verify_corrupted() {
679        let dir = tmp_dir("verify_corrupt");
680        let engine = BackupEngine::new(dir.clone(), 10);
681        let triples = sample_triples(3);
682        let m = engine
683            .create_backup(
684                &triples,
685                "ds",
686                BackupFormat::NQuads,
687                BackupCompression::None,
688            )
689            .unwrap();
690        // Corrupt the data file
691        fs::write(dir.join(format!("{}.dat", m.backup_id)), b"corrupted").unwrap();
692        assert!(!engine.verify(&m.backup_id).unwrap());
693        fs::remove_dir_all(&dir).ok();
694    }
695
696    #[test]
697    fn test_list_backups_empty() {
698        let dir = tmp_dir("list_empty");
699        let engine = BackupEngine::new(dir.clone(), 10);
700        assert!(engine.list_backups().is_empty());
701        fs::remove_dir_all(&dir).ok();
702    }
703
704    #[test]
705    fn test_list_backups_multiple_sorted() {
706        let dir = tmp_dir("list_multi");
707        let engine = BackupEngine::new(dir.clone(), 10);
708        let t = sample_triples(2);
709        engine
710            .create_backup(&t, "ds", BackupFormat::NQuads, BackupCompression::None)
711            .unwrap();
712        engine
713            .create_backup(&t, "ds", BackupFormat::Binary, BackupCompression::None)
714            .unwrap();
715        let list = engine.list_backups();
716        assert_eq!(list.len(), 2);
717        assert!(list[0].created_at_ms >= list[1].created_at_ms);
718        fs::remove_dir_all(&dir).ok();
719    }
720
721    #[test]
722    fn test_delete_backup() {
723        let dir = tmp_dir("delete");
724        let engine = BackupEngine::new(dir.clone(), 10);
725        let t = sample_triples(2);
726        let m = engine
727            .create_backup(&t, "ds", BackupFormat::NQuads, BackupCompression::None)
728            .unwrap();
729        engine.delete_backup(&m.backup_id).unwrap();
730        assert!(engine.list_backups().is_empty());
731        fs::remove_dir_all(&dir).ok();
732    }
733
734    #[test]
735    fn test_delete_nonexistent_is_ok() {
736        let dir = tmp_dir("delete_ne");
737        let engine = BackupEngine::new(dir.clone(), 10);
738        // Should not return error for non-existent backup
739        engine.delete_backup("no_such_id").unwrap();
740        fs::remove_dir_all(&dir).ok();
741    }
742
743    #[test]
744    fn test_prune_old_removes_oldest() {
745        let dir = tmp_dir("prune");
746        let engine = BackupEngine::new(dir.clone(), 10);
747        let t = sample_triples(1);
748        for _ in 0..5 {
749            engine
750                .create_backup(&t, "ds", BackupFormat::NQuads, BackupCompression::None)
751                .unwrap();
752        }
753        assert_eq!(engine.list_backups().len(), 5);
754        let removed = engine.prune_old(3);
755        assert_eq!(removed, 2);
756        assert_eq!(engine.list_backups().len(), 3);
757        fs::remove_dir_all(&dir).ok();
758    }
759
760    #[test]
761    fn test_prune_no_removal_needed() {
762        let dir = tmp_dir("prune_noop");
763        let engine = BackupEngine::new(dir.clone(), 10);
764        let t = sample_triples(1);
765        engine
766            .create_backup(&t, "ds", BackupFormat::NQuads, BackupCompression::None)
767            .unwrap();
768        let removed = engine.prune_old(5);
769        assert_eq!(removed, 0);
770        assert_eq!(engine.list_backups().len(), 1);
771        fs::remove_dir_all(&dir).ok();
772    }
773
774    #[test]
775    fn test_empty_triples_backup_restore() {
776        let dir = tmp_dir("empty_triples");
777        let engine = BackupEngine::new(dir.clone(), 10);
778        let triples: Vec<(String, String, String)> = vec![];
779        let m = engine
780            .create_backup(
781                &triples,
782                "ds",
783                BackupFormat::NQuads,
784                BackupCompression::None,
785            )
786            .unwrap();
787        assert_eq!(m.triple_count, 0);
788        let restored = engine.restore(&m.backup_id).unwrap();
789        assert!(restored.is_empty());
790        fs::remove_dir_all(&dir).ok();
791    }
792
793    #[test]
794    fn test_dataset_name_preserved_in_manifest() {
795        let dir = tmp_dir("ds_name");
796        let engine = BackupEngine::new(dir.clone(), 10);
797        let t = sample_triples(1);
798        let m = engine
799            .create_backup(
800                &t,
801                "my_dataset",
802                BackupFormat::Binary,
803                BackupCompression::None,
804            )
805            .unwrap();
806        assert_eq!(m.dataset_name, "my_dataset");
807        let list = engine.list_backups();
808        assert_eq!(list[0].dataset_name, "my_dataset");
809        fs::remove_dir_all(&dir).ok();
810    }
811
812    // ── IncrementalBackup ────────────────────────────────────────────────────
813
814    #[test]
815    fn test_incremental_new() {
816        let ib = IncrementalBackup::new(dummy_manifest());
817        assert_eq!(ib.base_manifest.backup_id, "base_001");
818        assert_eq!(ib.delta_count(), 0);
819        assert_eq!(ib.total_size(), 0);
820    }
821
822    #[test]
823    fn test_add_delta_returns_ref() {
824        let mut ib = IncrementalBackup::new(dummy_manifest());
825        let delta = ib.add_delta(
826            vec![("s".to_string(), "p".to_string(), "o".to_string())],
827            vec![],
828        );
829        assert!(!delta.delta_id.is_empty());
830        assert_eq!(delta.added.len(), 1);
831        assert_eq!(delta.removed.len(), 0);
832    }
833
834    #[test]
835    fn test_delta_count_multiple() {
836        let mut ib = IncrementalBackup::new(dummy_manifest());
837        ib.add_delta(vec![], vec![]);
838        ib.add_delta(vec![], vec![]);
839        assert_eq!(ib.delta_count(), 2);
840    }
841
842    #[test]
843    fn test_total_size_sums_adds_and_removes() {
844        let mut ib = IncrementalBackup::new(dummy_manifest());
845        // Delta 1: 1 add + 1 remove = 2 operations
846        ib.add_delta(
847            vec![("s1".to_string(), "p".to_string(), "o1".to_string())],
848            vec![("s2".to_string(), "p".to_string(), "o2".to_string())],
849        );
850        // Delta 2: 2 adds + 0 removes = 2 operations
851        ib.add_delta(
852            vec![
853                ("s3".to_string(), "p".to_string(), "o3".to_string()),
854                ("s4".to_string(), "p".to_string(), "o4".to_string()),
855            ],
856            vec![],
857        );
858        // Total: 2 + 2 = 4
859        assert_eq!(ib.total_size(), 4);
860    }
861
862    #[test]
863    fn test_reconstruct_add_only() {
864        let mut ib = IncrementalBackup::new(dummy_manifest());
865        let t1 = ("a".to_string(), "b".to_string(), "c".to_string());
866        let t2 = ("d".to_string(), "e".to_string(), "f".to_string());
867        ib.add_delta(vec![t1.clone(), t2.clone()], vec![]);
868        let result = ib.reconstruct();
869        assert_eq!(result.len(), 2);
870        assert!(result.contains(&t1));
871        assert!(result.contains(&t2));
872    }
873
874    #[test]
875    fn test_reconstruct_add_then_remove() {
876        let mut ib = IncrementalBackup::new(dummy_manifest());
877        let t1 = ("s1".to_string(), "p".to_string(), "o1".to_string());
878        let t2 = ("s2".to_string(), "p".to_string(), "o2".to_string());
879        ib.add_delta(vec![t1.clone(), t2.clone()], vec![]);
880        ib.add_delta(vec![], vec![t1.clone()]);
881        let result = ib.reconstruct();
882        assert_eq!(result.len(), 1);
883        assert!(result.contains(&t2));
884        assert!(!result.contains(&t1));
885    }
886
887    #[test]
888    fn test_reconstruct_empty() {
889        let ib = IncrementalBackup::new(dummy_manifest());
890        assert!(ib.reconstruct().is_empty());
891    }
892
893    #[test]
894    fn test_delta_timestamps_are_positive() {
895        let mut ib = IncrementalBackup::new(dummy_manifest());
896        let delta = ib.add_delta(vec![], vec![]);
897        assert!(delta.created_at_ms > 0);
898    }
899}