Skip to main content

tiller_sync/
backup.rs

1//! Backup management for local file backups during sync operations.
2
3use crate::error::Res;
4use crate::model::TillerData;
5use crate::{utils, Config};
6use anyhow::Context;
7use chrono::Local;
8use std::path::PathBuf;
9use tracing::debug;
10
11/// Prefix for sync-down backup files.
12pub const SYNC_DOWN: &str = "sync-down";
13
14/// Prefix for sync-up-pre backup files (snapshot before upload).
15pub const SYNC_UP_PRE: &str = "sync-up-pre";
16
17/// Prefix for SQLite backup files.
18pub const SQLITE: &str = "tiller.sqlite";
19
20/// Manages backup file creation and rotation.
21///
22/// The `Backup` struct is immutable and owns copies of the paths and settings it needs.
23/// Create a new instance via `Config::backup()` or `Backup::new()`.
24#[derive(Debug, Clone)]
25pub(crate) struct Backup {
26    backups_dir: PathBuf,
27    backup_copies: u32,
28    sqlite_path: PathBuf,
29}
30
31impl Backup {
32    /// Creates a new `Backup` instance from a `Config`.
33    pub(crate) fn new(config: &Config) -> Self {
34        Self {
35            backups_dir: config.backups().to_path_buf(),
36            backup_copies: config.backup_copies(),
37            sqlite_path: config.sqlite_path().to_path_buf(),
38        }
39    }
40
41    /// Saves `TillerData` as a pretty-printed JSON backup file.
42    ///
43    /// The filename format is `{prefix}.YYYY-MM-DD-NNN.json` where NNN is a sequence number.
44    /// Automatically rotates old backups, keeping only `backup_copies` files.
45    ///
46    /// Returns the path to the created backup file.
47    pub(crate) async fn save_json(&self, prefix: &str, data: &TillerData) -> Res<PathBuf> {
48        let date = today();
49        let seq = self.next_sequence_number(prefix, &date, "json").await?;
50        let filename = format!("{prefix}.{date}-{seq:03}.json");
51        let path = self.backups_dir.join(&filename);
52
53        let json =
54            serde_json::to_string_pretty(data).context("Failed to serialize TillerData to JSON")?;
55        utils::write(&path, json).await?;
56
57        self.rotate(prefix, "json").await?;
58
59        Ok(path)
60    }
61
62    /// Copies the SQLite database file to the backups directory.
63    ///
64    /// The filename format is `tiller.sqlite.YYYY-MM-DD-NNN`.
65    /// Automatically rotates old backups, keeping only `backup_copies` files.
66    ///
67    /// Returns the path to the created backup file.
68    pub(crate) async fn copy_sqlite(&self) -> Res<PathBuf> {
69        let date = today();
70        let seq = self.next_sequence_number(SQLITE, &date, "").await?;
71        let filename = format!("{SQLITE}.{date}-{seq:03}");
72        let path = self.backups_dir.join(&filename);
73
74        utils::copy(&self.sqlite_path, &path).await?;
75
76        self.rotate(SQLITE, "").await?;
77
78        Ok(path)
79    }
80
81    /// Loads the most recent JSON backup file with the given prefix.
82    ///
83    /// Returns `None` if no backup files exist.
84    pub(crate) async fn load_latest_json(&self, prefix: &str) -> Res<Option<TillerData>> {
85        let latest = self.find_latest_backup(prefix, "json").await?;
86
87        match latest {
88            None => {
89                debug!("No {prefix} backup found");
90                Ok(None)
91            }
92            Some(path) => {
93                debug!("Loading backup from {}", path.display());
94                let content = utils::read(&path).await?;
95                let data: TillerData = serde_json::from_str(&content)
96                    .with_context(|| format!("Failed to parse backup file: {}", path.display()))?;
97                Ok(Some(data))
98            }
99        }
100    }
101
102    /// Finds the most recent backup file with the given prefix and extension.
103    async fn find_latest_backup(&self, prefix: &str, extension: &str) -> Res<Option<PathBuf>> {
104        let mut files: Vec<(PathBuf, String)> = Vec::new();
105
106        let mut dir = utils::read_dir(&self.backups_dir).await?;
107        while let Some(entry) = dir
108            .next_entry()
109            .await
110            .context("Failed to read directory entry")?
111        {
112            let file_name = entry.file_name();
113            let name = file_name.to_string_lossy().to_string();
114
115            if is_backup_file(&name, prefix, extension) {
116                files.push((entry.path(), name));
117            }
118        }
119
120        // Sort by filename descending (most recent last due to date-seq format)
121        files.sort_by(|a, b| b.1.cmp(&a.1));
122
123        Ok(files.into_iter().next().map(|(path, _)| path))
124    }
125
126    /// Scans the backups directory for existing files with the given prefix and date,
127    /// and returns the next sequence number.
128    async fn next_sequence_number(&self, prefix: &str, date: &str, extension: &str) -> Res<u32> {
129        let pattern_start = format!("{prefix}.{date}-");
130        let mut max_seq: u32 = 0;
131
132        let mut dir = utils::read_dir(&self.backups_dir).await?;
133        while let Some(entry) = dir
134            .next_entry()
135            .await
136            .context("Failed to read directory entry")?
137        {
138            let file_name = entry.file_name();
139            let name = file_name.to_string_lossy();
140
141            if name.starts_with(&pattern_start) {
142                if let Some(seq) = parse_sequence_number(&name, prefix, date, extension) {
143                    max_seq = max_seq.max(seq);
144                }
145            }
146        }
147
148        Ok(max_seq + 1)
149    }
150
151    /// Rotates old backup files, keeping only `backup_copies` files with the given prefix.
152    async fn rotate(&self, prefix: &str, extension: &str) -> Res<()> {
153        // Collect all matching backup files
154        let mut files: Vec<(PathBuf, String)> = Vec::new();
155
156        let mut dir = utils::read_dir(&self.backups_dir).await?;
157        while let Some(entry) = dir
158            .next_entry()
159            .await
160            .context("Failed to read directory entry")?
161        {
162            let file_name = entry.file_name();
163            let name = file_name.to_string_lossy().to_string();
164
165            if is_backup_file(&name, prefix, extension) {
166                files.push((entry.path(), name));
167            }
168        }
169
170        // Sort by filename (which sorts by date and sequence number due to format)
171        files.sort_by(|a, b| a.1.cmp(&b.1));
172
173        // Delete oldest files if we have more than backup_copies
174        let to_delete = files.len().saturating_sub(self.backup_copies as usize);
175        for (path, _) in files.into_iter().take(to_delete) {
176            utils::remove(&path).await?;
177        }
178
179        Ok(())
180    }
181}
182
183/// Returns today's date in YYYY-MM-DD format.
184fn today() -> String {
185    Local::now().format("%Y-%m-%d").to_string()
186}
187
188/// Parses the sequence number from a backup filename.
189/// Returns None if the filename doesn't match the expected pattern.
190fn parse_sequence_number(filename: &str, prefix: &str, date: &str, extension: &str) -> Option<u32> {
191    // Pattern: {prefix}.{date}-{NNN}.{ext} or {prefix}.{date}-{NNN} (no extension)
192    let expected_start = format!("{prefix}.{date}-");
193
194    if !filename.starts_with(&expected_start) {
195        return None;
196    }
197
198    let remainder = &filename[expected_start.len()..];
199
200    // Extract the sequence number part
201    let seq_str = if extension.is_empty() {
202        remainder
203    } else {
204        let expected_suffix = format!(".{extension}");
205        remainder.strip_suffix(&expected_suffix)?
206    };
207
208    seq_str.parse().ok()
209}
210
211/// Checks if a filename is a backup file with the given prefix and extension.
212fn is_backup_file(filename: &str, prefix: &str, extension: &str) -> bool {
213    let starts_ok = filename.starts_with(&format!("{prefix}."));
214
215    let ends_ok = if extension.is_empty() {
216        // For SQLite backups, ensure it doesn't end with a known extension
217        !filename.ends_with(".json")
218    } else {
219        filename.ends_with(&format!(".{extension}"))
220    };
221
222    starts_ok && ends_ok
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_parse_sequence_number() {
231        assert_eq!(
232            parse_sequence_number(
233                "sync-down.2025-12-14-001.json",
234                "sync-down",
235                "2025-12-14",
236                "json"
237            ),
238            Some(1)
239        );
240        assert_eq!(
241            parse_sequence_number(
242                "sync-down.2025-12-14-042.json",
243                "sync-down",
244                "2025-12-14",
245                "json"
246            ),
247            Some(42)
248        );
249        assert_eq!(
250            parse_sequence_number(
251                "tiller.sqlite.2025-12-14-003",
252                "tiller.sqlite",
253                "2025-12-14",
254                ""
255            ),
256            Some(3)
257        );
258        // Wrong prefix
259        assert_eq!(
260            parse_sequence_number(
261                "sync-up-pre.2025-12-14-001.json",
262                "sync-down",
263                "2025-12-14",
264                "json"
265            ),
266            None
267        );
268        // Wrong date
269        assert_eq!(
270            parse_sequence_number(
271                "sync-down.2025-12-13-001.json",
272                "sync-down",
273                "2025-12-14",
274                "json"
275            ),
276            None
277        );
278    }
279
280    #[test]
281    fn test_is_backup_file() {
282        assert!(is_backup_file(
283            "sync-down.2025-12-14-001.json",
284            "sync-down",
285            "json"
286        ));
287        assert!(is_backup_file(
288            "sync-up-pre.2025-12-14-001.json",
289            "sync-up-pre",
290            "json"
291        ));
292        assert!(is_backup_file(
293            "tiller.sqlite.2025-12-14-001",
294            "tiller.sqlite",
295            ""
296        ));
297        assert!(!is_backup_file(
298            "sync-down.2025-12-14-001.json",
299            "sync-up-pre",
300            "json"
301        ));
302        assert!(!is_backup_file(
303            "tiller.sqlite.2025-12-14-001.json",
304            "tiller.sqlite",
305            ""
306        ));
307    }
308}