1use crate::manager::ShurikenManager;
2use anyhow::{Context, Result};
3use chrono::Utc;
4use flate2::Compression;
5use flate2::{read::GzDecoder, write::GzEncoder};
6use ignore::WalkBuilder;
7use opendal::Operator;
8use opendal::services::Fs;
9use serde::{Deserialize, Serialize};
10use std::fs::File;
11use std::{path::Path, process::Command};
12use tar::{Archive, Builder as TarBuilder};
13use tokio::task;
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
16pub enum CompressionType {
17 Fast,
18 Normal,
19 Best,
20}
21
22#[derive(Debug, Clone, Copy)]
23pub enum BackupFrequency {
24 Daily,
25 Weekly,
26 Monthly,
27}
28
29pub fn install_backup_schedule(frequency: BackupFrequency) -> std::io::Result<()> {
30 #[cfg(target_os = "windows")]
31 {
32 let (schedule, _) = match frequency {
33 BackupFrequency::Daily => ("DAILY", "/SC DAILY /ST 03:00"),
34 BackupFrequency::Weekly => ("WEEKLY", "/SC WEEKLY /D MON /ST 03:00"),
35 BackupFrequency::Monthly => ("MONTHLY", "/SC MONTHLY /D 1 /ST 03:00"),
36 };
37
38 Command::new("schtasks")
39 .args([
40 "/Create",
41 "/TN",
42 "NinjaBackup",
43 "/TR",
44 "ninja backup",
45 "/SC",
46 schedule,
47 "/ST",
48 "03:00",
49 ])
50 .status()?;
51 }
52
53 #[cfg(any(target_os = "linux", target_os = "macos"))]
54 {
55 let cron_expr = match frequency {
56 BackupFrequency::Daily => "0 3 * * *",
57 BackupFrequency::Weekly => "0 3 * * 1",
58 BackupFrequency::Monthly => "0 3 1 * *",
59 };
60
61 let job = format!("{} /usr/local/bin/ninja backup\n", cron_expr);
62
63 let output = Command::new("bash")
64 .arg("-c")
65 .arg(format!(
66 "(crontab -l 2>/dev/null; echo \"{}\") | crontab -",
67 job.replace("\"", "\\\"")
68 ))
69 .output()?;
70
71 if !output.status.success() {
72 eprintln!("Failed to install cron job: {:?}", output);
73 }
74 }
75
76 Ok(())
77}
78
79pub fn uninstall_backup_schedule() -> std::io::Result<()> {
80 #[cfg(target_os = "windows")]
81 {
82 Command::new("schtasks")
83 .args(["/Delete", "/TN", "NinjaBackup", "/F"])
84 .status()?;
85 }
86
87 #[cfg(any(target_os = "linux", target_os = "macos"))]
88 {
89 let output = Command::new("bash")
90 .arg("-c")
91 .arg("crontab -l 2>/dev/null | grep -v 'ninja backup' | crontab -")
92 .output()?;
93
94 if !output.status.success() {
95 eprintln!("Failed to remove cron job: {:?}", output);
96 }
97 }
98
99 Ok(())
100}
101
102pub async fn create_backup(
103 manager: &ShurikenManager,
104 compression: Option<CompressionType>,
105) -> Result<()> {
106 let backup_dir = manager.root_path.join("backups");
107
108 if !backup_dir.exists() {
110 tokio::fs::create_dir_all(&backup_dir)
111 .await
112 .context("Failed to create backup directory")?;
113 }
114
115 let backup_file_path = backup_dir.join(format!(
116 "backup-{}.tar.gz",
117 Utc::now().format("%Y-%m-%d-%H-%M-%S")
118 ));
119
120 let projects_path = manager.root_path.join("projects");
121 let backup_file_path_clone = backup_file_path.clone();
122
123 task::spawn_blocking(move || -> Result<()> {
125 let backup_file =
126 File::create(&backup_file_path_clone).context("Failed to create backup file")?;
127 let level: Compression = if let Some(compression) = compression {
128 match compression {
129 CompressionType::Best => Compression::best(),
130 CompressionType::Normal => Compression::default(),
131 CompressionType::Fast => Compression::fast(),
132 }
133 } else {
134 Compression::default()
135 };
136
137 let mut gzip = GzEncoder::new(backup_file, level);
138 {
139 let mut tar = TarBuilder::new(&mut gzip);
140
141 for entry in WalkBuilder::new(&projects_path)
142 .hidden(false)
143 .git_ignore(true)
144 .git_global(true)
145 .ignore(true)
146 .build()
147 {
148 let entry = entry.context("Failed to read directory entry")?;
149 if entry.file_type().is_some_and(|ft| ft.is_file()) {
150 let path = entry.path();
151 let rel_path = path
152 .strip_prefix(&projects_path)
153 .context("Failed to strip prefix for path")?;
154 tar.append_path_with_name(path, rel_path)
155 .context("Failed to append file to tar")?;
156 }
157 }
158 tar.finish().context("Failed to finish tar archive")?;
159 }
160
161 gzip.finish().context("Failed to finish gzip compression")?;
162 Ok(())
163 })
164 .await
165 .context("Backup task panicked")??;
166
167 let fs_builder = Fs::default().root(&backup_dir.display().to_string());
169 let fs_op = Operator::new(fs_builder)
170 .context("Failed to create Opendal operator")?
171 .finish();
172
173 let backup_name = backup_file_path
174 .file_name()
175 .context("Invalid backup file path: no filename")?
176 .to_string_lossy();
177 let data = tokio::fs::read(&backup_file_path)
178 .await
179 .context("Failed to read backup file")?;
180 fs_op
181 .write(&backup_name, data)
182 .await
183 .context("Failed to write backup file to Opendal")?;
184
185 Ok(())
186}
187
188pub async fn restore_backup(manager: &ShurikenManager, file: &Path) -> Result<()> {
189 let backup_file_path = file.to_path_buf();
190 let projects_path = manager.root_path.join("projects");
191 let backup_file_path_clone = backup_file_path.clone();
192 let projects_path_clone = projects_path.clone();
193
194 task::spawn_blocking(move || -> Result<()> {
196 let backup_file =
197 File::open(&backup_file_path_clone).context("Failed to open backup file")?;
198 let decompressor = GzDecoder::new(backup_file);
199 let mut archive = Archive::new(decompressor);
200
201 archive
202 .unpack(&projects_path_clone)
203 .context("Failed to unpack backup archive")?;
204
205 Ok(())
206 })
207 .await
208 .context("Restore task panicked")??;
209
210 Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use serde_json;
217
218 #[test]
219 fn test_compression_type_serialization() {
220 let fast = CompressionType::Fast;
222 let json = serde_json::to_string(&fast).unwrap();
223 assert!(json.contains("Fast"));
224
225 let normal = CompressionType::Normal;
227 let json = serde_json::to_string(&normal).unwrap();
228 assert!(json.contains("Normal"));
229
230 let best = CompressionType::Best;
232 let json = serde_json::to_string(&best).unwrap();
233 assert!(json.contains("Best"));
234 }
235
236 #[test]
237 fn test_compression_type_deserialization() {
238 let json = "\"Fast\"";
240 let fast: CompressionType = serde_json::from_str(json).unwrap();
241 assert!(matches!(fast, CompressionType::Fast));
242
243 let json = "\"Normal\"";
245 let normal: CompressionType = serde_json::from_str(json).unwrap();
246 assert!(matches!(normal, CompressionType::Normal));
247
248 let json = "\"Best\"";
250 let best: CompressionType = serde_json::from_str(json).unwrap();
251 assert!(matches!(best, CompressionType::Best));
252 }
253
254 #[test]
255 fn test_backup_frequency_variants() {
256 let daily = BackupFrequency::Daily;
258 let weekly = BackupFrequency::Weekly;
259 let monthly = BackupFrequency::Monthly;
260
261 let _ = format!("{:?}", daily);
263 let _ = format!("{:?}", weekly);
264 let _ = format!("{:?}", monthly);
265 }
266
267 #[test]
268 fn test_compression_level_conversion() {
269 let fast = CompressionType::Fast;
271 let fast_copy = fast;
272 let _ = format!("{:?}", fast);
273 let _ = format!("{:?}", fast_copy);
274 }
275
276 #[test]
277 fn test_compression_type_variants() {
278 let fast = CompressionType::Fast;
280 let normal = CompressionType::Normal;
281 let best = CompressionType::Best;
282
283 let fast_str = format!("{:?}", fast);
285 let normal_str = format!("{:?}", normal);
286 let best_str = format!("{:?}", best);
287
288 assert_ne!(fast_str, normal_str);
289 assert_ne!(normal_str, best_str);
290 assert_ne!(fast_str, best_str);
291 }
292}