refactor: hand clap's own structs to the commands

This commit is contained in:
2026-09-09 12:37:19 +00:00
parent b40dfe6124
commit 5dbe99a304
8 changed files with 156 additions and 128 deletions

View File

@@ -2,64 +2,80 @@ use std::path::PathBuf;
use clap::{Args, Subcommand}; 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)] #[derive(Subcommand, Debug)]
pub enum Link { pub enum Link {
/// Move untracked paths into the store and symlink them back /// Move untracked paths into the store and symlink them back
Add { Add(Add),
/// Move paths in the store back into the repository
Restore(Restore),
/// List what this repository keeps in the store
List(List),
/// List untracked paths a sandbox would still see
Check(Check),
}
#[derive(Args, Debug)]
pub struct Add {
/// Untracked or ignored paths inside the repository /// Untracked or ignored paths inside the repository
#[arg(required = true)] #[arg(required = true)]
paths: Vec<PathBuf>, pub paths: Vec<PathBuf>,
/// Link to a store path that already exists /// Link to a store path that already exists
#[arg(long)] #[arg(long)]
force: bool, pub force: bool,
#[command(flatten)] #[command(flatten)]
store: Store, pub store: Store,
}, }
/// Move paths in the store back into the repository #[derive(Args, Debug)]
Restore { pub struct Restore {
// said here rather than checked at runtime, so a mistake in the // 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 // arguments is reported as one, with the usage and the exit code clap
// gives every other argument error // gives every other argument error
#[arg(required_unless_present = "all", conflicts_with = "all")] #[arg(required_unless_present = "all", conflicts_with = "all")]
paths: Vec<PathBuf>, pub paths: Vec<PathBuf>,
/// Restore every path this repository has in the store /// Restore every path this repository has in the store
#[arg(long)] #[arg(long)]
all: bool, pub all: bool,
#[command(flatten)] #[command(flatten)]
store: Store, pub store: Store,
}, }
/// List what this repository keeps in the store #[derive(Args, Debug)]
List { pub struct List {
#[command(flatten)] #[command(flatten)]
store: Store, pub store: Store,
}, }
/// List untracked paths a sandbox would still see #[derive(Args, Debug)]
Check { pub struct Check {
/// Limit the listing to these paths /// Limit the listing to these paths
paths: Vec<PathBuf>, pub paths: Vec<PathBuf>,
/// Print `<code> <path>` for scripts, as `git status --porcelain` does /// Print `<code> <path>` for scripts, as `git status --porcelain` does
#[arg(long)] #[arg(long)]
porcelain: bool, pub porcelain: bool,
/// Exit with 4 when anything is outside the store, for scripts /// Exit with 4 when anything is outside the store, for scripts
#[arg(long)] #[arg(long)]
exit_code: bool, pub exit_code: bool,
/// Terminate porcelain entries with NUL /// Terminate porcelain entries with NUL
#[arg(short = 'z')] #[arg(short = 'z')]
null: bool, pub null: bool,
#[command(flatten)] #[command(flatten)]
store: Store, pub store: Store,
},
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
@@ -70,3 +86,9 @@ pub struct Store {
#[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")] #[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")]
pub root: Option<PathBuf>, pub root: Option<PathBuf>,
} }
impl Store {
pub fn root(&self) -> Option<&std::path::Path> {
self.root.as_deref()
}
}

View File

@@ -1,9 +1,9 @@
// build.rs reaches these definitions with include!, so nothing here may refer to // 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 // the rest of the crate: keep this tree to clap definitions only
mod ahab; pub mod ahab;
mod django; pub mod django;
mod link; pub mod link;
mod postgres; pub mod postgres;
pub use ahab::{Ahab, Commands}; pub use ahab::{Ahab, Commands};
pub use django::Django; pub use django::Django;

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use clap::{Subcommand, ValueEnum}; use clap::{Args, Subcommand, ValueEnum};
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum Postgres { pub enum Postgres {
@@ -15,17 +15,20 @@ pub enum Postgres {
}, },
/// Dump via pg_dump, or pg_dumpall for a whole cluster /// Dump via pg_dump, or pg_dumpall for a whole cluster
Dump { Dump(Dump),
path: PathBuf, }
#[derive(Args, Debug)]
pub struct Dump {
pub path: PathBuf,
/// Dump format /// Dump format
#[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)] #[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)]
format: Format, pub format: Format,
/// Compress the dump with gzip /// Compress the dump with gzip
#[arg(short = 'z', long)] #[arg(short = 'z', long)]
gzip: bool, pub gzip: bool,
},
} }
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)] #[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)]
@@ -51,16 +54,3 @@ pub enum Format {
#[value(alias = "all")] #[value(alias = "all")]
Cluster, 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,
}
}
}

View File

@@ -1,4 +1,5 @@
use super::{Argv, Cmd}; use super::{Argv, Cmd};
use crate::cli::Format;
// whether the server is accepting connections yet // whether the server is accepting connections yet
pub struct PgIsReady<'a> { pub struct PgIsReady<'a> {
@@ -156,13 +157,25 @@ pub struct PgDump<'a> {
} }
impl<'a> PgDump<'a> { impl<'a> PgDump<'a> {
pub fn new(username: &'a str, dbname: &'a str, format: &'a str) -> Self { // pg_dump's -F letter, which belongs with the rest of what this module
Self { // 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<Self> {
let format = match format {
Format::Custom => "c",
Format::Plain => "p",
Format::Tar => "t",
Format::Directory => "d",
Format::Cluster => return None,
};
Some(Self {
username, username,
dbname, dbname,
format, format,
to: None, to: None,
} })
} }
// a path in the container, for the directory format, which pg_dump writes // a path in the container, for the directory format, which pg_dump writes
@@ -201,7 +214,7 @@ impl Cmd for PgDumpAll<'_> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DropDb, PgDump, Psql}; use super::{DropDb, Format, PgDump, Psql};
use crate::cmd::Cmd; use crate::cmd::Cmd;
#[test] #[test]
@@ -219,16 +232,35 @@ mod tests {
#[test] #[test]
fn a_directory_dump_names_the_file_it_writes() { fn a_directory_dump_names_the_file_it_writes() {
let directory = PgDump::of("u", "db", Format::Directory).unwrap();
assert_eq!( 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" "pg_dump --username u --format d --file /tmp/dump -- db"
); );
assert_eq!( 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" "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] #[test]
fn only_a_single_database_restore_stops_at_the_first_error() { fn only_a_single_database_restore_stops_at_the_first_error() {
assert!( assert!(

View File

@@ -10,6 +10,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked}; use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
use crate::cli::link as cli;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::suffixed; use crate::fsops::suffixed;
use crate::output::{line, note, warning}; use crate::output::{line, note, warning};
@@ -19,8 +20,9 @@ const BACKUP_SUFFIX: &str = ".ahab-bak";
const RESTORING_SUFFIX: &str = ".ahab-restoring"; const RESTORING_SUFFIX: &str = ".ahab-restoring";
// move untracked paths out of the repo and symlink them back // move untracked paths out of the repo and symlink them back
pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { pub fn add(ctx: &Ctx, args: &cli::Add) -> Result<()> {
let repo = Repo::discover(ctx, store)?; let (paths, force) = (args.paths.as_slice(), args.force);
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo); let report = Report::new(&repo);
if let [path] = paths { 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 // 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<()> { pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> {
let repo = Repo::discover(ctx, store)?; let (paths, all) = (args.paths.as_slice(), args.all);
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo); let report = Report::new(&repo);
// clap requires one or the other and refuses both, so only the two real // 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 // list what the store holds for this repository, the inverse of check
pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> { pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
let repo = Repo::discover(ctx, store)?; let repo = Repo::discover(ctx, args.store.root())?;
let stored = stored_paths(&repo, &repo.store)?; let stored = stored_paths(&repo, &repo.store)?;
line!("store: {}", repo.store.display()); line!("store: {}", repo.store.display());

View File

@@ -6,20 +6,15 @@ use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt}; use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt};
use crate::cli::link as cli;
use crate::cmd::{Cmd, LsFiles}; use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::output::{line, text, write_bytes}; use crate::output::{line, text, write_bytes};
// whether anything is outside the store, which main turns into an exit code // whether anything is outside the store, which main turns into an exit code
pub fn check( pub fn check(ctx: &Ctx, args: &cli::Check) -> Result<bool> {
ctx: &Ctx, let repo = Repo::discover(ctx, args.store.root())?;
paths: &[PathBuf], let pathspecs = relative_pathspecs(&repo, &args.paths)?;
porcelain: bool,
null: bool,
store: Option<&Path>,
) -> Result<bool> {
let repo = Repo::discover(ctx, store)?;
let pathspecs = relative_pathspecs(&repo, paths)?;
let mut exposed = Vec::new(); let mut exposed = Vec::new();
// git lists untracked and ignored separately // git lists untracked and ignored separately
@@ -40,8 +35,8 @@ pub fn check(
exposed.sort_by(|a, b| a.name.cmp(&b.name)); exposed.sort_by(|a, b| a.name.cmp(&b.name));
if porcelain || null { if args.porcelain || args.null {
print_porcelain(&exposed, null); print_porcelain(&exposed, args.null);
} else { } else {
print_listing(&repo, &exposed); print_listing(&repo, &exposed);
} }

View File

@@ -10,6 +10,7 @@ use anyhow::{Context, Result, bail};
use self::server::{Database, wait_until_ready, when_ready}; use self::server::{Database, wait_until_ready, when_ready};
use self::shape::{Dump, HEADER_LEN, Kind}; use self::shape::{Dump, HEADER_LEN, Kind};
use crate::cli::Format; use crate::cli::Format;
use crate::cli::postgres as cli;
use crate::cmd::{ use crate::cmd::{
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, 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) .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)?; let db = Database::resolve(ctx)?;
if format == Format::Directory { 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 { if gzip {
bail!("a directory dump is a directory of already compressed files, not a stream"); 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 // pg_dump for one database, pg_dumpall for a cluster, which has no format letter
fn dump_command(db: &Database, format: Format) -> Box<dyn Cmd + '_> { fn dump_command(db: &Database, format: Format) -> Box<dyn Cmd + '_> {
match format.flag() { match PgDump::of(&db.user, &db.name, format) {
Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)), Some(dump) => Box::new(dump),
None => Box::new(PgDumpAll { username: &db.user }), 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()); note!(ctx, "dumping to local directory {}", target.display());
let remote = remote_dump(); 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) .to(&remote)
.in_container(&db.container) .in_container(&db.container)
.run(ctx)?; .run(ctx)?;

View File

@@ -73,51 +73,32 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
match command { match command {
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest), 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 // clap cannot say that a flag conflicts with one value of
// another, and this is still an argument error: it belongs // another, and this is still an argument error: it belongs
// with the usage and the exit code the others get // 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( usage(
"a directory dump is a directory of already \ "a directory dump is a directory of already \
compressed files, not a stream", compressed files, not a stream",
); );
} }
commands::postgres::dump(ctx, &path, format, gzip) commands::postgres::dump(ctx, &args)
} }
}?; }?;
Ok(done) Ok(done)
} }
cli::Commands::Link { command } => match command { cli::Commands::Link { command } => match command {
cli::Link::Add { cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done),
paths, cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done),
force, cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done),
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)
}
// the one command with something to say through its exit code // the one command with something to say through its exit code
cli::Link::Check { cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code {
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)), true => Ok(ExitCode::from(FINDINGS)),
false => Ok(done), false => Ok(done),
} },
}
}, },
cli::Commands::Status { store } => { cli::Commands::Status { store } => {
commands::status::status(ctx, store.root.as_deref())?; commands::status::status(ctx, store.root.as_deref())?;