diff --git a/src/cli/link.rs b/src/cli/link.rs index 1d3f1c8..853ff06 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -2,64 +2,80 @@ use std::path::PathBuf; use clap::{Args, Subcommand}; +// each variant carries its own arguments as a struct rather than as fields +// spread across the enum. the command that implements it takes that struct, so +// main does not unpack what clap has already parsed and hand it on as a row of +// booleans nothing but position tells apart #[derive(Subcommand, Debug)] pub enum Link { /// Move untracked paths into the store and symlink them back - Add { - /// Untracked or ignored paths inside the repository - #[arg(required = true)] - paths: Vec, - - /// Link to a store path that already exists - #[arg(long)] - force: bool, - - #[command(flatten)] - store: Store, - }, + Add(Add), /// Move paths in the store back into the repository - Restore { - // said here rather than checked at runtime, so a mistake in the - // arguments is reported as one, with the usage and the exit code clap - // gives every other argument error - #[arg(required_unless_present = "all", conflicts_with = "all")] - paths: Vec, - - /// Restore every path this repository has in the store - #[arg(long)] - all: bool, - - #[command(flatten)] - store: Store, - }, + Restore(Restore), /// List what this repository keeps in the store - List { - #[command(flatten)] - store: Store, - }, + List(List), /// List untracked paths a sandbox would still see - Check { - /// Limit the listing to these paths - paths: Vec, + Check(Check), +} - /// Print ` ` for scripts, as `git status --porcelain` does - #[arg(long)] - porcelain: bool, +#[derive(Args, Debug)] +pub struct Add { + /// Untracked or ignored paths inside the repository + #[arg(required = true)] + pub paths: Vec, - /// Exit with 4 when anything is outside the store, for scripts - #[arg(long)] - exit_code: bool, + /// Link to a store path that already exists + #[arg(long)] + pub force: bool, - /// Terminate porcelain entries with NUL - #[arg(short = 'z')] - null: bool, + #[command(flatten)] + pub store: Store, +} - #[command(flatten)] - store: Store, - }, +#[derive(Args, Debug)] +pub struct Restore { + // said here rather than checked at runtime, so a mistake in the + // arguments is reported as one, with the usage and the exit code clap + // gives every other argument error + #[arg(required_unless_present = "all", conflicts_with = "all")] + pub paths: Vec, + + /// Restore every path this repository has in the store + #[arg(long)] + pub all: bool, + + #[command(flatten)] + pub store: Store, +} + +#[derive(Args, Debug)] +pub struct List { + #[command(flatten)] + pub store: Store, +} + +#[derive(Args, Debug)] +pub struct Check { + /// Limit the listing to these paths + pub paths: Vec, + + /// Print ` ` for scripts, as `git status --porcelain` does + #[arg(long)] + pub porcelain: bool, + + /// Exit with 4 when anything is outside the store, for scripts + #[arg(long)] + pub exit_code: bool, + + /// Terminate porcelain entries with NUL + #[arg(short = 'z')] + pub null: bool, + + #[command(flatten)] + pub store: Store, } #[derive(Args, Debug)] @@ -70,3 +86,9 @@ pub struct Store { #[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")] pub root: Option, } + +impl Store { + pub fn root(&self) -> Option<&std::path::Path> { + self.root.as_deref() + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 663207e..b00f98d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,9 +1,9 @@ // build.rs reaches these definitions with include!, so nothing here may refer to // the rest of the crate: keep this tree to clap definitions only -mod ahab; -mod django; -mod link; -mod postgres; +pub mod ahab; +pub mod django; +pub mod link; +pub mod postgres; pub use ahab::{Ahab, Commands}; pub use django::Django; diff --git a/src/cli/postgres.rs b/src/cli/postgres.rs index 2f50505..b56c185 100644 --- a/src/cli/postgres.rs +++ b/src/cli/postgres.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{Subcommand, ValueEnum}; +use clap::{Args, Subcommand, ValueEnum}; #[derive(Subcommand, Debug)] pub enum Postgres { @@ -15,17 +15,20 @@ pub enum Postgres { }, /// Dump via pg_dump, or pg_dumpall for a whole cluster - Dump { - path: PathBuf, + Dump(Dump), +} - /// Dump format - #[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)] - format: Format, +#[derive(Args, Debug)] +pub struct Dump { + pub path: PathBuf, - /// Compress the dump with gzip - #[arg(short = 'z', long)] - gzip: bool, - }, + /// Dump format + #[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)] + pub format: Format, + + /// Compress the dump with gzip + #[arg(short = 'z', long)] + pub gzip: bool, } #[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)] @@ -51,16 +54,3 @@ pub enum Format { #[value(alias = "all")] Cluster, } - -impl Format { - // pg_dump's -F letter, which a cluster dump does not have: that is pg_dumpall - pub fn flag(&self) -> Option<&'static str> { - match self { - Self::Custom => Some("c"), - Self::Plain => Some("p"), - Self::Tar => Some("t"), - Self::Directory => Some("d"), - Self::Cluster => None, - } - } -} diff --git a/src/cmd/postgres.rs b/src/cmd/postgres.rs index e2dbe7d..86bf6c4 100644 --- a/src/cmd/postgres.rs +++ b/src/cmd/postgres.rs @@ -1,4 +1,5 @@ use super::{Argv, Cmd}; +use crate::cli::Format; // whether the server is accepting connections yet pub struct PgIsReady<'a> { @@ -156,13 +157,25 @@ pub struct PgDump<'a> { } impl<'a> PgDump<'a> { - pub fn new(username: &'a str, dbname: &'a str, format: &'a str) -> Self { - Self { + // pg_dump's -F letter, which belongs with the rest of what this module + // knows about pg_dump's argv rather than with the parser that reads the + // user's `-F custom`. a cluster has no letter: that is pg_dumpall, so + // there is no PgDump to build for it + pub fn of(username: &'a str, dbname: &'a str, format: Format) -> Option { + let format = match format { + Format::Custom => "c", + Format::Plain => "p", + Format::Tar => "t", + Format::Directory => "d", + Format::Cluster => return None, + }; + + Some(Self { username, dbname, format, to: None, - } + }) } // a path in the container, for the directory format, which pg_dump writes @@ -201,7 +214,7 @@ impl Cmd for PgDumpAll<'_> { #[cfg(test)] mod tests { - use super::{DropDb, PgDump, Psql}; + use super::{DropDb, Format, PgDump, Psql}; use crate::cmd::Cmd; #[test] @@ -219,16 +232,35 @@ mod tests { #[test] fn a_directory_dump_names_the_file_it_writes() { + let directory = PgDump::of("u", "db", Format::Directory).unwrap(); + assert_eq!( - PgDump::new("u", "db", "d").to("/tmp/dump").argv().quoted(), + directory.to("/tmp/dump").argv().quoted(), "pg_dump --username u --format d --file /tmp/dump -- db" ); assert_eq!( - PgDump::new("u", "db", "c").argv().quoted(), + PgDump::of("u", "db", Format::Custom) + .unwrap() + .argv() + .quoted(), "pg_dump --username u --format c -- db" ); } + #[test] + fn a_cluster_is_pg_dumpall_rather_than_a_pg_dump_letter() { + assert!(PgDump::of("u", "db", Format::Cluster).is_none()); + + for format in [ + Format::Custom, + Format::Plain, + Format::Tar, + Format::Directory, + ] { + assert!(PgDump::of("u", "db", format).is_some(), "{format:?}"); + } + } + #[test] fn only_a_single_database_restore_stops_at_the_first_error() { assert!( diff --git a/src/commands/link.rs b/src/commands/link.rs index ea8a90b..0dea1fa 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -10,6 +10,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked}; +use crate::cli::link as cli; use crate::ctx::Ctx; use crate::fsops::suffixed; use crate::output::{line, note, warning}; @@ -19,8 +20,9 @@ const BACKUP_SUFFIX: &str = ".ahab-bak"; const RESTORING_SUFFIX: &str = ".ahab-restoring"; // move untracked paths out of the repo and symlink them back -pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; +pub fn add(ctx: &Ctx, args: &cli::Add) -> Result<()> { + let (paths, force) = (args.paths.as_slice(), args.force); + let repo = Repo::discover(ctx, args.store.root())?; let report = Report::new(&repo); if let [path] = paths { @@ -42,8 +44,9 @@ pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> R } // move paths in the store back into the repo, the inverse of add -pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; +pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> { + let (paths, all) = (args.paths.as_slice(), args.all); + let repo = Repo::discover(ctx, args.store.root())?; let report = Report::new(&repo); // clap requires one or the other and refuses both, so only the two real @@ -225,8 +228,8 @@ pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize } // list what the store holds for this repository, the inverse of check -pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; +pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> { + let repo = Repo::discover(ctx, args.store.root())?; let stored = stored_paths(&repo, &repo.store)?; line!("store: {}", repo.store.display()); diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs index 7b4f980..b0f36f9 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -6,20 +6,15 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt}; +use crate::cli::link as cli; use crate::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; use crate::output::{line, text, write_bytes}; // whether anything is outside the store, which main turns into an exit code -pub fn check( - ctx: &Ctx, - paths: &[PathBuf], - porcelain: bool, - null: bool, - store: Option<&Path>, -) -> Result { - let repo = Repo::discover(ctx, store)?; - let pathspecs = relative_pathspecs(&repo, paths)?; +pub fn check(ctx: &Ctx, args: &cli::Check) -> Result { + let repo = Repo::discover(ctx, args.store.root())?; + let pathspecs = relative_pathspecs(&repo, &args.paths)?; let mut exposed = Vec::new(); // git lists untracked and ignored separately @@ -40,8 +35,8 @@ pub fn check( exposed.sort_by(|a, b| a.name.cmp(&b.name)); - if porcelain || null { - print_porcelain(&exposed, null); + if args.porcelain || args.null { + print_porcelain(&exposed, args.null); } else { print_listing(&repo, &exposed); } diff --git a/src/commands/postgres.rs b/src/commands/postgres.rs index 34fe179..1d29587 100644 --- a/src/commands/postgres.rs +++ b/src/commands/postgres.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result, bail}; use self::server::{Database, wait_until_ready, when_ready}; use self::shape::{Dump, HEADER_LEN, Kind}; use crate::cli::Format; +use crate::cli::postgres as cli; use crate::cmd::{ Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, }; @@ -297,10 +298,13 @@ pub fn psql(ctx: &Ctx, rest: &[String]) -> Result<()> { .replace(ctx) } -pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { +pub fn dump(ctx: &Ctx, args: &cli::Dump) -> Result<()> { + let (file, format, gzip) = (args.path.as_path(), args.format, args.gzip); let db = Database::resolve(ctx)?; if format == Format::Directory { + // main says this as an argument error before getting here; kept as the + // last word in case anything else ever calls dump if gzip { bail!("a directory dump is a directory of already compressed files, not a stream"); } @@ -343,8 +347,8 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { // pg_dump for one database, pg_dumpall for a cluster, which has no format letter fn dump_command(db: &Database, format: Format) -> Box { - match format.flag() { - Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)), + match PgDump::of(&db.user, &db.name, format) { + Some(dump) => Box::new(dump), None => Box::new(PgDumpAll { username: &db.user }), } } @@ -364,7 +368,8 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { note!(ctx, "dumping to local directory {}", target.display()); let remote = remote_dump(); - PgDump::new(&db.user, &db.name, "d") + PgDump::of(&db.user, &db.name, Format::Directory) + .expect("a directory dump has a pg_dump letter") .to(&remote) .in_container(&db.container) .run(ctx)?; diff --git a/src/main.rs b/src/main.rs index b0795c7..3fec578 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,51 +73,32 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { match command { cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest), - cli::Postgres::Dump { path, format, gzip } => { + cli::Postgres::Dump(args) => { // clap cannot say that a flag conflicts with one value of // another, and this is still an argument error: it belongs // with the usage and the exit code the others get - if gzip && format == cli::Format::Directory { + if args.gzip && args.format == cli::Format::Directory { usage( "a directory dump is a directory of already \ compressed files, not a stream", ); } - commands::postgres::dump(ctx, &path, format, gzip) + commands::postgres::dump(ctx, &args) } }?; Ok(done) } cli::Commands::Link { command } => match command { - cli::Link::Add { - paths, - force, - store, - } => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), - cli::Link::List { store } => { - commands::link::list(ctx, store.root.as_deref()).map(|()| done) - } - cli::Link::Restore { paths, all, store } => { - commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) - } + cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done), + cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done), + cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done), // the one command with something to say through its exit code - cli::Link::Check { - paths, - porcelain, - null, - exit_code, - store, - } => { - let exposed = - commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?; - - match exit_code && exposed { - true => Ok(ExitCode::from(FINDINGS)), - false => Ok(done), - } - } + cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code { + true => Ok(ExitCode::from(FINDINGS)), + false => Ok(done), + }, }, cli::Commands::Status { store } => { commands::status::status(ctx, store.root.as_deref())?;