Add backup prune CLI and update roadmap

This commit is contained in:
thePR0M3TH3AN
2025-05-21 16:19:32 -04:00
parent 886b9d12e7
commit 07693a7925
7 changed files with 128 additions and 19 deletions

View File

@@ -21,3 +21,4 @@
| `version diff` | — |
| `event add` | — |
| `event timeline` | — |
| `backup run` | --dir, --prune, --verify, --file |

View File

@@ -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<String>,
},
/// 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 },

67
cli-bin/src/cli/backup.rs Normal file
View File

@@ -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<PathBuf>,
/// Keep only N newest backups
#[arg(long)]
pub prune: Option<usize>,
/// Verify a backup file
#[arg(long)]
pub verify: bool,
/// Backup file to verify (used with --verify)
#[arg(long)]
pub file: Option<PathBuf>,
}
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(())
}

View File

@@ -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"]

View File

@@ -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 } => {

View File

@@ -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) | Epic1 | `marlin watch .` | DP002 |
| Backup autoprune | Epic1 | `backup --prune N` | |
| Dirtyscan | Epic2 | `scan --dirty` | DP002 |
| Grep snippets | Phase3 | `search -C3 …` | DP004 |
| Hash / dedupe | Phase4 | `scan --rehash` | DP005 |
| Tag aliases | Phase5 | `tag alias` commands | DP006 |
| Search DSL v2 | Phase6 | new grammar, `--legacy-search` flag | DP007 |
| Relationship templates | Phase7 | `template new/apply` | DP008 |
| TUI v1 | Phase8 | `marlintui` | DP009 |
---
## 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 | **26May 25** |
| 2 | Tarpaulin + Hyperfine jobs | @bob | **26May 25** |
| 3 | **DP001 Schema v1.1** draft | @carol | **30May 25** |
| 4 | backup prune CLI + nightly job | @dave | **05Jun 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.*

View File

@@ -216,6 +216,19 @@ impl BackupManager {
Ok(PruneResult { kept, removed })
}
pub fn verify_backup(&self, backup_id: &str) -> Result<bool> {
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");
}
}