Skip to main content

innate_core/backup/
command.rs

1#[derive(Subcommand)]
2pub enum BackupCommands {
3    /// Backup the database now (respects auto_backup_interval_hours check; use --force to skip)
4    Run {
5        /// Skip the 24-hour interval check and backup immediately
6        #[arg(long)]
7        force: bool,
8    },
9    /// Show last backup time and R2 config status
10    Status,
11    /// List all backups stored in R2
12    List,
13    /// Delete backups older than retention_days (keeps min_backups regardless)
14    Prune,
15}
16pub(crate) fn run_command(action: &BackupCommands, db_path: &Path) -> anyhow::Result<()> {
17    use super::R2BackupService;
18
19    let settings = crate::settings::load();
20    let cfg = settings.backup.as_ref().ok_or_else(|| {
21        anyhow::anyhow!(
22            "No backup config found in ~/.innate/settings.json.\n\
23             Add a \"backup\" section with \"enable\": true and \"r2\" credentials to enable R2 backup."
24        )
25    })?;
26
27    // status works regardless of enable flag
28    if let BackupCommands::Status = action {
29        let state = R2BackupService::last_backup_state();
30        println!("R2 backup enabled : {}", cfg.enable);
31        println!(
32            "R2 bucket         : {}",
33            cfg.r2.as_ref().map(|r| r.bucket.as_str()).unwrap_or("-")
34        );
35        println!(
36            "Last backup       : {}",
37            state.last_backup_at.as_deref().unwrap_or("never")
38        );
39        println!(
40            "Last backup key   : {}",
41            state.last_backup_key.as_deref().unwrap_or("-")
42        );
43        let due = R2BackupService::needs_backup(cfg.auto_backup_interval_hours);
44        println!(
45            "Backup due        : {}",
46            if cfg.enable && due {
47                "yes"
48            } else if !cfg.enable {
49                "disabled"
50            } else {
51                "no"
52            }
53        );
54        println!("Interval (h)      : {}", cfg.auto_backup_interval_hours);
55        println!("Retention (days)  : {}", cfg.retention_days);
56        println!("Min backups       : {}", cfg.min_backups);
57        return Ok(());
58    }
59
60    if !cfg.enable {
61        anyhow::bail!(
62            "R2 backup is disabled (backup.enable = false).\n\
63             Set \"enable\": true in the backup section of ~/.innate/settings.json to activate."
64        );
65    }
66    let r2_cfg = cfg
67        .r2
68        .as_ref()
69        .ok_or_else(|| anyhow::anyhow!("backup.r2 not configured in ~/.innate/settings.json"))?;
70
71    match action {
72        BackupCommands::Run { force } => {
73            if !force && !R2BackupService::needs_backup(cfg.auto_backup_interval_hours) {
74                let state = R2BackupService::last_backup_state();
75                println!(
76                    "backup not due yet (last: {}; interval: {}h). Use --force to override.",
77                    state.last_backup_at.as_deref().unwrap_or("never"),
78                    cfg.auto_backup_interval_hours
79                );
80                return Ok(());
81            }
82            println!("Starting backup to R2 bucket '{}'…", r2_cfg.bucket);
83            let svc = R2BackupService::from_config(r2_cfg)?;
84            let result = svc.backup_now(db_path, cfg.retention_days, cfg.min_backups)?;
85            println!("Backed up: {} ({} bytes)", result.key, result.size_bytes);
86            if !result.prune.deleted.is_empty() {
87                println!("Pruned {} old backup(s):", result.prune.deleted.len());
88                for k in &result.prune.deleted {
89                    println!("  - {k}");
90                }
91            }
92            if result.prune.protected_by_min > 0 {
93                println!(
94                    "  ({} old backup(s) kept to satisfy min_backups={})",
95                    result.prune.protected_by_min, cfg.min_backups
96                );
97            }
98            println!("Done. {} backup(s) remain in R2.", result.prune.kept);
99        }
100        BackupCommands::Status => unreachable!(), // handled above
101        BackupCommands::List => {
102            let svc = R2BackupService::from_config(r2_cfg)?;
103            let backups = svc.list_backups()?;
104            if backups.is_empty() {
105                println!("No backups found in R2.");
106            } else {
107                println!("{} backup(s):", backups.len());
108                for b in &backups {
109                    println!("  {} | {} | {} bytes", b.last_modified, b.key, b.size_bytes);
110                }
111            }
112        }
113        BackupCommands::Prune => {
114            let svc = R2BackupService::from_config(r2_cfg)?;
115            let result = svc.prune_old_backups(cfg.retention_days, cfg.min_backups)?;
116            if result.deleted.is_empty() {
117                println!("Nothing to prune ({} backup(s) kept).", result.kept);
118            } else {
119                println!("Deleted {} backup(s):", result.deleted.len());
120                for k in &result.deleted {
121                    println!("  - {k}");
122                }
123                if result.protected_by_min > 0 {
124                    println!(
125                        "  ({} old backup(s) kept to satisfy min_backups={})",
126                        result.protected_by_min, cfg.min_backups
127                    );
128                }
129                println!("{} backup(s) remain.", result.kept);
130            }
131        }
132    }
133    Ok(())
134}
135use std::path::Path;
136
137use clap::Subcommand;