1use 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
11pub const SYNC_DOWN: &str = "sync-down";
13
14pub const SYNC_UP_PRE: &str = "sync-up-pre";
16
17pub const SQLITE: &str = "tiller.sqlite";
19
20#[derive(Debug, Clone)]
25pub(crate) struct Backup {
26 backups_dir: PathBuf,
27 backup_copies: u32,
28 sqlite_path: PathBuf,
29}
30
31impl Backup {
32 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 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 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 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 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 files.sort_by(|a, b| b.1.cmp(&a.1));
122
123 Ok(files.into_iter().next().map(|(path, _)| path))
124 }
125
126 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 async fn rotate(&self, prefix: &str, extension: &str) -> Res<()> {
153 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 files.sort_by(|a, b| a.1.cmp(&b.1));
172
173 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
183fn today() -> String {
185 Local::now().format("%Y-%m-%d").to_string()
186}
187
188fn parse_sequence_number(filename: &str, prefix: &str, date: &str, extension: &str) -> Option<u32> {
191 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 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
211fn 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 !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 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 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}