Skip to main content

ninja/backup/
mod.rs

1//! Backup and restore functionality for Ninja projects.
2//!
3//! Provides functionality to create compressed backups of all projects
4//! and restore from backups. Supports scheduling backups using cron (Unix)
5//! or Task Scheduler (Windows).
6
7use 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/// Compression level for backup archives.
22///
23/// - `Fast`: Compression level 1 (fastest, larger file)
24/// - `Normal`: Default compression level
25/// - `Best`: Maximum compression level (slowest, smallest file)
26#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
27pub enum CompressionType {
28    /// Fast compression
29    Fast,
30    /// Normal compression
31    Normal,
32    /// Best compression
33    Best,
34}
35
36/// Frequency for scheduled backups.
37///
38/// - `Daily`: Every day at 3 AM
39/// - `Weekly`: Every Monday at 3 AM
40/// - `Monthly`: First of each month at 3 AM
41#[derive(Debug, Clone, Copy)]
42pub enum BackupFrequency {
43    /// Daily backups
44    Daily,
45    /// Weekly backups
46    Weekly,
47    /// Monthly backups
48    Monthly,
49}
50
51/// Installs a scheduled backup job.
52///
53/// On Windows: Uses Task Scheduler to schedule the `ninja backup` command.
54/// On Unix: Uses cron to schedule backups.
55///
56/// # Arguments
57/// - `frequency`: How often to run backups (daily, weekly, or monthly)
58///
59/// # Returns
60/// - `Ok(())` if installation succeeded
61/// - `Err` if system command fails
62pub 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
112/// Removes the scheduled backup job.
113///
114/// On Windows: Removes the NinjaBackup task from Task Scheduler.
115/// On Unix: Removes the cron job.
116///
117/// # Returns
118/// - `Ok(())` if removal succeeded
119/// - `Err` if system command fails
120pub 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
143/// Creates a backup of all projects.
144///
145/// Compresses all projects into a tar.gz file with a timestamp in the filename.
146/// Stored in ~/.ninja/backups/ directory.
147///
148/// # Arguments
149/// - `manager`: Reference to the ShurikenManager
150/// - `compression`: Optional compression level (defaults to Normal if None)
151///
152/// # Returns
153/// - `Ok(())` if backup succeeded
154/// - `Err` if file operations or compression fails
155pub async fn create_backup(
156    manager: &ShurikenManager,
157    compression: Option<CompressionType>,
158) -> Result<()> {
159    let backup_dir = manager.root_path.join("backups");
160
161    // Make sure backup directory exists
162    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    // Run synchronous backup in blocking task
177    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    // Optional: upload to Opendal
221    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
241/// Restores projects from a backup archive.
242///
243/// Extracts a tar.gz backup file to the projects directory.
244///
245/// # Arguments
246/// - `manager`: Reference to the ShurikenManager
247/// - `file`: Path to the backup file to restore
248///
249/// # Returns
250/// - `Ok(())` if restore succeeded
251/// - `Err` if file operations or extraction fails
252pub 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    // Run synchronous restore in blocking task
259    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        // Test Fast compression
285        let fast = CompressionType::Fast;
286        let json = serde_json::to_string(&fast).unwrap();
287        assert!(json.contains("Fast"));
288
289        // Test Normal compression
290        let normal = CompressionType::Normal;
291        let json = serde_json::to_string(&normal).unwrap();
292        assert!(json.contains("Normal"));
293
294        // Test Best compression
295        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        // Test deserializing Fast
303        let json = "\"Fast\"";
304        let fast: CompressionType = serde_json::from_str(json).unwrap();
305        assert!(matches!(fast, CompressionType::Fast));
306
307        // Test deserializing Normal
308        let json = "\"Normal\"";
309        let normal: CompressionType = serde_json::from_str(json).unwrap();
310        assert!(matches!(normal, CompressionType::Normal));
311
312        // Test deserializing Best
313        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        // Test that all variants are constructible
321        let daily = BackupFrequency::Daily;
322        let weekly = BackupFrequency::Weekly;
323        let monthly = BackupFrequency::Monthly;
324
325        // Verify they can be formatted (suppress unused result warning)
326        let _ = format!("{:?}", daily);
327        let _ = format!("{:?}", weekly);
328        let _ = format!("{:?}", monthly);
329    }
330
331    #[test]
332    fn test_compression_level_conversion() {
333        // Test that compression types can be copied
334        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        // Ensure all compression types are distinct
343        let fast = CompressionType::Fast;
344        let normal = CompressionType::Normal;
345        let best = CompressionType::Best;
346
347        // Debug format should be different
348        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}