Skip to main content

ninja/backup/
mod.rs

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    // Make sure backup directory exists
109    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    // Run synchronous backup in blocking task
124    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    // Optional: upload to Opendal
168    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    // Run synchronous restore in blocking task
195    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        // Test Fast compression
221        let fast = CompressionType::Fast;
222        let json = serde_json::to_string(&fast).unwrap();
223        assert!(json.contains("Fast"));
224
225        // Test Normal compression
226        let normal = CompressionType::Normal;
227        let json = serde_json::to_string(&normal).unwrap();
228        assert!(json.contains("Normal"));
229
230        // Test Best compression
231        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        // Test deserializing Fast
239        let json = "\"Fast\"";
240        let fast: CompressionType = serde_json::from_str(json).unwrap();
241        assert!(matches!(fast, CompressionType::Fast));
242
243        // Test deserializing Normal
244        let json = "\"Normal\"";
245        let normal: CompressionType = serde_json::from_str(json).unwrap();
246        assert!(matches!(normal, CompressionType::Normal));
247
248        // Test deserializing Best
249        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        // Test that all variants are constructible
257        let daily = BackupFrequency::Daily;
258        let weekly = BackupFrequency::Weekly;
259        let monthly = BackupFrequency::Monthly;
260
261        // Verify they can be formatted (suppress unused result warning)
262        let _ = format!("{:?}", daily);
263        let _ = format!("{:?}", weekly);
264        let _ = format!("{:?}", monthly);
265    }
266
267    #[test]
268    fn test_compression_level_conversion() {
269        // Test that compression types can be copied
270        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        // Ensure all compression types are distinct
279        let fast = CompressionType::Fast;
280        let normal = CompressionType::Normal;
281        let best = CompressionType::Best;
282
283        // Debug format should be different
284        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}