From f2a4ca84a1151a5a7cb55b52b23bfccac4f60d69 Mon Sep 17 00:00:00 2001 From: Gyorgy Bolyki Date: Wed, 6 May 2026 15:12:15 +0100 Subject: [PATCH 1/5] chore: fix snapshot helper spelling --- src/commands/copy.rs | 4 ++-- src/commands/merge.rs | 4 ++-- src/commands/repair.rs | 4 ++-- src/commands/rewrite.rs | 6 +++--- src/repository.rs | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/commands/copy.rs b/src/commands/copy.rs index f32c016b3..28aadcaf4 100644 --- a/src/commands/copy.rs +++ b/src/commands/copy.rs @@ -4,7 +4,7 @@ use crate::{ Application, RUSTIC_APP, RusticConfig, commands::init::init_credentials, helpers::table_with_titles, - repository::{AllRepositoryOptions, IndexedRepo, Repo, get_snapots_from_ids}, + repository::{AllRepositoryOptions, IndexedRepo, Repo, get_snapshots_from_ids}, status_err, }; use abscissa_core::{Command, FrameworkError, Runnable, Shutdown, config::Override}; @@ -83,7 +83,7 @@ impl CopyCmd { fn inner_run(&self, repo: IndexedRepo) -> Result<()> { let config = RUSTIC_APP.config(); let config = config; - let mut snapshots = get_snapots_from_ids(&repo, &self.ids)?; + let mut snapshots = get_snapshots_from_ids(&repo, &self.ids)?; // sort for nicer output snapshots.sort_unstable(); diff --git a/src/commands/merge.rs b/src/commands/merge.rs index 76ec6ed7c..19836a76b 100644 --- a/src/commands/merge.rs +++ b/src/commands/merge.rs @@ -2,7 +2,7 @@ use crate::{ Application, RUSTIC_APP, - repository::{OpenRepo, get_snapots_from_ids}, + repository::{OpenRepo, get_snapshots_from_ids}, status_err, }; use abscissa_core::{Command, Runnable, Shutdown}; @@ -50,7 +50,7 @@ impl MergeCmd { let config = RUSTIC_APP.config(); let repo = repo.to_indexed_ids()?; - let snapshots = get_snapots_from_ids(&repo, &self.ids)?; + let snapshots = get_snapshots_from_ids(&repo, &self.ids)?; // Handle dry-run mode if config.global.dry_run { diff --git a/src/commands/repair.rs b/src/commands/repair.rs index f8fca0eae..acef5782c 100644 --- a/src/commands/repair.rs +++ b/src/commands/repair.rs @@ -2,7 +2,7 @@ use crate::{ Application, RUSTIC_APP, - repository::{IndexedRepo, OpenRepo, get_snapots_from_ids}, + repository::{IndexedRepo, OpenRepo, get_snapshots_from_ids}, status_err, }; use abscissa_core::{Command, Runnable, Shutdown}; @@ -85,7 +85,7 @@ impl Runnable for SnapSubCmd { impl SnapSubCmd { fn inner_run(&self, repo: IndexedRepo) -> Result<()> { let config = RUSTIC_APP.config(); - let snaps = get_snapots_from_ids(&repo, &self.ids)?; + let snaps = get_snapshots_from_ids(&repo, &self.ids)?; repo.repair_snapshots(&self.opts, snaps, config.global.dry_run)?; Ok(()) } diff --git a/src/commands/rewrite.rs b/src/commands/rewrite.rs index 446ad090b..e4f9e40ca 100644 --- a/src/commands/rewrite.rs +++ b/src/commands/rewrite.rs @@ -3,7 +3,7 @@ use crate::{ Application, RUSTIC_APP, commands::snapshots::print_snapshots, - repository::{IndexedRepo, OpenRepo, get_snapots_from_ids}, + repository::{IndexedRepo, OpenRepo, get_snapshots_from_ids}, status_err, }; @@ -75,7 +75,7 @@ impl RewriteCmd { } fn inner_run_open(&self, repo: OpenRepo) -> Result<()> { - let snapshots = get_snapots_from_ids(&repo, &self.ids)?; + let snapshots = get_snapshots_from_ids(&repo, &self.ids)?; let snaps = repo.rewrite_snapshots(snapshots, &self.opts())?; @@ -85,7 +85,7 @@ impl RewriteCmd { } fn inner_run_indexed(&self, repo: IndexedRepo) -> Result<()> { - let snapshots = get_snapots_from_ids(&repo, &self.ids)?; + let snapshots = get_snapshots_from_ids(&repo, &self.ids)?; let tree_opts = RewriteTreesOptions::default() .all_trees(self.all_trees) .excludes(self.excludes.clone()) diff --git a/src/repository.rs b/src/repository.rs index 2b64da173..0c96a6ad0 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -183,7 +183,7 @@ impl Repo { } // get snapshots from ids allowing `latest`, if empty use all snapshots respecting the filters. -pub fn get_snapots_from_ids( +pub fn get_snapshots_from_ids( repo: &Repository, ids: &[String], ) -> Result> { From 970bd86b22d08abf662b0056740b7d3110cc2c3e Mon Sep 17 00:00:00 2001 From: Gyorgy Bolyki Date: Wed, 6 May 2026 15:12:15 +0100 Subject: [PATCH 2/5] feat: add guided setup command --- Cargo.lock | 10 + Cargo.toml | 1 + src/commands.rs | 8 +- src/commands/backup.rs | 4 +- src/commands/setup.rs | 854 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 874 insertions(+), 3 deletions(-) create mode 100644 src/commands/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 8b742d124..5d4c82c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3315,6 +3315,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-strength" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bc33cd32cf9d77af191f8d81471321b21c6a58dc03b37d00584d1ea2c5f99" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "paste" version = "1.0.15" @@ -4305,6 +4314,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "password-strength", "predicates", "pretty_assertions", "prometheus", diff --git a/Cargo.toml b/Cargo.toml index e0d4d8483..af86c9708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,7 @@ opentelemetry-otlp = { version = "0.31.0", features = ["metrics"], optional = tr opentelemetry_sdk = { version = "0.31.0", default-features = false, features = ["metrics"], optional = true } rhai = { version = "1", features = ["sync", "serde", "no_optimize", "no_module", "no_custom_syntax", "only_i64"], optional = true } subst = "0.3.8" +password-strength = "1.0.0" [dev-dependencies] abscissa_core = { version = "0.9.0", default-features = false, features = ["testing"] } diff --git a/src/commands.rs b/src/commands.rs index 265e27559..f0cedc818 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -24,6 +24,7 @@ pub(crate) mod repoinfo; pub(crate) mod restore; pub(crate) mod rewrite; pub(crate) mod self_update; +pub(crate) mod setup; pub(crate) mod show_config; pub(crate) mod snapshots; pub(crate) mod tag; @@ -48,8 +49,8 @@ use crate::{ config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, docs::DocsCmd, dump::DumpCmd, forget::ForgetCmd, init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd, prune::PruneCmd, repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd, - rewrite::RewriteCmd, self_update::SelfUpdateCmd, show_config::ShowConfigCmd, - snapshots::SnapshotCmd, tag::TagCmd, + rewrite::RewriteCmd, self_update::SelfUpdateCmd, setup::SetupCmd, + show_config::ShowConfigCmd, snapshots::SnapshotCmd, tag::TagCmd, }, config::RusticConfig, }; @@ -150,6 +151,9 @@ enum RusticCmd { /// Show general information about the repository Repoinfo(Box), + /// Interactive setup wizard for configuring backups + Setup(Box), + /// Change tags of snapshots Tag(Box), diff --git a/src/commands/backup.rs b/src/commands/backup.rs index 8b2e927dc..95f845ae7 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -290,7 +290,9 @@ impl BackupCmd { .collect(); if config_snapshots.is_empty() { - bail!("no backup source given."); + bail!( + "No backup source given. Specify source paths on the command line (e.g. 'rustic backup /path/to/data') or define [[backup.snapshots]] sections in your config profile. Run 'rustic setup' for guided configuration." + ); } info!("using backup sources from config file."); diff --git a/src/commands/setup.rs b/src/commands/setup.rs new file mode 100644 index 000000000..7b0cc95f0 --- /dev/null +++ b/src/commands/setup.rs @@ -0,0 +1,854 @@ +//! `setup` subcommand - interactive wizard for configuring rustic backups + +use std::fs; +use std::path::{Path, PathBuf}; + +use abscissa_core::{Command, Runnable, Shutdown}; +use anyhow::{Result, anyhow, bail}; +use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme}; +use directories::ProjectDirs; +use log::{info, warn}; + +use crate::{Application, RUSTIC_APP, status_err}; + +/// `setup` subcommand - interactive wizard for configuring backups +#[derive(clap::Parser, Command, Debug)] +pub(crate) struct SetupCmd { + /// Profile name to create (without .toml extension). + /// Leave empty or use the default 'rustic' for the default profile. + #[clap(long, value_name = "PROFILE", default_value = "rustic")] + profile: String, + + /// Overwrite existing profile if it exists + #[clap(long)] + force: bool, +} + +impl Runnable for SetupCmd { + fn run(&self) { + if let Err(err) = self.inner_run() { + status_err!("{}", err); + RUSTIC_APP.shutdown(Shutdown::Crash); + } + } +} + +// Common exclusion presets. + +struct ExclusionPreset { + name: &'static str, + globs: Vec<&'static str>, +} + +fn exclusion_presets() -> Vec { + vec![ + ExclusionPreset { + name: "Node.js (node_modules)", + globs: vec!["!**/node_modules/**"], + }, + ExclusionPreset { + name: "Python (__pycache__, .venv, *.pyc)", + globs: vec!["!**/__pycache__/**", "!**/.venv/**", "!**/*.pyc"], + }, + ExclusionPreset { + name: "Rust (target/)", + globs: vec!["!**/target/**"], + }, + ExclusionPreset { + name: "Git (.git/)", + globs: vec!["!**/.git/**"], + }, + ExclusionPreset { + name: "IDE files (.idea/, .vscode/, *.swp)", + globs: vec!["!**/.idea/**", "!**/.vscode/**", "!**/*.swp"], + }, + ExclusionPreset { + name: "macOS (.DS_Store, ._*)", + globs: vec!["!**/.DS_Store", "!**/._*"], + }, + ExclusionPreset { + name: "Temporary files (*.tmp, *.bak, *~)", + globs: vec!["!**/*.tmp", "!**/*.bak", "!**/*~"], + }, + ExclusionPreset { + name: "Cache directories (CACHEDIR.TAG, .cache/)", + globs: vec!["!**/.cache/**"], + }, + ] +} + +// Wizard implementation. + +impl SetupCmd { + fn inner_run(&self) -> Result<()> { + let theme = ColorfulTheme::default(); + + println!(); + println!("rustic setup"); + println!("============"); + println!("This wizard configures a backup source, target, and retention policy."); + println!(); + + // Ask for profile name + let profile = loop { + let input: String = Input::with_theme(&theme) + .with_prompt("Profile name (leave empty for default)") + .default(self.profile.clone()) + .allow_empty(true) + .interact_text()?; + let p = if input.trim().is_empty() { + "rustic".to_string() + } else { + input.trim().to_string() + }; + + if let Err(err) = validate_profile_name(&p) { + println!("Invalid profile name: {err}"); + } else { + break p; + } + }; + println!(); + + // Step 1: Repository target. + + println!("Step 1: Repository (where to store backups)"); + println!(); + + let repo_types = vec![ + "Local path", + "S3-compatible storage", + "SFTP storage", + "rclone remote", + "REST server", + "OpenDAL (advanced)", + ]; + + let repo_type_idx = Select::with_theme(&theme) + .with_prompt("Where do you want to store backups?") + .items(&repo_types) + .default(0) + .interact()?; + + let mut repo_options = toml::Table::new(); + + let repository = match repo_type_idx { + 0 => { + // Local path + let path: String = Input::with_theme(&theme) + .with_prompt("Repository path") + .default("/backup/rustic".to_string()) + .interact_text()?; + let path = expand_tilde(&path); + let repo_path = Path::new(&path); + + if !repo_path.exists() { + let create = Confirm::with_theme(&theme) + .with_prompt(format!("Directory '{}' doesn't exist. Create it?", path)) + .default(true) + .interact()?; + if create { + fs::create_dir_all(repo_path)?; + info!("Created directory: {}", path); + } + } + + path + } + 1 => { + // S3 + let bucket: String = Input::with_theme(&theme) + .with_prompt("S3 bucket name") + .interact_text()?; + let root: String = Input::with_theme(&theme) + .with_prompt("Repository root inside bucket") + .default("/rustic".to_string()) + .interact_text()?; + let endpoint: String = Input::with_theme(&theme) + .with_prompt("S3 endpoint URL (optional, for non-AWS providers)") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + let region: String = Input::with_theme(&theme) + .with_prompt("S3 region (optional)") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + + _ = repo_options.insert("bucket".to_string(), toml::Value::String(bucket)); + _ = repo_options.insert("root".to_string(), toml::Value::String(root)); + if !endpoint.trim().is_empty() { + _ = repo_options.insert( + "endpoint".to_string(), + toml::Value::String(endpoint.trim().to_string()), + ); + } + if !region.trim().is_empty() { + _ = repo_options.insert( + "region".to_string(), + toml::Value::String(region.trim().to_string()), + ); + } + + "opendal:s3".to_string() + } + 2 => { + // SFTP + let endpoint: String = Input::with_theme(&theme) + .with_prompt("SFTP endpoint (host:port)") + .interact_text()?; + let user: String = Input::with_theme(&theme) + .with_prompt("SFTP user") + .interact_text()?; + let root: String = Input::with_theme(&theme) + .with_prompt("Repository path on SFTP server") + .interact_text()?; + + _ = repo_options.insert("endpoint".to_string(), toml::Value::String(endpoint)); + _ = repo_options.insert("user".to_string(), toml::Value::String(user)); + _ = repo_options.insert("root".to_string(), toml::Value::String(root)); + + "opendal:sftp".to_string() + } + 3 => { + // rclone + let remote: String = Input::with_theme(&theme) + .with_prompt("rclone remote (e.g. myremote:backup/rustic)") + .interact_text()?; + format!("rclone:{remote}") + } + 4 => { + // REST + let url: String = Input::with_theme(&theme) + .with_prompt("REST server URL (e.g. http://localhost:8000)") + .default("http://localhost:8000".to_string()) + .interact_text()?; + format!("rest:{url}") + } + 5 => { + // OpenDAL advanced + let service: String = Input::with_theme(&theme) + .with_prompt("OpenDAL service (e.g. s3, gcs, azblob, sftp)") + .interact_text()?; + let options: String = Input::with_theme(&theme) + .with_prompt("OpenDAL options (key=value, comma-separated, optional)") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + for option in options.split(',') { + let option = option.trim(); + if option.is_empty() { + continue; + } + let Some((key, value)) = option.split_once('=') else { + bail!("invalid OpenDAL option '{option}', expected key=value"); + }; + _ = repo_options.insert( + key.trim().to_string(), + toml::Value::String(value.trim().to_string()), + ); + } + format!("opendal:{}", service.trim()) + } + _ => bail!("Invalid selection"), + }; + + // Password + println!(); + let password_method = Select::with_theme(&theme) + .with_prompt("How do you want to provide the repository password?") + .items([ + "Always prompt (no stored password)", + "Password file path", + "Password command", + "Type password now (stored in config)", + ]) + .default(0) + .interact()?; + + let (password, password_file, password_command) = match password_method { + 0 => (None, None, None), + 1 => { + let file: String = Input::with_theme(&theme) + .with_prompt("Password file path") + .interact_text()?; + (None, Some(file), None) + } + 2 => { + let cmd: String = Input::with_theme(&theme) + .with_prompt("Password command") + .interact_text()?; + (None, None, Some(cmd)) + } + 3 => { + warn!("The password will be stored in the generated profile."); + let pass: String = dialoguer::Password::with_theme(&theme) + .with_prompt("Repository password") + .allow_empty_password(true) + .with_confirmation("Confirm password", "Passwords do not match") + .interact()?; + // Password strength feedback + if pass.is_empty() { + warn!("Warning: Empty password. The repository data will still be encrypted,"); + warn!(" but anyone with access to the repository can decrypt it."); + } else { + let strength = password_strength::estimate_strength(&pass); + if strength < 0.7 { + warn!( + "Warning: Your password is rated as weak ({:.2}/1.0).", + strength + ); + warn!(" Consider using a stronger password for better security."); + } + } + (Some(pass), None, None) + } + _ => bail!("Invalid selection"), + }; + + // Step 2: Backup sources. + + println!(); + println!("Step 2: Backup Sources (what to back up)"); + println!(); + + let mut sources: Vec> = Vec::new(); + loop { + let source: String = Input::with_theme(&theme) + .with_prompt("Add a path to back up (or press Enter to finish)") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + + let source = source.trim().to_string(); + + if source.is_empty() { + if sources.is_empty() { + println!("You need at least one backup source."); + continue; + } + break; + } + + let expanded = expand_tilde(&source); + if !Path::new(&expanded).exists() { + println!("Warning: '{}' does not exist.", expanded); + let add_anyway = Confirm::with_theme(&theme) + .with_prompt("Add it anyway?") + .default(false) + .interact()?; + if !add_anyway { + continue; + } + } + sources.push(vec![expanded]); + println!("Added: {}", source); + } + + // Exclusion patterns + println!(); + let use_exclusions = Confirm::with_theme(&theme) + .with_prompt("Configure exclusion patterns?") + .default(true) + .interact()?; + + let mut globs: Vec = Vec::new(); + let mut exclude_if_present: Vec = Vec::new(); + let mut use_git_ignore = false; + + if use_exclusions { + // Preset exclusions + let presets = exclusion_presets(); + let preset_names: Vec<&str> = presets.iter().map(|p| p.name).collect(); + + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select exclusion presets (Space to toggle, Enter to confirm)") + .items(&preset_names) + .interact()?; + + for idx in selections { + for glob in &presets[idx].globs { + globs.push(glob.to_string()); + } + } + + // Git ignore + use_git_ignore = Confirm::with_theme(&theme) + .with_prompt("Respect .gitignore files?") + .default(true) + .interact()?; + + // Common exclude-if-present markers + let use_nobackup = Confirm::with_theme(&theme) + .with_prompt("Exclude directories containing '.nobackup' or 'CACHEDIR.TAG'?") + .default(true) + .interact()?; + + if use_nobackup { + exclude_if_present.push(".nobackup".to_string()); + exclude_if_present.push("CACHEDIR.TAG".to_string()); + } + + // Custom globs + let custom_globs: String = Input::with_theme(&theme) + .with_prompt("Custom exclusion globs (comma-separated, or Enter to skip)") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + + if !custom_globs.is_empty() { + for g in custom_globs.split(',') { + let g = g.trim(); + if !g.is_empty() { + globs.push(format!("!{g}")); + } + } + } + } + + // Step 3: Retention policy. + + println!(); + println!("Step 3: Retention Policy (how long to keep backups)"); + println!(); + + let retention_presets = vec![ + "Conservative (keep-daily=7, keep-weekly=4, keep-monthly=12, keep-yearly=5)", + "Moderate (keep-daily=3, keep-weekly=2, keep-monthly=6)", + "Minimal (keep-daily=1, keep-weekly=1, keep-monthly=3)", + "Custom", + "None (manual forget only)", + ]; + + let retention_idx = Select::with_theme(&theme) + .with_prompt("Select a retention policy") + .items(&retention_presets) + .default(0) + .interact()?; + + struct RetentionConfig { + keep_daily: u32, + keep_weekly: u32, + keep_monthly: u32, + keep_yearly: u32, + } + + let retention = match retention_idx { + 0 => Some(RetentionConfig { + keep_daily: 7, + keep_weekly: 4, + keep_monthly: 12, + keep_yearly: 5, + }), + 1 => Some(RetentionConfig { + keep_daily: 3, + keep_weekly: 2, + keep_monthly: 6, + keep_yearly: 0, + }), + 2 => Some(RetentionConfig { + keep_daily: 1, + keep_weekly: 1, + keep_monthly: 3, + keep_yearly: 0, + }), + 3 => { + let daily: u32 = Input::with_theme(&theme) + .with_prompt("Keep daily snapshots") + .default(7) + .interact_text()?; + let weekly: u32 = Input::with_theme(&theme) + .with_prompt("Keep weekly snapshots") + .default(4) + .interact_text()?; + let monthly: u32 = Input::with_theme(&theme) + .with_prompt("Keep monthly snapshots") + .default(12) + .interact_text()?; + let yearly: u32 = Input::with_theme(&theme) + .with_prompt("Keep yearly snapshots") + .default(5) + .interact_text()?; + Some(RetentionConfig { + keep_daily: daily, + keep_weekly: weekly, + keep_monthly: monthly, + keep_yearly: yearly, + }) + } + _ => None, + }; + + // Step 4: Performance options. + + println!(); + println!("Step 4: Performance Options"); + println!(); + + let compression_idx = Select::with_theme(&theme) + .with_prompt("Select compression level") + .items(["Default", "None", "Max"]) + .default(0) + .interact()?; + + let compression = match compression_idx { + 1 => Some("0"), // Level 0 represents no compression in rustic config + 2 => Some("22"), // Max compression level for zstd is 22 + _ => None, // null implies auto/default in rustic + }; + + let pack_size_preset = Select::with_theme(&theme) + .with_prompt("Select default pack size") + .items(["Default", "Large", "Extra Large"]) + .default(0) + .interact()?; + + let pack_size = match pack_size_preset { + 1 => Some(128_u32), + 2 => Some(512_u32), + _ => None, + }; + + // Step 5: Generate config and summary. + + println!(); + println!("Step 5: Summary & Configuration"); + println!(); + + // Build the TOML config string using the toml crate for safety and robustness + let mut config_table = toml::Table::new(); + + // Repository section + let mut repo_table = toml::Table::new(); + _ = repo_table.insert( + "repository".to_string(), + toml::Value::String(repository.clone()), + ); + if let Some(pass) = password { + _ = repo_table.insert("password".to_string(), toml::Value::String(pass)); + } + if let Some(file) = password_file { + _ = repo_table.insert("password-file".to_string(), toml::Value::String(file)); + } + if let Some(cmd) = password_command { + _ = repo_table.insert("password-command".to_string(), toml::Value::String(cmd)); + } + if !repo_options.is_empty() { + _ = repo_table.insert("options".to_string(), toml::Value::Table(repo_options)); + } + _ = config_table.insert("repository".to_string(), toml::Value::Table(repo_table)); + + // Backup section + let mut backup_table = toml::Table::new(); + if use_git_ignore { + _ = backup_table.insert("git-ignore".to_string(), toml::Value::Boolean(true)); + } + if !exclude_if_present.is_empty() { + _ = backup_table.insert( + "exclude-if-present".to_string(), + toml::Value::Array( + exclude_if_present + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + } + if !globs.is_empty() { + _ = backup_table.insert( + "globs".to_string(), + toml::Value::Array(globs.iter().cloned().map(toml::Value::String).collect()), + ); + } + + // Backup sources (via snapshots array of tables) + let mut snapshots = toml::value::Array::new(); + for source_paths in &sources { + let mut snap_table = toml::Table::new(); + _ = snap_table.insert( + "sources".to_string(), + toml::Value::Array( + source_paths + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + snapshots.push(toml::Value::Table(snap_table)); + } + if !snapshots.is_empty() { + _ = backup_table.insert("snapshots".to_string(), toml::Value::Array(snapshots)); + } + _ = config_table.insert("backup".to_string(), toml::Value::Table(backup_table)); + + // Forget/retention section + if let Some(ref ret) = retention { + let mut forget_table = toml::Table::new(); + if ret.keep_daily > 0 { + _ = forget_table.insert( + "keep-daily".to_string(), + toml::Value::Integer(i64::from(ret.keep_daily)), + ); + } + if ret.keep_weekly > 0 { + _ = forget_table.insert( + "keep-weekly".to_string(), + toml::Value::Integer(i64::from(ret.keep_weekly)), + ); + } + if ret.keep_monthly > 0 { + _ = forget_table.insert( + "keep-monthly".to_string(), + toml::Value::Integer(i64::from(ret.keep_monthly)), + ); + } + if ret.keep_yearly > 0 { + _ = forget_table.insert( + "keep-yearly".to_string(), + toml::Value::Integer(i64::from(ret.keep_yearly)), + ); + } + _ = config_table.insert("forget".to_string(), toml::Value::Table(forget_table)); + } + + let mut config = format!( + "# rustic config profile: {}\n# Generated by 'rustic setup' on {}\n\n", + profile, + jiff::Zoned::now().strftime("%Y-%m-%d %H:%M:%S") + ); + config.push_str(&toml::to_string_pretty(&config_table).map_err(|e| anyhow!(e))?); + + // Print summary + println!("Configuration summary:"); + println!(" Profile: {}", profile); + println!(" Repository: {}", truncate_str(&repository, 64)); + println!( + " Sources: {}", + if sources.len() == 1 { + truncate_str(&sources[0].join(", "), 64) + } else { + format!("{} paths configured", sources.len()) + } + ); + if !globs.is_empty() { + println!(" Exclusions: {} patterns", globs.len()); + } + if let Some(ref ret) = retention { + println!( + " Retention: daily={}, weekly={}, monthly={}, yearly={}", + ret.keep_daily, ret.keep_weekly, ret.keep_monthly, ret.keep_yearly + ); + } + println!(); + + // Show generated config + println!("Generated configuration:"); + println!("------------------------"); + println!("{config}"); + println!("------------------------"); + println!(); + + // Determine config path + let config_dir = ProjectDirs::from("", "", "rustic") + .map(|dirs| dirs.config_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + let config_file = config_dir.join(format!("{}.toml", profile)); + + // Check for existing config + if config_file.exists() && !self.force { + let overwrite = Confirm::with_theme(&theme) + .with_prompt(format!( + "Config file '{}' already exists. Overwrite?", + config_file.display() + )) + .default(false) + .interact()?; + if !overwrite { + println!("Aborted. Use --force to overwrite."); + return Ok(()); + } + } + + // Save the config + let save = Confirm::with_theme(&theme) + .with_prompt(format!( + "Save configuration to '{}'?", + config_file.display() + )) + .default(true) + .interact()?; + + if save { + fs::create_dir_all(&config_dir)?; + + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&config_file)?; + std::io::Write::write_all(&mut file, config.as_bytes())?; + fs::set_permissions(&config_file, fs::Permissions::from_mode(0o600))?; + } + #[cfg(not(unix))] + { + fs::write(&config_file, &config)?; + } + + println!(); + println!("Configuration saved to: {}", config_file.display()); + } else { + println!("Configuration not saved."); + return Ok(()); + } + + // Offer to initialize the repository + println!(); + let init_repo = Confirm::with_theme(&theme) + .with_prompt("Initialize the repository now?") + .default(true) + .interact()?; + + if init_repo { + let profile_arg = if profile == "rustic" { + String::new() + } else { + format!(" -P {}", profile) + }; + + let mut init_args = String::new(); + if let Some(comp) = compression { + init_args.push_str(&format!(" --set-compression {}", comp)); + } + if let Some(size) = pack_size { + init_args.push_str(&format!(" --set-datapack-size {}MiB", size)); + init_args.push_str(&format!( + " --set-treepack-size {}MiB", + if size > 32 { 16 } else { 4 } + )); + } + + println!(); + println!("Run the following command to initialize:"); + println!(" rustic{profile_arg} init{init_args}"); + println!(); + println!("Then start your first backup with:"); + println!(" rustic{profile_arg} backup"); + } + + // Usage hints + println!(); + println!("Next steps:"); + println!(); + if profile != "rustic" { + println!( + " Use -P {} with all rustic commands to use this profile:", + profile + ); + println!(" rustic -P {} init", profile); + println!(" rustic -P {} backup", profile); + println!(" rustic -P {} snapshots", profile); + println!(" rustic -P {} restore latest /restore/path", profile); + } else { + println!(" This is the default profile. Commands:"); + println!(" rustic init"); + println!(" rustic backup"); + println!(" rustic snapshots"); + println!(" rustic restore latest /restore/path"); + } + println!(); + println!(" For more information: https://rustic.cli.rs/docs/getting_started.html"); + println!(); + + Ok(()) + } +} + +/// Truncate a string to a max length (by characters), adding "..." if truncated +fn truncate_str(s: &str, max_len: usize) -> String { + let char_count = s.chars().count(); + if char_count <= max_len { + s.to_string() + } else if max_len > 3 { + format!("{}...", s.chars().take(max_len - 3).collect::()) + } else { + s.chars().take(max_len).collect::() + } +} + +fn validate_profile_name(profile: &str) -> Result<()> { + if profile.is_empty() { + bail!("name cannot be empty"); + } + if matches!(profile, "." | "..") { + bail!("name cannot be '.' or '..'"); + } + if profile.ends_with(".toml") { + bail!("enter the profile name without the .toml extension"); + } + if profile + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + Ok(()) + } else { + bail!("use only ASCII letters, numbers, '.', '-' and '_'"); + } +} + +/// Simple tilde expansion: replace leading `~` with the user's home directory +fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix('~') + && let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) + { + let home = home.to_string_lossy(); + if rest.is_empty() { + return home.to_string(); + } + if rest.starts_with('/') || rest.starts_with('\\') { + return format!("{home}{rest}"); + } + } + path.to_string() +} + +#[cfg(test)] +mod tests { + use super::{truncate_str, validate_profile_name}; + + #[test] + fn profile_name_validation_accepts_safe_names() { + for profile in ["rustic", "daily-backup", "home_1", "prod.eu"] { + validate_profile_name(profile).unwrap(); + } + } + + #[test] + fn profile_name_validation_rejects_paths_and_extensions() { + for profile in [ + "", + ".", + "..", + "../rustic", + "nested/profile", + "nested\\profile", + "a.toml", + ] { + assert!(validate_profile_name(profile).is_err()); + } + } + + #[test] + fn truncate_str_is_utf8_safe() { + let sample = "\u{e5}\u{df}\u{2202}\u{192}"; + assert_eq!(truncate_str("abcdef", 4), "a..."); + assert_eq!(truncate_str(sample, 3), "\u{e5}\u{df}\u{2202}"); + assert_eq!(truncate_str(sample, 4), sample); + } +} From ad1290dbdff61857bf038c96fe541fc86acec351 Mon Sep 17 00:00:00 2001 From: Gyorgy Bolyki Date: Wed, 6 May 2026 17:35:54 +0100 Subject: [PATCH 3/5] feat: add setup print mode --- src/commands/setup.rs | 612 +++++++++++++++++++++++++++++------------- 1 file changed, 422 insertions(+), 190 deletions(-) diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 7b0cc95f0..0ee837b69 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,7 +1,7 @@ //! `setup` subcommand - interactive wizard for configuring rustic backups -use std::fs; use std::path::{Path, PathBuf}; +use std::{fs, io::Write}; use abscissa_core::{Command, Runnable, Shutdown}; use anyhow::{Result, anyhow, bail}; @@ -22,6 +22,10 @@ pub(crate) struct SetupCmd { /// Overwrite existing profile if it exists #[clap(long)] force: bool, + + /// Print generated TOML instead of writing a config profile + #[clap(long)] + print: bool, } impl Runnable for SetupCmd { @@ -40,6 +44,29 @@ struct ExclusionPreset { globs: Vec<&'static str>, } +#[derive(Clone, Debug)] +struct RetentionConfig { + keep_daily: u32, + keep_weekly: u32, + keep_monthly: u32, + keep_yearly: u32, +} + +#[derive(Clone, Debug)] +struct GeneratedConfig { + profile: String, + repository: String, + repository_options: toml::Table, + password: Option, + password_file: Option, + password_command: Option, + sources: Vec>, + globs: Vec, + exclude_if_present: Vec, + use_git_ignore: bool, + retention: Option, +} + fn exclusion_presets() -> Vec { vec![ ExclusionPreset { @@ -82,12 +109,28 @@ fn exclusion_presets() -> Vec { impl SetupCmd { fn inner_run(&self) -> Result<()> { let theme = ColorfulTheme::default(); + macro_rules! wizard_println { + () => { + if self.print { + eprintln!(); + } else { + println!(); + } + }; + ($($arg:tt)*) => { + if self.print { + eprintln!($($arg)*); + } else { + println!($($arg)*); + } + }; + } - println!(); - println!("rustic setup"); - println!("============"); - println!("This wizard configures a backup source, target, and retention policy."); - println!(); + wizard_println!(); + wizard_println!("rustic setup"); + wizard_println!("============"); + wizard_println!("This wizard configures a backup source, target, and retention policy."); + wizard_println!(); // Ask for profile name let profile = loop { @@ -103,17 +146,17 @@ impl SetupCmd { }; if let Err(err) = validate_profile_name(&p) { - println!("Invalid profile name: {err}"); + wizard_println!("Invalid profile name: {err}"); } else { break p; } }; - println!(); + wizard_println!(); // Step 1: Repository target. - println!("Step 1: Repository (where to store backups)"); - println!(); + wizard_println!("Step 1: Repository (where to store backups)"); + wizard_println!(); let repo_types = vec![ "Local path", @@ -142,7 +185,12 @@ impl SetupCmd { let path = expand_tilde(&path); let repo_path = Path::new(&path); - if !repo_path.exists() { + if !repo_path.exists() && self.print { + wizard_println!( + "Directory '{}' doesn't exist; --print will not create it.", + path + ); + } else if !repo_path.exists() { let create = Confirm::with_theme(&theme) .with_prompt(format!("Directory '{}' doesn't exist. Create it?", path)) .default(true) @@ -254,7 +302,7 @@ impl SetupCmd { }; // Password - println!(); + wizard_println!(); let password_method = Select::with_theme(&theme) .with_prompt("How do you want to provide the repository password?") .items([ @@ -308,9 +356,9 @@ impl SetupCmd { // Step 2: Backup sources. - println!(); - println!("Step 2: Backup Sources (what to back up)"); - println!(); + wizard_println!(); + wizard_println!("Step 2: Backup Sources (what to back up)"); + wizard_println!(); let mut sources: Vec> = Vec::new(); loop { @@ -324,7 +372,7 @@ impl SetupCmd { if source.is_empty() { if sources.is_empty() { - println!("You need at least one backup source."); + wizard_println!("You need at least one backup source."); continue; } break; @@ -332,7 +380,7 @@ impl SetupCmd { let expanded = expand_tilde(&source); if !Path::new(&expanded).exists() { - println!("Warning: '{}' does not exist.", expanded); + wizard_println!("Warning: '{}' does not exist.", expanded); let add_anyway = Confirm::with_theme(&theme) .with_prompt("Add it anyway?") .default(false) @@ -342,11 +390,11 @@ impl SetupCmd { } } sources.push(vec![expanded]); - println!("Added: {}", source); + wizard_println!("Added: {}", source); } // Exclusion patterns - println!(); + wizard_println!(); let use_exclusions = Confirm::with_theme(&theme) .with_prompt("Configure exclusion patterns?") .default(true) @@ -408,9 +456,9 @@ impl SetupCmd { // Step 3: Retention policy. - println!(); - println!("Step 3: Retention Policy (how long to keep backups)"); - println!(); + wizard_println!(); + wizard_println!("Step 3: Retention Policy (how long to keep backups)"); + wizard_println!(); let retention_presets = vec![ "Conservative (keep-daily=7, keep-weekly=4, keep-monthly=12, keep-yearly=5)", @@ -426,13 +474,6 @@ impl SetupCmd { .default(0) .interact()?; - struct RetentionConfig { - keep_daily: u32, - keep_weekly: u32, - keep_monthly: u32, - keep_yearly: u32, - } - let retention = match retention_idx { 0 => Some(RetentionConfig { keep_daily: 7, @@ -481,9 +522,9 @@ impl SetupCmd { // Step 4: Performance options. - println!(); - println!("Step 4: Performance Options"); - println!(); + wizard_println!(); + wizard_println!("Step 4: Performance Options"); + wizard_println!(); let compression_idx = Select::with_theme(&theme) .with_prompt("Select compression level") @@ -511,151 +552,70 @@ impl SetupCmd { // Step 5: Generate config and summary. - println!(); - println!("Step 5: Summary & Configuration"); - println!(); - - // Build the TOML config string using the toml crate for safety and robustness - let mut config_table = toml::Table::new(); + wizard_println!(); + wizard_println!("Step 5: Summary & Configuration"); + wizard_println!(); - // Repository section - let mut repo_table = toml::Table::new(); - _ = repo_table.insert( - "repository".to_string(), - toml::Value::String(repository.clone()), - ); - if let Some(pass) = password { - _ = repo_table.insert("password".to_string(), toml::Value::String(pass)); - } - if let Some(file) = password_file { - _ = repo_table.insert("password-file".to_string(), toml::Value::String(file)); - } - if let Some(cmd) = password_command { - _ = repo_table.insert("password-command".to_string(), toml::Value::String(cmd)); - } - if !repo_options.is_empty() { - _ = repo_table.insert("options".to_string(), toml::Value::Table(repo_options)); - } - _ = config_table.insert("repository".to_string(), toml::Value::Table(repo_table)); - - // Backup section - let mut backup_table = toml::Table::new(); - if use_git_ignore { - _ = backup_table.insert("git-ignore".to_string(), toml::Value::Boolean(true)); - } - if !exclude_if_present.is_empty() { - _ = backup_table.insert( - "exclude-if-present".to_string(), - toml::Value::Array( - exclude_if_present - .iter() - .cloned() - .map(toml::Value::String) - .collect(), - ), - ); - } - if !globs.is_empty() { - _ = backup_table.insert( - "globs".to_string(), - toml::Value::Array(globs.iter().cloned().map(toml::Value::String).collect()), - ); - } + let generated = GeneratedConfig { + profile, + repository, + repository_options: repo_options, + password, + password_file, + password_command, + sources, + globs, + exclude_if_present, + use_git_ignore, + retention, + }; + let config = render_config(&generated)?; - // Backup sources (via snapshots array of tables) - let mut snapshots = toml::value::Array::new(); - for source_paths in &sources { - let mut snap_table = toml::Table::new(); - _ = snap_table.insert( - "sources".to_string(), - toml::Value::Array( - source_paths - .iter() - .cloned() - .map(toml::Value::String) - .collect(), - ), - ); - snapshots.push(toml::Value::Table(snap_table)); - } - if !snapshots.is_empty() { - _ = backup_table.insert("snapshots".to_string(), toml::Value::Array(snapshots)); - } - _ = config_table.insert("backup".to_string(), toml::Value::Table(backup_table)); - - // Forget/retention section - if let Some(ref ret) = retention { - let mut forget_table = toml::Table::new(); - if ret.keep_daily > 0 { - _ = forget_table.insert( - "keep-daily".to_string(), - toml::Value::Integer(i64::from(ret.keep_daily)), - ); - } - if ret.keep_weekly > 0 { - _ = forget_table.insert( - "keep-weekly".to_string(), - toml::Value::Integer(i64::from(ret.keep_weekly)), - ); - } - if ret.keep_monthly > 0 { - _ = forget_table.insert( - "keep-monthly".to_string(), - toml::Value::Integer(i64::from(ret.keep_monthly)), - ); - } - if ret.keep_yearly > 0 { - _ = forget_table.insert( - "keep-yearly".to_string(), - toml::Value::Integer(i64::from(ret.keep_yearly)), - ); - } - _ = config_table.insert("forget".to_string(), toml::Value::Table(forget_table)); + if !self.writes_config() { + print!("{config}"); + std::io::stdout().flush()?; + return Ok(()); } - let mut config = format!( - "# rustic config profile: {}\n# Generated by 'rustic setup' on {}\n\n", - profile, - jiff::Zoned::now().strftime("%Y-%m-%d %H:%M:%S") - ); - config.push_str(&toml::to_string_pretty(&config_table).map_err(|e| anyhow!(e))?); - // Print summary - println!("Configuration summary:"); - println!(" Profile: {}", profile); - println!(" Repository: {}", truncate_str(&repository, 64)); - println!( + wizard_println!("Configuration summary:"); + wizard_println!(" Profile: {}", generated.profile); + wizard_println!(" Repository: {}", truncate_str(&generated.repository, 64)); + wizard_println!( " Sources: {}", - if sources.len() == 1 { - truncate_str(&sources[0].join(", "), 64) + if generated.sources.len() == 1 { + truncate_str(&generated.sources[0].join(", "), 64) } else { - format!("{} paths configured", sources.len()) + format!("{} paths configured", generated.sources.len()) } ); - if !globs.is_empty() { - println!(" Exclusions: {} patterns", globs.len()); + if !generated.globs.is_empty() { + wizard_println!(" Exclusions: {} patterns", generated.globs.len()); } - if let Some(ref ret) = retention { - println!( + if let Some(ref ret) = generated.retention { + wizard_println!( " Retention: daily={}, weekly={}, monthly={}, yearly={}", - ret.keep_daily, ret.keep_weekly, ret.keep_monthly, ret.keep_yearly + ret.keep_daily, + ret.keep_weekly, + ret.keep_monthly, + ret.keep_yearly ); } - println!(); + wizard_println!(); // Show generated config - println!("Generated configuration:"); - println!("------------------------"); - println!("{config}"); - println!("------------------------"); - println!(); + wizard_println!("Generated configuration:"); + wizard_println!("------------------------"); + wizard_println!("{config}"); + wizard_println!("------------------------"); + wizard_println!(); // Determine config path let config_dir = ProjectDirs::from("", "", "rustic") .map(|dirs| dirs.config_dir().to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); - let config_file = config_dir.join(format!("{}.toml", profile)); + let config_file = config_dir.join(format!("{}.toml", generated.profile)); // Check for existing config if config_file.exists() && !self.force { @@ -667,7 +627,7 @@ impl SetupCmd { .default(false) .interact()?; if !overwrite { - println!("Aborted. Use --force to overwrite."); + wizard_println!("Aborted. Use --force to overwrite."); return Ok(()); } } @@ -693,7 +653,7 @@ impl SetupCmd { .truncate(true) .mode(0o600) .open(&config_file)?; - std::io::Write::write_all(&mut file, config.as_bytes())?; + file.write_all(config.as_bytes())?; fs::set_permissions(&config_file, fs::Permissions::from_mode(0o600))?; } #[cfg(not(unix))] @@ -701,25 +661,25 @@ impl SetupCmd { fs::write(&config_file, &config)?; } - println!(); - println!("Configuration saved to: {}", config_file.display()); + wizard_println!(); + wizard_println!("Configuration saved to: {}", config_file.display()); } else { - println!("Configuration not saved."); + wizard_println!("Configuration not saved."); return Ok(()); } // Offer to initialize the repository - println!(); + wizard_println!(); let init_repo = Confirm::with_theme(&theme) .with_prompt("Initialize the repository now?") .default(true) .interact()?; if init_repo { - let profile_arg = if profile == "rustic" { + let profile_arg = if generated.profile == "rustic" { String::new() } else { - format!(" -P {}", profile) + format!(" -P {}", generated.profile) }; let mut init_args = String::new(); @@ -734,40 +694,173 @@ impl SetupCmd { )); } - println!(); - println!("Run the following command to initialize:"); - println!(" rustic{profile_arg} init{init_args}"); - println!(); - println!("Then start your first backup with:"); - println!(" rustic{profile_arg} backup"); + wizard_println!(); + wizard_println!("Run the following command to initialize:"); + wizard_println!(" rustic{profile_arg} init{init_args}"); + wizard_println!(); + wizard_println!("Then start your first backup with:"); + wizard_println!(" rustic{profile_arg} backup"); } // Usage hints - println!(); - println!("Next steps:"); - println!(); - if profile != "rustic" { - println!( + wizard_println!(); + wizard_println!("Next steps:"); + wizard_println!(); + if generated.profile != "rustic" { + wizard_println!( " Use -P {} with all rustic commands to use this profile:", - profile + generated.profile + ); + wizard_println!(" rustic -P {} init", generated.profile); + wizard_println!(" rustic -P {} backup", generated.profile); + wizard_println!(" rustic -P {} snapshots", generated.profile); + wizard_println!( + " rustic -P {} restore latest /restore/path", + generated.profile ); - println!(" rustic -P {} init", profile); - println!(" rustic -P {} backup", profile); - println!(" rustic -P {} snapshots", profile); - println!(" rustic -P {} restore latest /restore/path", profile); } else { - println!(" This is the default profile. Commands:"); - println!(" rustic init"); - println!(" rustic backup"); - println!(" rustic snapshots"); - println!(" rustic restore latest /restore/path"); + wizard_println!(" This is the default profile. Commands:"); + wizard_println!(" rustic init"); + wizard_println!(" rustic backup"); + wizard_println!(" rustic snapshots"); + wizard_println!(" rustic restore latest /restore/path"); } - println!(); - println!(" For more information: https://rustic.cli.rs/docs/getting_started.html"); - println!(); + wizard_println!(); + wizard_println!(" For more information: https://rustic.cli.rs/docs/getting_started.html"); + wizard_println!(); Ok(()) } + + fn writes_config(&self) -> bool { + !self.print + } +} + +fn render_config(generated: &GeneratedConfig) -> Result { + render_config_with_timestamp( + generated, + &jiff::Zoned::now().strftime("%Y-%m-%d %H:%M:%S").to_string(), + ) +} + +fn render_config_with_timestamp(generated: &GeneratedConfig, generated_at: &str) -> Result { + let mut config_table = toml::Table::new(); + + let mut repo_table = toml::Table::new(); + _ = repo_table.insert( + "repository".to_string(), + toml::Value::String(generated.repository.clone()), + ); + if let Some(pass) = &generated.password { + _ = repo_table.insert("password".to_string(), toml::Value::String(pass.clone())); + } + if let Some(file) = &generated.password_file { + _ = repo_table.insert( + "password-file".to_string(), + toml::Value::String(file.clone()), + ); + } + if let Some(cmd) = &generated.password_command { + _ = repo_table.insert( + "password-command".to_string(), + toml::Value::String(cmd.clone()), + ); + } + if !generated.repository_options.is_empty() { + _ = repo_table.insert( + "options".to_string(), + toml::Value::Table(generated.repository_options.clone()), + ); + } + _ = config_table.insert("repository".to_string(), toml::Value::Table(repo_table)); + + let mut backup_table = toml::Table::new(); + if generated.use_git_ignore { + _ = backup_table.insert("git-ignore".to_string(), toml::Value::Boolean(true)); + } + if !generated.exclude_if_present.is_empty() { + _ = backup_table.insert( + "exclude-if-present".to_string(), + toml::Value::Array( + generated + .exclude_if_present + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + } + if !generated.globs.is_empty() { + _ = backup_table.insert( + "globs".to_string(), + toml::Value::Array( + generated + .globs + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + } + + let mut snapshots = toml::value::Array::new(); + for source_paths in &generated.sources { + let mut snap_table = toml::Table::new(); + _ = snap_table.insert( + "sources".to_string(), + toml::Value::Array( + source_paths + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + ); + snapshots.push(toml::Value::Table(snap_table)); + } + if !snapshots.is_empty() { + _ = backup_table.insert("snapshots".to_string(), toml::Value::Array(snapshots)); + } + _ = config_table.insert("backup".to_string(), toml::Value::Table(backup_table)); + + if let Some(ret) = &generated.retention { + let mut forget_table = toml::Table::new(); + if ret.keep_daily > 0 { + _ = forget_table.insert( + "keep-daily".to_string(), + toml::Value::Integer(i64::from(ret.keep_daily)), + ); + } + if ret.keep_weekly > 0 { + _ = forget_table.insert( + "keep-weekly".to_string(), + toml::Value::Integer(i64::from(ret.keep_weekly)), + ); + } + if ret.keep_monthly > 0 { + _ = forget_table.insert( + "keep-monthly".to_string(), + toml::Value::Integer(i64::from(ret.keep_monthly)), + ); + } + if ret.keep_yearly > 0 { + _ = forget_table.insert( + "keep-yearly".to_string(), + toml::Value::Integer(i64::from(ret.keep_yearly)), + ); + } + _ = config_table.insert("forget".to_string(), toml::Value::Table(forget_table)); + } + + let mut config = format!( + "# rustic config profile: {}\n# Generated by 'rustic setup' on {generated_at}\n\n", + generated.profile + ); + config.push_str(&toml::to_string_pretty(&config_table).map_err(|e| anyhow!(e))?); + Ok(config) } /// Truncate a string to a max length (by characters), adding "..." if truncated @@ -820,11 +913,47 @@ fn expand_tilde(path: &str) -> String { #[cfg(test)] mod tests { - use super::{truncate_str, validate_profile_name}; + use super::{ + GeneratedConfig, RetentionConfig, SetupCmd, render_config_with_timestamp, truncate_str, + validate_profile_name, + }; + use crate::RusticConfig; + + fn base_generated_config() -> GeneratedConfig { + GeneratedConfig { + profile: "rustic".to_string(), + repository: "/backup/rustic".to_string(), + repository_options: toml::Table::new(), + password: None, + password_file: None, + password_command: None, + sources: vec![vec!["/home".to_string()]], + globs: vec!["!**/target/**".to_string()], + exclude_if_present: vec![".nobackup".to_string(), "CACHEDIR.TAG".to_string()], + use_git_ignore: true, + retention: Some(RetentionConfig { + keep_daily: 7, + keep_weekly: 4, + keep_monthly: 12, + keep_yearly: 5, + }), + } + } + + fn render_parseable_config(generated: &GeneratedConfig) -> RusticConfig { + let rendered = render_config_with_timestamp(generated, "2026-05-06 12:00:00").unwrap(); + toml::from_str(&rendered).unwrap() + } #[test] fn profile_name_validation_accepts_safe_names() { - for profile in ["rustic", "daily-backup", "home_1", "prod.eu"] { + for profile in [ + "rustic", + "daily-backup", + "home_1", + "prod.eu", + "daily.2026-05-06", + ] { validate_profile_name(profile).unwrap(); } } @@ -839,6 +968,8 @@ mod tests { "nested/profile", "nested\\profile", "a.toml", + "has space", + "ümlaut", ] { assert!(validate_profile_name(profile).is_err()); } @@ -851,4 +982,105 @@ mod tests { assert_eq!(truncate_str(sample, 3), "\u{e5}\u{df}\u{2202}"); assert_eq!(truncate_str(sample, 4), sample); } + + #[test] + fn print_mode_renders_parseable_toml_without_writing() { + let cmd = SetupCmd { + profile: "rustic".to_string(), + force: false, + print: true, + }; + assert!(!cmd.writes_config()); + + let generated = base_generated_config(); + let rendered = render_config_with_timestamp(&generated, "2026-05-06 12:00:00").unwrap(); + let parsed: RusticConfig = toml::from_str(&rendered).unwrap(); + + assert_eq!( + parsed.repository.be.repository.as_deref(), + Some("/backup/rustic") + ); + assert!(!rendered.contains("password =")); + } + + #[test] + fn rendered_s3_options_parse() { + let mut generated = base_generated_config(); + generated.repository = "opendal:s3".to_string(); + _ = generated.repository_options.insert( + "bucket".to_string(), + toml::Value::String("example-backups".to_string()), + ); + _ = generated.repository_options.insert( + "root".to_string(), + toml::Value::String("/rustic".to_string()), + ); + _ = generated.repository_options.insert( + "region".to_string(), + toml::Value::String("eu-central-1".to_string()), + ); + + let parsed = render_parseable_config(&generated); + + assert_eq!( + parsed.repository.be.repository.as_deref(), + Some("opendal:s3") + ); + assert_eq!( + parsed + .repository + .be + .options + .get("bucket") + .map(String::as_str), + Some("example-backups") + ); + assert_eq!( + parsed + .repository + .be + .options + .get("region") + .map(String::as_str), + Some("eu-central-1") + ); + } + + #[test] + fn rendered_sftp_options_parse() { + let mut generated = base_generated_config(); + generated.repository = "opendal:sftp".to_string(); + _ = generated.repository_options.insert( + "endpoint".to_string(), + toml::Value::String("backup.example.com:22".to_string()), + ); + _ = generated.repository_options.insert( + "user".to_string(), + toml::Value::String("backup".to_string()), + ); + _ = generated.repository_options.insert( + "root".to_string(), + toml::Value::String("/srv/rustic".to_string()), + ); + + let parsed = render_parseable_config(&generated); + + assert_eq!( + parsed.repository.be.repository.as_deref(), + Some("opendal:sftp") + ); + assert_eq!( + parsed + .repository + .be + .options + .get("endpoint") + .map(String::as_str), + Some("backup.example.com:22") + ); + assert_eq!( + parsed.repository.be.options.get("user").map(String::as_str), + Some("backup") + ); + } } From a7b828176b8f5af62950e4a40c2c2acf0a2d2087 Mon Sep 17 00:00:00 2001 From: Gyorgy Bolyki Date: Wed, 6 May 2026 17:39:30 +0100 Subject: [PATCH 4/5] feat: add check-config command --- src/commands.rs | 14 ++++--- src/commands/backup.rs | 2 +- src/commands/check_config.rs | 31 +++++++++++++++ src/config.rs | 4 ++ tests/check_config.rs | 73 ++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 src/commands/check_config.rs create mode 100644 tests/check_config.rs diff --git a/src/commands.rs b/src/commands.rs index f0cedc818..a19fbc706 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,6 +3,7 @@ pub(crate) mod backup; pub(crate) mod cat; pub(crate) mod check; +pub(crate) mod check_config; pub(crate) mod completions; pub(crate) mod config; pub(crate) mod copy; @@ -45,11 +46,11 @@ use crate::commands::webdav::WebDavCmd; use crate::{ Application, RUSTIC_APP, commands::{ - backup::BackupCmd, cat::CatCmd, check::CheckCmd, completions::CompletionsCmd, - config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, docs::DocsCmd, dump::DumpCmd, - forget::ForgetCmd, init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd, - prune::PruneCmd, repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd, - rewrite::RewriteCmd, self_update::SelfUpdateCmd, setup::SetupCmd, + backup::BackupCmd, cat::CatCmd, check::CheckCmd, check_config::CheckConfigCmd, + completions::CompletionsCmd, config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, + docs::DocsCmd, dump::DumpCmd, forget::ForgetCmd, init::InitCmd, key::KeyCmd, list::ListCmd, + ls::LsCmd, merge::MergeCmd, prune::PruneCmd, repair::RepairCmd, repoinfo::RepoInfoCmd, + restore::RestoreCmd, rewrite::RewriteCmd, self_update::SelfUpdateCmd, setup::SetupCmd, show_config::ShowConfigCmd, snapshots::SnapshotCmd, tag::TagCmd, }, config::RusticConfig, @@ -89,6 +90,9 @@ enum RusticCmd { /// Check the repository Check(Box), + /// Validate merged configuration without opening the repository + CheckConfig(Box), + /// Copy snapshots to other repositories Copy(Box), diff --git a/src/commands/backup.rs b/src/commands/backup.rs index 95f845ae7..7ba36fe07 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -156,7 +156,7 @@ pub struct BackupCmd { } impl BackupCmd { - fn validate(&self) -> Result<(), &str> { + pub(crate) fn validate(&self) -> Result<(), &'static str> { // manually check for a "source" field, check is not done by serde, see above. if !self.sources.is_empty() { return Err("key \"sources\" is not valid in the [backup] section!"); diff --git a/src/commands/check_config.rs b/src/commands/check_config.rs new file mode 100644 index 000000000..d5e13ba91 --- /dev/null +++ b/src/commands/check_config.rs @@ -0,0 +1,31 @@ +//! `check-config` subcommand + +use crate::{Application, RUSTIC_APP, status_err}; + +use abscissa_core::{Command, Runnable, Shutdown}; +use anyhow::{Result, bail}; + +/// `check-config` subcommand +#[derive(clap::Parser, Command, Debug)] +pub(crate) struct CheckConfigCmd {} + +impl Runnable for CheckConfigCmd { + fn run(&self) { + if let Err(err) = self.inner_run() { + status_err!("{}", err); + RUSTIC_APP.shutdown(Shutdown::Crash); + }; + } +} + +impl CheckConfigCmd { + fn inner_run(&self) -> Result<()> { + let config = RUSTIC_APP.config(); + if let Err(err) = config.backup.validate() { + bail!("{err}"); + } + + println!("config ok"); + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs index 5fdf34bde..a28c17e75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -138,6 +138,10 @@ impl RusticConfig { config_content }; let mut config = Self::load_toml(config_content)?; + config + .backup + .validate() + .map_err(|err| FrameworkErrorKind::ConfigError.context(anyhow!(err)))?; // sanity check if config.global.profile_substitute_env && config.global.use_profiles.is_empty() { merge_logs.push((Level::Warn, "Option `profile-substitute-env` is given without any profiles to load! Note that this option does NOT apply to the file where it is specified!".to_string())); diff --git a/tests/check_config.rs b/tests/check_config.rs new file mode 100644 index 000000000..e94bfad58 --- /dev/null +++ b/tests/check_config.rs @@ -0,0 +1,73 @@ +//! Config validation tests for `check-config`. + +use std::{env, fs, path::Path}; + +use assert_cmd::Command; +use predicates::prelude::predicate; +use rustic_testing::TestResult; +use tempfile::tempdir; + +fn rustic_cmd() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rustic")); + for (key, _) in env::vars_os() { + let key_string = key.to_string_lossy(); + let remove = key_string.starts_with("RUSTIC_") + || key_string.starts_with("OPENDAL") + || key_string.starts_with("OTEL_"); + if remove { + cmd.env_remove(&key); + } + } + cmd +} + +fn manifest_path(path: &str) -> String { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(path) + .display() + .to_string() +} + +#[test] +fn check_config_passes_valid_examples() -> TestResult<()> { + for profile in [ + "config/local.toml", + "config/services/s3_aws.toml", + "config/services/sftp.toml", + "config/services/rclone_ovh-hot-cold.toml", + ] { + rustic_cmd() + .args(["-P", &manifest_path(profile), "check-config"]) + .assert() + .success() + .stdout(predicate::str::contains("config ok")); + } + + Ok(()) +} + +#[test] +fn check_config_fails_invalid_backup_shape() -> TestResult<()> { + let temp_dir = tempdir()?; + fs::write( + temp_dir.path().join("bad.toml"), + r#" +[repository] +repository = "/tmp/rustic-repo" + +[backup] +sources = ["/home"] +"#, + )?; + + rustic_cmd() + .current_dir(temp_dir.path()) + .args(["-P", "bad", "check-config"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "key \"sources\" is not valid in the [backup] section", + )); + + Ok(()) +} From 793bb082f9b1e288eccb35e0699a8368cb27f976 Mon Sep 17 00:00:00 2001 From: Gyorgy Bolyki Date: Wed, 6 May 2026 17:41:06 +0100 Subject: [PATCH 5/5] docs: refresh config examples --- config/local.toml | 32 +++++++---------------- config/services/rclone_ovh-hot-cold.toml | 33 ++++++------------------ config/services/s3_aws.toml | 28 +++++++++++++------- config/services/sftp.toml | 28 +++++++++++++------- 4 files changed, 56 insertions(+), 65 deletions(-) diff --git a/config/local.toml b/config/local.toml index 39caa1a15..1bd592b5a 100644 --- a/config/local.toml +++ b/config/local.toml @@ -1,31 +1,19 @@ -# rustic config file to backup /home, /etc and /root to a local repository -# -# backup usage: "rustic -P local backup -# cleanup: "rustic -P local forget --prune -# +# Local repository example. This follows the output style of `rustic setup`. + [repository] repository = "/backup/rustic" -password-file = "/root/key-rustic" -no-cache = true # no cache needed for local repository - -[forget] -keep-hourly = 20 -keep-daily = 14 -keep-weekly = 8 -keep-monthly = 24 -keep-yearly = 10 +no-cache = true [backup] +git-ignore = true exclude-if-present = [".nobackup", "CACHEDIR.TAG"] -glob-files = ["/root/rustic-local.glob"] -one-file-system = true +globs = ["!**/target/**", "!**/node_modules/**"] [[backup.snapshots]] sources = ["/home"] -git-ignore = true -[[backup.snapshots]] -sources = ["/etc"] - -[[backup.snapshots]] -sources = ["/root"] +[forget] +keep-daily = 7 +keep-weekly = 4 +keep-monthly = 12 +keep-yearly = 5 diff --git a/config/services/rclone_ovh-hot-cold.toml b/config/services/rclone_ovh-hot-cold.toml index 431fcd967..fa84293b8 100644 --- a/config/services/rclone_ovh-hot-cold.toml +++ b/config/services/rclone_ovh-hot-cold.toml @@ -1,34 +1,17 @@ -# rustic config file to backup /home, /etc and /root to a hot/cold repository hosted by OVH -# using OVH cloud archive and OVH object storage -# -# backup usage: "rustic --use-profile ovh-hot-cold backup -# cleanup: "rustic --use-profile ovh-hot-cold forget --prune +# rclone repository example. Configure the `backup-remote` remote with rclone. [repository] -repository = "rclone:ovh:backup-home" -repo-hot = "rclone:ovh:backup-home-hot" -password-file = "/root/key-rustic-ovh" -cache-dir = "/var/lib/cache/rustic" # explicitly specify cache dir for remote repository -warm-up = true # cold storage needs warm-up, just trying to access a file is sufficient to start the warm-up -warm-up-wait = "10m" # in my examples, 10 minutes wait-time was sufficient, according to docu it can be up to 12h - -[forget] -keep-daily = 8 -keep-weekly = 5 -keep-monthly = 13 -keep-yearly = 10 +repository = "rclone:backup-remote:rustic" [backup] +git-ignore = true exclude-if-present = [".nobackup", "CACHEDIR.TAG"] -glob-files = ["/root/rustic-ovh.glob"] -one-file-system = true [[backup.snapshots]] sources = ["/home"] -git-ignore = true -[[backup.snapshots]] -sources = ["/etc"] - -[[backup.snapshots]] -sources = ["/root"] +[forget] +keep-daily = 7 +keep-weekly = 4 +keep-monthly = 12 +keep-yearly = 5 diff --git a/config/services/s3_aws.toml b/config/services/s3_aws.toml index e0b94e5d0..3292dd8ba 100644 --- a/config/services/s3_aws.toml +++ b/config/services/s3_aws.toml @@ -1,13 +1,23 @@ -# rustic config file to use s3 storage -# Note that this internally uses opendal S3 service, see https://opendal.apache.org/docs/rust/opendal/services/struct.S3.html -# where endpoint, bucket and root are extracted from the repository URL. +# S3-compatible repository example. Provide access keys through AWS/OpenDAL +# environment variables or your provider's standard config files. + [repository] repository = "opendal:s3" -password = "password" -# Other options can be given here - note that opendal also support reading config from env files or AWS config dirs, see the opendal S3 docu [repository.options] -access_key_id = "xxx" # this can be omitted, when AWS config is used -secret_access_key = "xxx" # this can be omitted, when AWS config is used -bucket = "bucket_name" -root = "/path/to/repo" +bucket = "example-backups" +root = "/rustic" +region = "eu-central-1" + +[backup] +git-ignore = true +exclude-if-present = [".nobackup", "CACHEDIR.TAG"] + +[[backup.snapshots]] +sources = ["/home"] + +[forget] +keep-daily = 7 +keep-weekly = 4 +keep-monthly = 12 +keep-yearly = 5 diff --git a/config/services/sftp.toml b/config/services/sftp.toml index f016ce8bc..90da02a09 100644 --- a/config/services/sftp.toml +++ b/config/services/sftp.toml @@ -1,13 +1,23 @@ -# rustic config file to use sftp storage -# Note: -# - currently sftp only works on unix -# - Using sftp with password is not supported yet, use key authentication, e.g. use -# ssh-copy-id user@host +# SFTP repository example. Use SSH key authentication or provide credentials +# through the environment. + [repository] repository = "opendal:sftp" -password = "mypassword" [repository.options] -user = "myuser" -endpoint = "host:port" -root = "path/to/repo" +endpoint = "backup.example.com:22" +user = "backup" +root = "/srv/rustic" + +[backup] +git-ignore = true +exclude-if-present = [".nobackup", "CACHEDIR.TAG"] + +[[backup.snapshots]] +sources = ["/home"] + +[forget] +keep-daily = 7 +keep-weekly = 4 +keep-monthly = 12 +keep-yearly = 5