1use crate::manager::ShurikenManager;
8use anyhow::{Context, Result};
9use chrono::Utc;
10use flate2::Compression;
11use flate2::{read::GzDecoder, write::GzEncoder};
12use ignore::WalkBuilder;
13use opendal::Operator;
14use opendal::services::Fs;
15use serde::{Deserialize, Serialize};
16use std::fs::File;
17use std::{path::Path, process::Command};
18use tar::{Archive, Builder as TarBuilder};
19use tokio::task;
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
27pub enum CompressionType {
28 Fast,
30 Normal,
32 Best,
34}
35
36#[derive(Debug, Clone, Copy)]
42pub enum BackupFrequency {
43 Daily,
45 Weekly,
47 Monthly,
49}
50
51pub fn install_backup_schedule(frequency: BackupFrequency) -> std::io::Result<()> {
63 #[cfg(target_os = "windows")]
64 {
65 let (schedule, _) = match frequency {
66 BackupFrequency::Daily => ("DAILY", "/SC DAILY /ST 03:00"),
67 BackupFrequency::Weekly => ("WEEKLY", "/SC WEEKLY /D MON /ST 03:00"),
68 BackupFrequency::Monthly => ("MONTHLY", "/SC MONTHLY /D 1 /ST 03:00"),
69 };
70
71 Command::new("schtasks")
72 .args([
73 "/Create",
74 "/TN",
75 "NinjaBackup",
76 "/TR",
77 "ninja backup",
78 "/SC",
79 schedule,
80 "/ST",
81 "03:00",
82 ])
83 .status()?;
84 }
85
86 #[cfg(any(target_os = "linux", target_os = "macos"))]
87 {
88 let cron_expr = match frequency {
89 BackupFrequency::Daily => "0 3 * * *",
90 BackupFrequency::Weekly => "0 3 * * 1",
91 BackupFrequency::Monthly => "0 3 1 * *",
92 };
93
94 let job = format!("{} /usr/local/bin/ninja backup\n", cron_expr);
95
96 let output = Command::new("bash")
97 .arg("-c")
98 .arg(format!(
99 "(crontab -l 2>/dev/null; echo \"{}\") | crontab -",
100 job.replace("\"", "\\\"")
101 ))
102 .output()?;
103
104 if !output.status.success() {
105 eprintln!("Failed to install cron job: {:?}", output);
106 }
107 }
108
109 Ok(())
110}
111
112pub fn uninstall_backup_schedule() -> std::io::Result<()> {
121 #[cfg(target_os = "windows")]
122 {
123 Command::new("schtasks")
124 .args(["/Delete", "/TN", "NinjaBackup", "/F"])
125 .status()?;
126 }
127
128 #[cfg(any(target_os = "linux", target_os = "macos"))]
129 {
130 let output = Command::new("bash")
131 .arg("-c")
132 .arg("crontab -l 2>/dev/null | grep -v 'ninja backup' | crontab -")
133 .output()?;
134
135 if !output.status.success() {
136 eprintln!("Failed to remove cron job: {:?}", output);
137 }
138 }
139
140 Ok(())
141}
142
143pub async fn create_backup(
156 manager: &ShurikenManager,
157 compression: Option<CompressionType>,
158) -> Result<()> {
159 let backup_dir = manager.root_path.join("backups");
160
161 if !backup_dir.exists() {
163 tokio::fs::create_dir_all(&backup_dir)
164 .await
165 .context("Failed to create backup directory")?;
166 }
167
168 let backup_file_path = backup_dir.join(format!(
169 "backup-{}.tar.gz",
170 Utc::now().format("%Y-%m-%d-%H-%M-%S")
171 ));
172
173 let projects_path = manager.root_path.join("projects");
174 let backup_file_path_clone = backup_file_path.clone();
175
176 task::spawn_blocking(move || -> Result<()> {
178 let backup_file =
179 File::create(&backup_file_path_clone).context("Failed to create backup file")?;
180 let level: Compression = if let Some(compression) = compression {
181 match compression {
182 CompressionType::Best => Compression::best(),
183 CompressionType::Normal => Compression::default(),
184 CompressionType::Fast => Compression::fast(),
185 }
186 } else {
187 Compression::default()
188 };
189
190 let mut gzip = GzEncoder::new(backup_file, level);
191 {
192 let mut tar = TarBuilder::new(&mut gzip);
193
194 for entry in WalkBuilder::new(&projects_path)
195 .hidden(false)
196 .git_ignore(true)
197 .git_global(true)
198 .ignore(true)
199 .build()
200 {
201 let entry = entry.context("Failed to read directory entry")?;
202 if entry.file_type().is_some_and(|ft| ft.is_file()) {
203 let path = entry.path();
204 let rel_path = path
205 .strip_prefix(&projects_path)
206 .context("Failed to strip prefix for path")?;
207 tar.append_path_with_name(path, rel_path)
208 .context("Failed to append file to tar")?;
209 }
210 }
211 tar.finish().context("Failed to finish tar archive")?;
212 }
213
214 gzip.finish().context("Failed to finish gzip compression")?;
215 Ok(())
216 })
217 .await
218 .context("Backup task panicked")??;
219
220 let fs_builder = Fs::default().root(&backup_dir.display().to_string());
222 let fs_op = Operator::new(fs_builder)
223 .context("Failed to create Opendal operator")?
224 .finish();
225
226 let backup_name = backup_file_path
227 .file_name()
228 .context("Invalid backup file path: no filename")?
229 .to_string_lossy();
230 let data = tokio::fs::read(&backup_file_path)
231 .await
232 .context("Failed to read backup file")?;
233 fs_op
234 .write(&backup_name, data)
235 .await
236 .context("Failed to write backup file to Opendal")?;
237
238 Ok(())
239}
240
241pub async fn restore_backup(manager: &ShurikenManager, file: &Path) -> Result<()> {
253 let backup_file_path = file.to_path_buf();
254 let projects_path = manager.root_path.join("projects");
255 let backup_file_path_clone = backup_file_path.clone();
256 let projects_path_clone = projects_path.clone();
257
258 task::spawn_blocking(move || -> Result<()> {
260 let backup_file =
261 File::open(&backup_file_path_clone).context("Failed to open backup file")?;
262 let decompressor = GzDecoder::new(backup_file);
263 let mut archive = Archive::new(decompressor);
264
265 archive
266 .unpack(&projects_path_clone)
267 .context("Failed to unpack backup archive")?;
268
269 Ok(())
270 })
271 .await
272 .context("Restore task panicked")??;
273
274 Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use serde_json;
281
282 #[test]
283 fn test_compression_type_serialization() {
284 let fast = CompressionType::Fast;
286 let json = serde_json::to_string(&fast).unwrap();
287 assert!(json.contains("Fast"));
288
289 let normal = CompressionType::Normal;
291 let json = serde_json::to_string(&normal).unwrap();
292 assert!(json.contains("Normal"));
293
294 let best = CompressionType::Best;
296 let json = serde_json::to_string(&best).unwrap();
297 assert!(json.contains("Best"));
298 }
299
300 #[test]
301 fn test_compression_type_deserialization() {
302 let json = "\"Fast\"";
304 let fast: CompressionType = serde_json::from_str(json).unwrap();
305 assert!(matches!(fast, CompressionType::Fast));
306
307 let json = "\"Normal\"";
309 let normal: CompressionType = serde_json::from_str(json).unwrap();
310 assert!(matches!(normal, CompressionType::Normal));
311
312 let json = "\"Best\"";
314 let best: CompressionType = serde_json::from_str(json).unwrap();
315 assert!(matches!(best, CompressionType::Best));
316 }
317
318 #[test]
319 fn test_backup_frequency_variants() {
320 let daily = BackupFrequency::Daily;
322 let weekly = BackupFrequency::Weekly;
323 let monthly = BackupFrequency::Monthly;
324
325 let _ = format!("{:?}", daily);
327 let _ = format!("{:?}", weekly);
328 let _ = format!("{:?}", monthly);
329 }
330
331 #[test]
332 fn test_compression_level_conversion() {
333 let fast = CompressionType::Fast;
335 let fast_copy = fast;
336 let _ = format!("{:?}", fast);
337 let _ = format!("{:?}", fast_copy);
338 }
339
340 #[test]
341 fn test_compression_type_variants() {
342 let fast = CompressionType::Fast;
344 let normal = CompressionType::Normal;
345 let best = CompressionType::Best;
346
347 let fast_str = format!("{:?}", fast);
349 let normal_str = format!("{:?}", normal);
350 let best_str = format!("{:?}", best);
351
352 assert_ne!(fast_str, normal_str);
353 assert_ne!(normal_str, best_str);
354 assert_ne!(fast_str, best_str);
355 }
356}