diff --git a/Cargo.toml b/Cargo.toml index 3376a18..6365518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ description = "This is like ncdu for a restic repository." anyhow = "1" camino = "1" chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } crossterm = "0.27" directories = "5" flexi_logger = "0.28" diff --git a/README.md b/README.md index 59b3078..72ef1c5 100644 --- a/README.md +++ b/README.md @@ -30,21 +30,24 @@ Note: it currently requires nightly to build. # Running -You can use all the regular restic environment variables, they will be passed -to all restic subprocesses redu spawns. +You can specify the repository and the password command in exactly the same ways +that restic supports, with the exception that redu will not prompt you for the password. -For example: +For example using environment variables: ``` $ export RESTIC_REPOSITORY='sftp://my-backup-server.my-domain.net' $ export RESTIC_PASSWORD_COMMAND='security find-generic-password -s restic -a personal -w' $ redu ``` -Alternatively, you can pass the repo and the password as arguments to redu: +Or via command line arguments: ``` redu -r 'sftp://my-backup-server.my-domain.net' --password-command 'security find-generic-password -s restic -a personal -w' ``` +Note: `--repository-file` (env: `RESTIC_REPOSITORY_FILE`) and `--password-file` (env: `RESTIC_PASSWORD_FILE`) +are supported as well and work just like in restic. + # Usage Redu keeps a cache with your file/directory sizes (per repo). On each run it will sync the cache with the snapshots in your repo, @@ -81,6 +84,8 @@ about the currently highlighted item: You can keep navigating with the details window open and it will update as you browse around. +Hint: you can press **Escape** to close the details window (as well as other dialogs). + ### Marking files You can mark files and directories to build up your list of things to exclude. Keybinds diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..ea732e4 --- /dev/null +++ b/src/args.rs @@ -0,0 +1,118 @@ +use clap::{ArgGroup, Parser}; +use log::LevelFilter; +use redu::restic::Repository; + +use crate::restic::Password; + +#[derive(Debug)] +pub struct Args { + pub repository: Repository, + pub password: Password, + pub parallelism: usize, + pub log_level: LevelFilter, + pub no_cache: bool, +} + +impl Args { + /// Parse arguments from std::env::args_os(), exit on error. + pub fn parse() -> Self { + let cli = Cli::parse(); + + Args { + repository: if let Some(repo) = cli.repo { + Repository::Repo(repo) + } else if let Some(file) = cli.repository_file { + Repository::File(file) + } else { + unreachable!("Error in Config: neither repo nor repository_file found. Please open an issue if you see this.") + }, + password: if let Some(command) = cli.password_command { + Password::Command(command) + } else if let Some(file) = cli.password_file { + Password::File(file) + } else { + unreachable!("Error in Config: neither password_command nor password_file found. Please open an issue if you see this.") + }, + parallelism: cli.parallelism, + log_level: match cli.verbose { + 0 => LevelFilter::Info, + 1 => LevelFilter::Debug, + _ => LevelFilter::Trace, + }, + no_cache: cli.no_cache, + } + } +} + +/// This is like ncdu for a restic respository. +/// +/// It computes the size for each directory/file by +/// taking the largest over all snapshots in the repository. +/// +/// You can browse your repository and mark directories/files. +/// These marks are persisted across runs of redu. +/// +/// When you're happy with the marks you can generate +/// a list to stdout with everything that you marked. +/// This list can be used directly as an exclude-file for restic. +/// +/// Redu keeps all messages and UI in stderr, +/// only the marks list is generated to stdout. +/// This means that you can pipe redu directly to a file +/// to get the exclude-file. +/// +/// NOTE: redu will never do any kind of modification to your repo. +/// It's strictly read-only. +/// +/// Keybinds: +/// Arrows or hjkl: Movement +/// PgUp/PgDown or C-b/C-f: Page up / Page down +/// Enter: Details +/// Escape: Close dialog +/// m: Mark +/// u: Unmark +/// c: Clear all marks +/// g: Generate +/// q: Quit +#[derive(Parser)] +#[command(version, long_about, verbatim_doc_comment)] +#[command(group( + ArgGroup::new("repository") + .required(true) + .args(["repo", "repository_file"]), +))] +#[command(group( + ArgGroup::new("password") + .required(true) + .args(["password_command", "password_file"]), +))] +struct Cli { + #[arg(short = 'r', long, env = "RESTIC_REPOSITORY")] + repo: Option, + + #[arg(long, env = "RESTIC_REPOSITORY_FILE")] + repository_file: Option, + + #[arg(long, value_name = "COMMAND", env = "RESTIC_PASSWORD_COMMAND")] + password_command: Option, + + #[arg(long, value_name = "FILE", env = "RESTIC_PASSWORD_FILE")] + password_file: Option, + + /// How many restic subprocesses to spawn concurrently. + /// + /// If you get ssh-related errors or too much memory use try lowering this. + #[arg(short = 'j', value_name = "NUMBER", default_value_t = 4)] + parallelism: usize, + + /// Log verbosity level. You can pass it multiple times (maxes out at two). + #[arg( + short = 'v', + action = clap::ArgAction::Count, + )] + verbose: u8, + + /// Pass the --no-cache option to restic subprocesses. + #[arg(long)] + no_cache: bool, +} diff --git a/src/main.rs b/src/main.rs index c4135d3..7dda1ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,8 @@ use std::{ }; use anyhow::Context; +use args::Args; use camino::{Utf8Path, Utf8PathBuf}; -use clap::{command, Parser}; use crossterm::{ event::{KeyCode, KeyModifiers}, terminal::{ @@ -25,7 +25,7 @@ use crossterm::{ ExecutableCommand, }; use directories::ProjectDirs; -use flexi_logger::{FileSpec, LogSpecification, Logger, WriteMode}; +use flexi_logger::{FileSpec, Logger, WriteMode}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use log::{error, info, trace}; use rand::{seq::SliceRandom, thread_rng}; @@ -46,67 +46,13 @@ use util::snapshot_short_id; use crate::ui::{Action, App, Event}; +mod args; mod ui; mod util; -/// This is like ncdu for a restic respository. -/// -/// It computes the size for each directory/file by -/// taking the largest over all snapshots in the repository. -/// -/// You can browse your repository and mark directories/files. -/// These marks are persisted across runs of redu. -/// -/// When you're happy with the marks you can generate -/// a list to stdout with everything that you marked. -/// This list can be used directly as an exclude-file for restic. -/// -/// Redu keeps all messages and UI in stderr, -/// only the marks list is generated to stdout. -/// This means that you can pipe redu directly to a file -/// to get the exclude-file. -/// -/// NOTE: redu will never do any kind of modification to your repo. -/// It's strictly read-only. -/// -/// Keybinds: -/// Arrows or hjkl: Movement -/// PgUp/PgDown or C-b/C-f: Page up / Page down -/// m: Mark -/// u: Unmark -/// c: Clear all marks -/// g: Generate -/// q: Quit -#[derive(Parser)] -#[command(version, long_about, verbatim_doc_comment)] -struct Cli { - #[arg(short = 'r', long)] - repo: Option, - - #[arg(long)] - password_command: Option, - - /// How many restic subprocesses to spawn concurrently. - /// - /// If you get ssh-related errors or too much memory use try lowering this. - #[arg(short = 'j', value_name = "NUMBER", default_value_t = 4)] - parallelism: usize, - - /// Log verbosity level. You can pass it multiple times (maxes out at two). - #[arg( - short = 'v', - action = clap::ArgAction::Count, - )] - verbose: u8, - - /// Pass the --no-cache option to restic subprocesses. - #[arg(long)] - no_cache: bool, -} - fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - let restic = Restic::new(cli.repo, cli.password_command, cli.no_cache); + let args = Args::parse(); + let restic = Restic::new(args.repository, args.password, args.no_cache); let dirs = ProjectDirs::from("eu", "drdo", "redu") .expect("unable to determine project directory"); @@ -120,12 +66,7 @@ fn main() -> anyhow::Result<()> { let filespec = { FileSpec::default().directory(directory).suppress_basename() }; - let spec = match cli.verbose { - 0 => LogSpecification::info(), - 1 => LogSpecification::debug(), - _ => LogSpecification::trace(), - }; - Logger::with(spec) + Logger::with(args.log_level) .log_to_file(filespec) .write_mode(WriteMode::BufferAndFlush) .format(flexi_logger::with_thread) @@ -175,7 +116,7 @@ fn main() -> anyhow::Result<()> { } }; - sync_snapshots(&restic, &mut cache, cli.parallelism)?; + sync_snapshots(&restic, &mut cache, args.parallelism)?; let entries = cache.get_entries(None)?; if entries.is_empty() { diff --git a/src/restic.rs b/src/restic.rs index d91e74f..405539f 100644 --- a/src/restic.rs +++ b/src/restic.rs @@ -95,18 +95,34 @@ pub struct Config { } pub struct Restic { - repo: Option, - password_command: Option, + repository: Repository, + password: Password, no_cache: bool, } +#[derive(Debug)] +pub enum Repository { + /// A repository string (restic: --repo) + Repo(String), + /// A repository file (restic: --repository-file) + File(String), +} + +#[derive(Debug)] +pub enum Password { + /// A password command (restic: --password-command) + Command(String), + /// A password file (restic: --password-file) + File(String), +} + impl Restic { pub fn new( - repo: Option, - password_command: Option, + repository: Repository, + password: Password, no_cache: bool, ) -> Self { - Restic { repo, password_command, no_cache } + Restic { repository, password, no_cache } } pub fn config(&self) -> Result { @@ -192,12 +208,15 @@ impl Restic { Ok(()) }); } - if let Some(repo) = &self.repo { - cmd.arg("--repo").arg(repo); - } - if let Some(password_command) = &self.password_command { - cmd.arg("--password-command").arg(password_command); - } + match &self.repository { + Repository::Repo(repo) => cmd.arg("--repo").arg(repo), + Repository::File(file) => cmd.arg("--repository-file").arg(file), + }; + match &self.password { + Password::Command(command) => + cmd.arg("--password-command").arg(command), + Password::File(file) => cmd.arg("--password-file").arg(file), + }; if self.no_cache { cmd.arg("--no-cache"); }