diff --git a/cli-bin/docs/cli_cheatsheet.md b/cli-bin/docs/cli_cheatsheet.md index f9297c7..402fe7f 100644 --- a/cli-bin/docs/cli_cheatsheet.md +++ b/cli-bin/docs/cli_cheatsheet.md @@ -21,3 +21,4 @@ | `version diff` | — | | `event add` | — | | `event timeline` | — | +| `backup run` | --dir, --prune, --verify, --file | diff --git a/cli-bin/src/cli.rs b/cli-bin/src/cli.rs index 2cc734c..a688035 100644 --- a/cli-bin/src/cli.rs +++ b/cli-bin/src/cli.rs @@ -1,6 +1,7 @@ // src/cli.rs pub mod annotate; +pub mod backup; pub mod coll; pub mod event; pub mod link; @@ -73,8 +74,8 @@ pub enum Commands { exec: Option, }, - /// Create a timestamped backup of the database - Backup, + /// Create or manage database backups + Backup(backup::BackupOpts), /// Restore from a backup file (overwrites current DB) Restore { backup_path: std::path::PathBuf }, diff --git a/cli-bin/src/cli/backup.rs b/cli-bin/src/cli/backup.rs new file mode 100644 index 0000000..9d219a8 --- /dev/null +++ b/cli-bin/src/cli/backup.rs @@ -0,0 +1,67 @@ +// src/cli/backup.rs +use crate::cli::Format; +use anyhow::{Context, Result}; +use clap::Args; +use libmarlin::backup::BackupManager; +use rusqlite::Connection; +use std::path::{Path, PathBuf}; + +/// Options for the `backup` command +#[derive(Args, Debug)] +pub struct BackupOpts { + /// Directory to store backups (defaults next to DB) + #[arg(long)] + pub dir: Option, + + /// Keep only N newest backups + #[arg(long)] + pub prune: Option, + + /// Verify a backup file + #[arg(long)] + pub verify: bool, + + /// Backup file to verify (used with --verify) + #[arg(long)] + pub file: Option, +} + +pub fn run(opts: &BackupOpts, db_path: &Path, _conn: &mut Connection, _fmt: Format) -> Result<()> { + let backups_dir = opts + .dir + .clone() + .unwrap_or_else(|| db_path.parent().unwrap().join("backups")); + let manager = BackupManager::new(db_path, &backups_dir)?; + + if opts.verify { + let file = opts + .file + .as_ref() + .context("--file required with --verify")?; + let name = file + .file_name() + .and_then(|n| n.to_str()) + .context("invalid backup file name")?; + let ok = manager.verify_backup(name)?; + if ok { + println!("Backup OK: {}", name); + } else { + println!("Backup corrupted: {}", name); + } + return Ok(()); + } + + if let Some(n) = opts.prune { + let result = manager.prune(n)?; + println!( + "Pruned {} old backups, kept {}", + result.removed.len(), + result.kept.len() + ); + return Ok(()); + } + + let info = manager.create_backup()?; + println!("Created backup {}", info.id); + Ok(()) +} diff --git a/cli-bin/src/cli/commands.yaml b/cli-bin/src/cli/commands.yaml index 19ea663..343d6dc 100644 --- a/cli-bin/src/cli/commands.yaml +++ b/cli-bin/src/cli/commands.yaml @@ -79,3 +79,9 @@ event: add: args: [file, date, description] timeline: {} + +backup: + description: "Create, prune or verify backups" + actions: + run: + flags: ["--dir", "--prune", "--verify", "--file"] diff --git a/cli-bin/src/main.rs b/cli-bin/src/main.rs index 4be42fa..b178bff 100644 --- a/cli-bin/src/main.rs +++ b/cli-bin/src/main.rs @@ -41,7 +41,7 @@ fn main() -> Result<()> { let cfg = config::Config::load()?; // resolves DB path match &args.command { - Commands::Init | Commands::Backup | Commands::Restore { .. } => {} + Commands::Init | Commands::Backup(_) | Commands::Restore { .. } => {} _ => match db::backup(&cfg.db_path) { Ok(p) => info!("Pre-command auto-backup created at {}", p.display()), Err(e) => error!("Failed to create pre-command auto-backup: {e}"), @@ -100,9 +100,8 @@ fn main() -> Result<()> { Commands::Search { query, exec } => run_search(&conn, &query, exec)?, /* ---- maintenance ---------------------------------------- */ - Commands::Backup => { - let p = db::backup(&cfg.db_path)?; - println!("Backup created: {}", p.display()); + Commands::Backup(opts) => { + cli::backup::run(&opts, &cfg.db_path, &mut conn, args.format)?; } Commands::Restore { backup_path } => { diff --git a/docs/roadmap.md b/docs/roadmap.md index 160513e..8c324cf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ -# Marlin ― Delivery Road-map **v3** +# Marlin ― Delivery Road-map **v3.2** -*Engineering-ready version — updated 2025-05-17* +*Engineering-ready version — updated 2025-05-18* > **Legend** > **△** = engineering artefact (spec / ADR / perf target)  **✦** = user-visible deliverable @@ -39,15 +39,20 @@ ### 2 · Feature cross-matrix (quick look-ups) -| Capability | Sprint / Phase | CLI flag or GUI element | Linked DP | -| ------------------------------------- | -------------- | ---------------------------------- | --------- | -| Relationship **templates** | P7 | `template new`, `template apply` | DP-008 | -| Positive / negative filter combinator | P6 | DSL `+tag:foo -tag:bar date>=2025` | DP-007 | -| ~~Dirty-scan optimisation~~ | ~~E1~~ | ~~`scan --dirty`~~ | ~~DP-002~~ | -| Watch-mode | E2 | `marlin watch .` | DP-003 | -| Grep snippets | P3 | `search -C3 "foo"` | DP-004 | -| Hash / dedupe | P4 | `scan --rehash` | DP-005 | +| Capability | Sprint / Phase | CLI / GUI element | Linked DP | +| -------------------------- | -------------- | -------------------- | --------- | +| Crate split & docs autogen | S0 | — | – | +| Tarpaulin coverage gate | S0 | — | – | +| Watch mode (FS events) | Epic 1 | `marlin watch .` | DP‑002 | +| Backup auto‑prune | Epic 1 | `backup --prune N` | – | +| Dirty‑scan | Epic 2 | `scan --dirty` | DP‑002 | +| Grep snippets | Phase 3 | `search -C3 …` | DP‑004 | +| Hash / dedupe | Phase 4 | `scan --rehash` | DP‑005 | +| Tag aliases | Phase 5 | `tag alias` commands | DP‑006 | +| Search DSL v2 | Phase 6 | new grammar, `--legacy-search` flag | DP‑007 | +| Relationship templates | Phase 7 | `template new/apply` | DP‑008 | +| TUI v1 | Phase 8 | `marlin‑tui` | DP‑009 | --- ## 3 · Milestone acceptance checklist @@ -65,8 +70,11 @@ Before a milestone is declared “shipped”: ### 4 · Next immediate actions -~~1. **Write DP-001 (Schema v1.1)** — owner @alice, due 21 May~~ -~~2. **Set up Tarpaulin & Hyperfine jobs** — @bob, due 23 May~~ -~~3. **Spike dirty-flag logic** — @carol 2-day time-box, outcome in DP-002~~ +| # | Task | Owner | Due | +| - | ------------------------------ | ------ | ------------- | +| 1 | Crate split + CI baseline | @alice | **26 May 25** | +| 2 | Tarpaulin + Hyperfine jobs | @bob | **26 May 25** | +| 3 | **DP‑001 Schema v1.1** draft | @carol | **30 May 25** | +| 4 | backup prune CLI + nightly job | @dave | **05 Jun 25** | > *This roadmap now contains both product-level “what” and engineering-level “how/when/prove it”. It should allow a new contributor to jump in, pick the matching DP, and know exactly the bar they must clear for their code to merge.* diff --git a/libmarlin/src/backup.rs b/libmarlin/src/backup.rs index 7834da3..b184cc4 100644 --- a/libmarlin/src/backup.rs +++ b/libmarlin/src/backup.rs @@ -216,6 +216,19 @@ impl BackupManager { Ok(PruneResult { kept, removed }) } + pub fn verify_backup(&self, backup_id: &str) -> Result { + let backup_file_path = self.backups_dir.join(backup_id); + if !backup_file_path.exists() || !backup_file_path.is_file() { + return Err(anyhow::Error::new(marlin_error::Error::NotFound(format!( + "Backup file not found or is not a file: {}", + backup_file_path.display() + )))); + } + let conn = rusqlite::Connection::open(&backup_file_path)?; + let res: String = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0))?; + Ok(res == "ok") + } + pub fn restore_from_backup(&self, backup_id: &str) -> Result<()> { let backup_file_path = self.backups_dir.join(backup_id); if !backup_file_path.exists() || !backup_file_path.is_file() { @@ -532,4 +545,18 @@ mod tests { assert_eq!(info.id, "backup_badformat.db"); assert_eq!(info.timestamp, expected_ts); } + + #[test] + fn verify_backup_ok() { + let tmp = tempdir().unwrap(); + let live_db = tmp.path().join("live_verify.db"); + let _conn = create_valid_live_db(&live_db); + + let backups_dir = tmp.path().join("ver_backups"); + let manager = BackupManager::new(&live_db, &backups_dir).unwrap(); + let info = manager.create_backup().unwrap(); + + let ok = manager.verify_backup(&info.id).unwrap(); + assert!(ok, "expected integrity check to pass"); + } }