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/argv.rs b/src/cmd/argv.rs index ed44190..ab9e5bc 100644 --- a/src/cmd/argv.rs +++ b/src/cmd/argv.rs @@ -65,9 +65,8 @@ impl Argv { &self.0 } - // one word list for `sh -c`, quoted so a value with a space survives the - // shell. lossy: this is for reading, and for scripts built only out of the - // ascii and compose-derived words that reach a pipeline + // for reading: the -v echo, the dry run's plan, and the command named in an + // error. lossy, because a message about a path is still worth printing pub fn quoted(&self) -> String { let words: Vec = self .0 @@ -82,6 +81,28 @@ impl Argv { shlex::try_join(words.iter().map(String::as_str)).unwrap_or_else(|_| words.join(" ")) } + // one word list for a shell to read, which is a string and so cannot carry + // a word that is not text. only ascii and compose-derived words reach a + // pipeline today; this is what keeps it that way, rather than a comment + // saying so while to_string_lossy quietly rewrites a path + pub fn for_shell(&self) -> Result { + let mut words = Vec::with_capacity(self.0.len()); + + for word in &self.0 { + let word = word.to_str().ok_or_else(|| { + anyhow!( + "`{}` cannot go in a shell pipeline: {:?} is not text", + self.quoted(), + word + ) + })?; + + words.push(word); + } + + shlex::try_join(words).map_err(|e| anyhow!("quoting `{}`: {e}", self.quoted())) + } + pub fn run(&self, ctx: &Ctx) -> Result<()> { if self.skipped(ctx) { return Ok(()); diff --git a/src/cmd/compose.rs b/src/cmd/compose.rs index 2b7daae..8e81de3 100644 --- a/src/cmd/compose.rs +++ b/src/cmd/compose.rs @@ -1,4 +1,5 @@ use super::{Argv, Cmd}; +use crate::ctx::Ctx; // compose prints its own progress, which --quiet has to ask it to stop fn compose(quiet: bool) -> Argv { @@ -10,6 +11,49 @@ fn compose(quiet: bool) -> Argv { } } +// the compose commands, each already knowing whether -q asked compose to stop +// narrating. the flag was a field every construction site set by hand, and +// three of them did not, so `ahab -q` still had compose talking over the output +pub struct Compose<'a> { + ctx: &'a Ctx, +} + +impl<'a> Compose<'a> { + pub(crate) fn new(ctx: &'a Ctx) -> Self { + Self { ctx } + } + + pub fn up(&self) -> Up { + Up { + quiet: self.ctx.quiet, + } + } + + pub fn stop(&self) -> Stop { + Stop { + quiet: self.ctx.quiet, + } + } + + pub fn start(&self, service: &'a str) -> Start<'a> { + Start { + service, + quiet: self.ctx.quiet, + } + } + + // reading the project says nothing, so there is no progress to quieten + pub fn config(&self) -> Config { + Config + } + + pub fn ps(&self, service: &str) -> Ps { + Ps { + service: service.to_string(), + } + } +} + // docker compose run --rm, which runs the image's entrypoint and so fixes up the // container user before handing over pub struct Run { @@ -19,20 +63,15 @@ pub struct Run { } impl Run { - pub fn wrapping(service: &str, inner: Argv) -> Self { + // only reached through Cmd::in_service, which takes the context, so the + // progress flag cannot be left off by forgetting a builder call + pub(super) fn wrapping(ctx: &Ctx, service: &str, inner: Argv) -> Self { Self { service: service.to_string(), inner, - quiet: false, + quiet: ctx.quiet, } } - - // compose narrates the container it creates before handing over, which is - // progress along the way rather than what was asked for - pub fn quiet(mut self, quiet: bool) -> Self { - self.quiet = quiet; - self - } } impl Cmd for Run { @@ -59,14 +98,6 @@ pub struct Ps { service: String, } -impl Ps { - pub fn id_of(service: &str) -> Self { - Self { - service: service.to_string(), - } - } -} - impl Cmd for Ps { fn argv(&self) -> Argv { compose(false).arg("ps").arg("--quiet").arg(&self.service) @@ -74,7 +105,7 @@ impl Cmd for Ps { } pub struct Up { - pub quiet: bool, + quiet: bool, } impl Cmd for Up { @@ -84,8 +115,8 @@ impl Cmd for Up { } pub struct Start<'a> { - pub service: &'a str, - pub quiet: bool, + service: &'a str, + quiet: bool, } impl Cmd for Start<'_> { @@ -95,7 +126,7 @@ impl Cmd for Start<'_> { } pub struct Stop { - pub quiet: bool, + quiet: bool, } impl Cmd for Stop { diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index a88486f..5ac7f72 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -14,7 +14,7 @@ use std::{ use anyhow::Result; pub use argv::Argv; -pub use compose::{Config, Ps, Run, Start, Stop, Up}; +pub use compose::{Compose, Run}; pub use django::{Bash, Manage, Words}; pub use docker::{Cp, Exec}; pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse}; @@ -30,22 +30,25 @@ pub trait Cmd { fn argv(&self) -> Argv; // this command as one word list, for a shell to read - fn shell(&self) -> String { - self.argv().quoted() + fn shell(&self) -> Result { + self.argv().for_shell() } // adapters - fn pipe(&self, next: &dyn Cmd) -> Pipeline { - Pipeline::starting(self.shell()).pipe(next) + fn pipe(&self, next: &dyn Cmd) -> Result { + Pipeline::starting(self.shell()?).pipe(next) } fn in_container(&self, container: &str) -> Exec { Exec::wrapping(container, self.argv()) } - fn in_service(&self, service: &str) -> Run { - Run::wrapping(service, self.argv()) + // the context comes in here because compose narrates what it is doing, and + // whether it should is the caller's -q rather than this command's business + fn in_service(&self, ctx: &Ctx, service: &str) -> Run { + Run::wrapping(ctx, service, self.argv()) } + // terminals fn run(&self, ctx: &Ctx) -> Result<()> { self.argv().run(ctx) 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/cmd/shell.rs b/src/cmd/shell.rs index 850a590..3857214 100644 --- a/src/cmd/shell.rs +++ b/src/cmd/shell.rs @@ -1,3 +1,5 @@ +use anyhow::Result; + use super::{Argv, Cmd}; // sh -c, the only way to reach a shell feature inside a container @@ -30,9 +32,9 @@ impl Pipeline { } } - pub fn pipe(mut self, next: &dyn Cmd) -> Self { - self.stages.push(next.shell()); - self + pub fn pipe(mut self, next: &dyn Cmd) -> Result { + self.stages.push(next.shell()?); + Ok(self) } // for a pipeline whose last stage closes the pipe on purpose, where the @@ -119,21 +121,26 @@ mod tests { #[test] fn a_pipeline_reports_a_stage_that_dies_mid_stream() { - let script = Gunzip.pipe(&Gzip).shell(); + let script = Gunzip.pipe(&Gzip).unwrap().shell().unwrap(); assert_eq!(script, "sh -c 'set -o pipefail; gunzip -c | gzip'"); } #[test] fn a_pipeline_that_closes_the_pipe_on_purpose_keeps_the_default() { - let script = Gunzip.pipe(&Head::bytes(512)).allow_early_close().shell(); + let script = Gunzip + .pipe(&Head::bytes(512)) + .unwrap() + .allow_early_close() + .shell() + .unwrap(); assert_eq!(script, "sh -c 'gunzip -c | head -c 512'"); } #[test] fn a_pipeline_reaches_a_container_as_one_argument() { - let argv = Gunzip.pipe(&Gzip).in_container("abc123").argv(); + let argv = Gunzip.pipe(&Gzip).unwrap().in_container("abc123").argv(); assert_eq!( argv.words(), diff --git a/src/commands/django.rs b/src/commands/django.rs index e9795ca..2c4d699 100644 --- a/src/commands/django.rs +++ b/src/commands/django.rs @@ -4,7 +4,7 @@ use anyhow::{Result, anyhow}; use crate::cmd::{Bash, Cmd, Manage, Words}; use crate::ctx::Ctx; -use crate::fsops::{create_dir, touch, write_new}; + use crate::output::note; use crate::project::Project; @@ -40,8 +40,8 @@ pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> { let management_dir = app_dir.join("management"); if !management_dir.exists() { - create_dir(ctx, &management_dir)?; - touch(ctx, &management_dir.join("__init__.py"))?; + ctx.fs().create_dir(&management_dir)?; + ctx.fs().touch(&management_dir.join("__init__.py"))?; note!(ctx, "created module {app_name}.management") }; @@ -49,14 +49,13 @@ pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> { let commands_dir = management_dir.join("commands"); if !commands_dir.exists() { - create_dir(ctx, &commands_dir)?; - touch(ctx, &commands_dir.join("__init__.py"))?; + ctx.fs().create_dir(&commands_dir)?; + ctx.fs().touch(&commands_dir.join("__init__.py"))?; note!(ctx, "created module {app_name}.management.commands") }; - write_new( - ctx, + ctx.fs().write_new( &commands_dir.join(format!("{name}.py")), DEBUG_TEMPLATE.as_bytes(), )?; @@ -72,22 +71,18 @@ fn is_module_name(name: &str) -> bool { } pub fn bash(ctx: &Ctx) -> Result<()> { - Bash.in_service(&service(ctx)?) - .quiet(ctx.quiet) - .replace(ctx) + Bash.in_service(ctx, &service(ctx)?).replace(ctx) } pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> { Words::new(rest) - .in_service(&service(ctx)?) - .quiet(ctx.quiet) + .in_service(ctx, &service(ctx)?) .replace(ctx) } pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> { Manage::new(rest) - .in_service(&service(ctx)?) - .quiet(ctx.quiet) + .in_service(ctx, &service(ctx)?) .replace(ctx) } diff --git a/src/commands/link.rs b/src/commands/link.rs index 5a725be..2e20f49 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -10,49 +10,62 @@ 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::{ - ensure_private_parent, move_path, place_link, prune_empty, remove_file, rename, suffixed, -}; -use crate::output::{line, note, warning}; +use crate::fsops::suffixed; +use crate::output::{line, note, plural, warning}; const BACKUP_SUFFIX: &str = ".ahab-bak"; // where the link waits while the payload comes back out of the store 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)?; - let report = Report::new(&repo); - - if let [path] = paths { - return link_one(ctx, &repo, path, force, &report); +// one path's failure is its own; several are counted, so a run over a list says +// what happened to each and then how the run as a whole went. a single path has +// nothing to count, so its error is simply the command's +fn each(paths: &[PathBuf], mut act: impl FnMut(&Path) -> Result<()>) -> Result<()> { + if let [only] = paths { + return act(only); } let mut failed = 0; for path in paths { - if let Err(e) = link_one(ctx, &repo, path, force, &report) { + if let Err(e) = act(path) { warning!("{e:#}"); failed += 1; } } - if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); + match failed { + 0 => Ok(()), + failed => Err(anyhow!("{failed} of {} paths failed", paths.len())), } - Ok(()) +} + +// move untracked paths out of the repo and symlink them back +pub fn add(ctx: &Ctx, args: &cli::Add) -> Result<()> { + let repo = Repo::discover(ctx, args.store.root())?; + let report = Report::new(&repo); + + each(&args.paths, |path| { + link_one(ctx, &repo, path, args.force, &report) + }) } // 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 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 // cases are left here - let stored = match all { + let stored = match args.all { true => stored_paths(&repo, &repo.store)?, - false => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(), + false => args + .paths + .iter() + .cloned() + .map(|path| (path, Stored::Linked)) + .collect(), }; // only a linked path can be moved back; the store can hold orphans too @@ -69,7 +82,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> n => note!( ctx, "nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`", - if n == 1 { "" } else { "s" }, + plural(n), if n == 1 { "is" } else { "are" } ), } @@ -81,26 +94,11 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> note!( ctx, "leaving {skipped} path{} that the store holds but nothing links to", - if skipped == 1 { "" } else { "s" } + plural(skipped) ); } - if let [path] = linked.as_slice() { - return restore_one(ctx, &repo, path, &report); - } - - let mut failed = 0; - for path in &linked { - if let Err(e) = restore_one(ctx, &repo, path, &report) { - warning!("{e:#}"); - failed += 1; - } - } - - if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", linked.len())); - } - Ok(()) + each(&linked, |path| restore_one(ctx, &repo, path, &report)) } fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { @@ -137,10 +135,10 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<( bail!("{} is in the way; move it aside", aside.display()); } - rename(ctx, &src, &aside)?; + ctx.fs().rename(&src, &aside)?; - if let Err(e) = move_path(ctx, &stored, &src) { - rename(ctx, &aside, &src).with_context(|| { + if let Err(e) = ctx.fs().move_path(&stored, &src) { + ctx.fs().rename(&aside, &src).with_context(|| { format!( "could not put the link at {} back after failing to restore it", src.display() @@ -150,8 +148,8 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<( return Err(e); } - remove_file(ctx, &aside)?; - prune_empty(ctx, stored.parent(), &repo.base); + ctx.fs().remove_file(&aside)?; + ctx.fs().prune_empty(stored.parent(), &repo.base); report.line("restored", &rel); Ok(()) @@ -227,8 +225,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()); @@ -246,12 +244,18 @@ pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> { Stored::Taken => "shadowed", }; - line!("\t{:<11}{}", format!("{verb}:"), rel.display()); + entry(verb, rel); } Ok(()) } +// one line of what the store did with a path, in the column width `status` +// prints too: the two used to set it separately and had to be kept in step +fn entry(verb: &str, path: &Path) { + line!("\t{:<11}{}", format!("{verb}:"), path.display()); +} + struct Report { store: PathBuf, named: Cell, @@ -271,7 +275,7 @@ impl Report { line!("store: {}", self.store.display()); } - line!("\t{:<11}{}", format!("{verb}:"), path.display()); + entry(verb, path); } } @@ -295,7 +299,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - stays_in_store(repo, &rel)?; // before anything is moved in, so the tree it lands in is never briefly // readable by anyone else - ensure_private_parent(ctx, &repo.base, &target)?; + ctx.fs().ensure_private_parent(&repo.base, &target)?; if tracked(ctx, repo, &rel)? { return Err(anyhow!( @@ -363,7 +367,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - return Err(needs_force(&target)); } - place_link(ctx, &src, &target)?; + ctx.fs().place_link(&src, &target)?; report.line("repointed", &rel); Ok(()) } @@ -381,10 +385,10 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - )); } - rename(ctx, &src, &backup)?; + ctx.fs().rename(&src, &backup)?; report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); - place_link(ctx, &src, &target)?; + ctx.fs().place_link(&src, &target)?; report.line("linked", &rel); Ok(()) } @@ -399,7 +403,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - if !force { return Err(needs_force(&target)); } - place_link(ctx, &src, &target)?; + ctx.fs().place_link(&src, &target)?; report.line("linked", &rel); Ok(()) } @@ -416,10 +420,10 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - // but no link placed, the store holds a path nothing points at and the working // tree has lost it altogether, which is the one outcome worse than failing fn move_and_link(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { - move_path(ctx, src, target)?; + ctx.fs().move_path(src, target)?; - if let Err(e) = place_link(ctx, src, target) { - move_path(ctx, target, src).with_context(|| { + if let Err(e) = ctx.fs().place_link(src, target) { + ctx.fs().move_path(target, src).with_context(|| { format!( "could not put {} back after failing to link it to {}", src.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 735cf05..79af0eb 100644 --- a/src/commands/postgres.rs +++ b/src/commands/postgres.rs @@ -10,13 +10,13 @@ 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, Start, - Stop, Up, + Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, }; use crate::ctx::Ctx; -use crate::fsops::{create_private_new, remove_file, rename, suffixed}; -use crate::output::{note, warning}; +use crate::fsops::{create_private_new, suffixed}; +use crate::output::{note, plural, warning}; // unique per run: docker cp will not copy a directory over an existing path, and // quietly leaves whatever was there for pg_restore to read instead @@ -36,6 +36,13 @@ fn partial_path(file: &Path) -> PathBuf { suffixed(file, &format!(".{}.partial", std::process::id())) } +// the database was dropped before the restore was attempted, so a restore that +// failed leaves nothing behind: said the same way wherever it happens, since +// what the user has to do about it does not depend on which tool it was +fn left_empty(tool: &str) -> anyhow::Error { + anyhow::anyhow!("{tool} failed, the database is left empty") +} + // pg_dumpall recreates roles the cluster already has, so this is the expected // complaint rather than a failure fn is_existing_role_error(line: &str) -> bool { @@ -75,17 +82,17 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> note!( ctx, "left {existing} existing role{} alone", - if existing == 1 { "" } else { "s" } + plural(existing) ); } if !out.status.success() { - bail!("psql failed, the database is left empty"); + return Err(left_empty("psql")); } if failed > 0 { bail!( "psql reported {failed} error{}, so the cluster restored only in part", - if failed == 1 { "" } else { "s" } + plural(failed) ); } @@ -99,7 +106,7 @@ fn gzip_kind(ctx: &Ctx, db: &Database, file: &Path) -> Result { wait_until_ready(ctx, db)?; let out = Gunzip - .pipe(&Head::bytes(HEADER_LEN)) + .pipe(&Head::bytes(HEADER_LEN))? // head closes the pipe once it has its bytes, which kills gunzip .allow_early_close() .in_container(&db.container) @@ -136,7 +143,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { } note!(ctx, "stopping all containers"); - Stop { quiet: ctx.quiet }.run(ctx)?; + ctx.compose().stop().run(ctx)?; // everything from here runs with the project down, so an error leaves it // that way and the user has no reason to guess as much @@ -159,11 +166,7 @@ fn local_path_for_docker(file: &Path) -> Result<()> { fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> { note!(ctx, "starting db container"); - Start { - service: &db.service, - quiet: ctx.quiet, - } - .run(ctx)?; + ctx.compose().start(&db.service).run(ctx)?; let remote = remote_dump(); @@ -229,8 +232,8 @@ fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> { restored?; note!(ctx, "restarting containers"); - Stop { quiet: ctx.quiet }.run(ctx)?; - Up { quiet: ctx.quiet }.run(ctx)?; + ctx.compose().stop().run(ctx)?; + ctx.compose().up().run(ctx)?; Ok(()) } @@ -261,14 +264,14 @@ fn restore_dump( .status(ctx)?; if !status.success() { - bail!("{tool} failed, the database is left empty"); + return Err(left_empty(tool)); } return Ok(()); } let restore: Box = match dump { - Dump::Gzip => Box::new(Gunzip.pipe(&*restore)), + Dump::Gzip => Box::new(Gunzip.pipe(&*restore)?), _ => restore, }; @@ -284,7 +287,7 @@ fn restore_dump( .stdin_from(ctx, file)?; if !status.success() { - bail!("{tool} failed, the database is left empty"); + return Err(left_empty(tool)); } Ok(()) @@ -302,10 +305,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"); } @@ -329,7 +335,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { let dumped = if gzip { dumping - .pipe(&Gzip) + .pipe(&Gzip)? .in_container(&db.container) .stream_to(ctx, stdout) } else { @@ -337,19 +343,19 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { }; if let Err(e) = dumped { - let _ = remove_file(ctx, &partial); + let _ = ctx.fs().remove_file(&partial); return Err(e); } - rename(ctx, &partial, file)?; + ctx.fs().rename(&partial, file)?; Ok(()) } // 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 }), } } @@ -369,7 +375,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/commands/postgres/server.rs b/src/commands/postgres/server.rs index 0f92b73..84999ed 100644 --- a/src/commands/postgres/server.rs +++ b/src/commands/postgres/server.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use anyhow::{Result, bail}; use super::shape::Kind; -use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql}; +use crate::cmd::{Cmd, PgIsReady, PgRestore, Psql}; use crate::ctx::Ctx; use crate::project::Project; @@ -26,7 +26,7 @@ impl Database { let service = compose.postgres()?; let (user, name) = compose.postgres_credentials(&service)?; - let listed = Ps::id_of(&service).capture(ctx)?; + let listed = ctx.compose().ps(&service).capture(ctx)?; let mut ids = listed.lines().map(str::trim).filter(|id| !id.is_empty()); let container = match (ids.next(), ids.next()) { diff --git a/src/commands/status.rs b/src/commands/status.rs index 05651e2..992f58d 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -2,7 +2,7 @@ use std::path::Path; use anyhow::Result; -use crate::cmd::{Cmd, Ps}; +use crate::cmd::Cmd; use crate::commands::link; use crate::ctx::Ctx; use crate::output::line; @@ -62,7 +62,7 @@ fn role(ctx: &Ctx, role: &str, detected: Result, project: &Project) { line!("{role}: {service} ({image})"); - match Ps::id_of(&service).capture(ctx) { + match ctx.compose().ps(&service).capture(ctx) { Ok(id) if id.trim().is_empty() => line!("\tcontainer: not running"), Ok(id) => line!("\tcontainer: {}", short(id.trim())), Err(e) => line!("\tcontainer: {e:#}"), diff --git a/src/ctx.rs b/src/ctx.rs index 1099bc6..4714ab5 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,6 +1,22 @@ +use crate::cmd::Compose; +use crate::fsops::Fs; + // how ahab was invoked, handed to everything that runs commands or writes files pub struct Ctx { pub verbose: bool, pub dry_run: bool, pub quiet: bool, } + +impl Ctx { + // the two things these flags actually decide, asked for by name rather than + // read field by field wherever they are needed: a dry run writes nothing, + // and --quiet stops compose narrating + pub fn fs(&self) -> Fs<'_> { + Fs::new(self) + } + + pub fn compose(&self) -> Compose<'_> { + Compose::new(self) + } +} diff --git a/src/fsops.rs b/src/fsops.rs index c97bd63..b4f0e58 100644 --- a/src/fsops.rs +++ b/src/fsops.rs @@ -16,68 +16,155 @@ pub fn suffixed(path: &Path, suffix: &str) -> PathBuf { PathBuf::from(out) } -// every write to the working tree goes through this module, so a dry run is held -// back in one place rather than at each call site -pub fn move_path(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - ensure_parent(ctx, target)?; - - // rename cannot cross filesystems, and the store often is another one - match fs::rename(src, target) { - Ok(()) => Ok(()), - Err(rename_err) => match copy_recursive(src, target) { - Ok(()) => remove_recursive(ctx, src), - Err(copy_err) => Err(copy_err).with_context(|| format!("after {rename_err}")), - }, - } +// the working tree, as this invocation is allowed to touch it. every write goes +// through here, so what a dry run means is settled in one place rather than at +// the top of each operation, where the next one added could simply not ask +pub struct Fs<'a> { + ctx: &'a Ctx, } -pub fn rename(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); +impl<'a> Fs<'a> { + pub(crate) fn new(ctx: &'a Ctx) -> Self { + Self { ctx } } - Ok(fs::rename(src, target)?) -} - -pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - // the parent can be missing when the store holds a path the repository does - // not have any more, and symlink would only report the bare ENOENT - ensure_parent(ctx, link_path)?; - - // symlink under a temp name and rename over the path: the rename is atomic - let tmp = suffixed(link_path, ".ahab-tmp"); - - // only ahab's own leftover is cleared away: anything else here belongs to - // the project, and silently unlinking it would lose it - match fs::symlink_metadata(&tmp) { - Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?, - Ok(_) => { - return Err(anyhow!( - "{} is in the way and is not a symlink ahab left behind; \ - move it aside", - tmp.display() - )); + // a dry run writes nothing and reports that nothing went wrong, which for + // every operation here is its empty answer + fn unless_planned(&self, act: impl FnOnce() -> Result) -> Result { + match self.ctx.dry_run { + true => Ok(T::default()), + false => act(), } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e.into()), } - symlink(target, &tmp)?; - Ok(fs::rename(&tmp, link_path)?) + pub fn move_path(&self, src: &Path, target: &Path) -> Result<()> { + self.unless_planned(|| { + ensure_parent(target)?; + + // rename cannot cross filesystems, and the store often is another one + match fs::rename(src, target) { + Ok(()) => Ok(()), + Err(rename_err) => match copy_recursive(src, target) { + Ok(()) => remove_recursive(src), + Err(copy_err) => Err(copy_err).with_context(|| format!("after {rename_err}")), + }, + } + }) + } + + pub fn rename(&self, src: &Path, target: &Path) -> Result<()> { + self.unless_planned(|| Ok(fs::rename(src, target)?)) + } + + pub fn place_link(&self, link_path: &Path, target: &Path) -> Result<()> { + self.unless_planned(|| { + // the parent can be missing when the store holds a path the + // repository does not have any more, and symlink would only report + // the bare ENOENT + ensure_parent(link_path)?; + + // symlink under a temp name and rename over the path: the rename is + // atomic + let tmp = suffixed(link_path, ".ahab-tmp"); + + // only ahab's own leftover is cleared away: anything else here + // belongs to the project, and silently unlinking it would lose it + match fs::symlink_metadata(&tmp) { + Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?, + Ok(_) => { + return Err(anyhow!( + "{} is in the way and is not a symlink ahab left behind; \ + move it aside", + tmp.display() + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + + symlink(target, &tmp)?; + Ok(fs::rename(&tmp, link_path)?) + }) + } + + pub fn remove_file(&self, path: &Path) -> Result<()> { + self.unless_planned(|| Ok(fs::remove_file(path)?)) + } + + pub fn create_dir(&self, path: &Path) -> Result<()> { + self.unless_planned(|| Ok(fs::create_dir(path)?)) + } + + // created if it is missing, left as it is otherwise, which is what a caller + // making an empty __init__.py wants + pub fn touch(&self, path: &Path) -> Result<()> { + self.unless_planned(|| { + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path)?; + + Ok(()) + }) + } + + // fails rather than writing over a file that is already there + pub fn write_new(&self, path: &Path, contents: &[u8]) -> Result<()> { + self.unless_planned(|| { + let mut file = fs::File::create_new(path)?; + file.write_all(contents)?; + + Ok(()) + }) + } + + // the store holds what should not be reachable from the repository, so the + // directories it is kept in are the owner's alone. only the tree at or + // below `base` is created here; what is above it is the user's own business + pub fn ensure_private_parent(&self, base: &Path, target: &Path) -> Result<()> { + self.unless_planned(|| { + let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else { + return Ok(()); + }; + + // a recursive create applies the mode to every directory it makes + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent) + .with_context(|| format!("creating {}", parent.display())) + }) + } + + // a restored path can leave the store holding nothing but empty directories + pub fn prune_empty(&self, dir: Option<&Path>, stop: &Path) { + let pruned: Result<()> = self.unless_planned(|| { + let mut dir = dir; + + while let Some(path) = dir { + if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() { + break; + } + + dir = path.parent(); + } + + Ok(()) + }); + + // an empty directory left behind is untidy, not a failure + let _ = pruned; + } } // 0600 and only where nothing is yet. a dump is the whole database, and // pg_dumpall's is every role's password hash, so it is not the owner's to share // by default; create_new also refuses to follow a symlink planted at the path, -// which File::create would open and truncate +// which File::create would open and truncate. +// +// not on Fs: a dry run has no file to hand back, so its one caller asks for +// this only once it knows it is writing for real pub fn create_private_new(path: &Path) -> Result { std::fs::OpenOptions::new() .write(true) @@ -87,40 +174,15 @@ pub fn create_private_new(path: &Path) -> Result { .with_context(|| format!("creating {}", path.display())) } -// the store holds what should not be reachable from the repository, so the -// directories it is kept in are the owner's alone. only the tree at or below -// `base` is created here; what is above it is the user's own business -pub fn ensure_private_parent(ctx: &Ctx, base: &Path, target: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); +fn ensure_parent(target: &Path) -> Result<()> { + match target.parent() { + Some(parent) if !parent.as_os_str().is_empty() => Ok(fs::create_dir_all(parent)?), + _ => Ok(()), } - - let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else { - return Ok(()); - }; - - // a recursive create applies the mode to every directory it makes - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(parent) - .with_context(|| format!("creating {}", parent.display())) -} - -pub fn remove_file(ctx: &Ctx, path: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - Ok(fs::remove_file(path)?) } // whatever is there, file, directory or symlink -fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - +fn remove_recursive(path: &Path) -> Result<()> { if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path)?; } else { @@ -130,70 +192,6 @@ fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> { Ok(()) } -fn ensure_parent(ctx: &Ctx, target: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - match target.parent() { - Some(parent) if !parent.as_os_str().is_empty() => Ok(fs::create_dir_all(parent)?), - _ => Ok(()), - } -} - -pub fn create_dir(ctx: &Ctx, path: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - Ok(fs::create_dir(path)?) -} - -// created if it is missing, left as it is otherwise, which is what a caller -// making an empty __init__.py wants -pub fn touch(ctx: &Ctx, path: &Path) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .open(path)?; - - Ok(()) -} - -// fails rather than writing over a file that is already there -pub fn write_new(ctx: &Ctx, path: &Path, contents: &[u8]) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - let mut file = fs::File::create_new(path)?; - file.write_all(contents)?; - - Ok(()) -} - -// a restored path can leave the store holding nothing but empty directories -pub fn prune_empty(ctx: &Ctx, dir: Option<&Path>, stop: &Path) { - if ctx.dry_run { - return; - } - - let mut dir = dir; - - while let Some(path) = dir { - if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() { - return; - } - - dir = path.parent(); - } -} - fn copy_recursive(src: &Path, target: &Path) -> Result<()> { let meta = fs::symlink_metadata(src)?; 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())?; diff --git a/src/output.rs b/src/output.rs index 14086f7..237cdec 100644 --- a/src/output.rs +++ b/src/output.rs @@ -107,3 +107,11 @@ pub(crate) fn delivered() -> Result<()> { } pub(crate) use {line, note, text, warning}; + +// the `s` on a counted noun, so a message reads right for one and for many +pub(crate) fn plural(n: usize) -> &'static str { + match n { + 1 => "", + _ => "s", + } +} diff --git a/src/project.rs b/src/project.rs index 124c936..b32efdb 100644 --- a/src/project.rs +++ b/src/project.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result, anyhow}; use serde_json::Value; -use crate::cmd::{Cmd, Config}; +use crate::cmd::Cmd; use crate::ctx::Ctx; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; @@ -13,7 +13,7 @@ pub struct Project { impl Project { pub fn resolve(ctx: &Ctx) -> Result { - let json = Config.capture(ctx)?; + let json = ctx.compose().config().capture(ctx)?; let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?; let services = config .get("services") diff --git a/tests/link_store.rs b/tests/link_store.rs new file mode 100644 index 0000000..95c36b2 --- /dev/null +++ b/tests/link_store.rs @@ -0,0 +1,262 @@ +// what the store is for: a path a sandbox should not read living somewhere it +// cannot. every case here is one that was once wrong. +mod support; + +use std::fs; +use support::Case; + +#[test] +fn a_path_moves_into_the_store_and_reads_back_through_the_link() { + let case = Case::new("roundtrip"); + case.write(".env", b"SECRET=1\n"); + case.write("secrets/token", b"tok\n"); + case.gitignore(".env\nsecrets/\n"); + + case.ahab(&["link", "add", ".env", "secrets"]).ok(); + + assert!(case.is_symlink(".env")); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n"); + assert_eq!(fs::read(case.path("secrets/token")).unwrap(), b"tok\n"); + + case.ahab(&["link", "check", "--exit-code"]).ok(); + case.ahab(&["link", "restore", "--all"]).ok(); + + assert!(!case.is_symlink(".env")); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n"); +} + +#[test] +fn add_refuses_a_symlink_that_already_leads_out_of_the_repository() { + let case = Case::new("launder"); + fs::write(case.outside.join("key"), b"KEY\n").unwrap(); + case.link(&case.path("cache"), &case.outside); + + // moving the link would move the pointer and leave the contents there, + // and check would then see a link into the store and call it clean + case.ahab(&["link", "add", "cache"]) + .failed() + .says("outside the repository"); + + case.ahab(&["link", "check", "--exit-code"]).code_is(4); +} + +#[test] +fn check_reports_a_store_entry_that_leads_back_out() { + let case = Case::new("poisoned"); + fs::create_dir_all(&case.store).unwrap(); + fs::write(case.outside.join("key"), b"KEY\n").unwrap(); + // the state an older ahab left: the store holds a way back out + case.link(&case.store.join("cache"), &case.outside); + case.link(&case.path("cache"), &case.store.join("cache")); + + case.ahab(&["link", "check", "--exit-code"]).code_is(4); +} + +#[test] +fn check_reports_a_tracked_symlink_leading_out() { + let case = Case::new("tracked-link"); + fs::create_dir_all(case.outside.join("aws")).unwrap(); + case.link(&case.path("awsdir"), &case.outside.join("aws")); + case.git(&["add", "-f", "awsdir"]); + case.git(&["commit", "-qm", "commit a symlink out"]); + + // git tracks symlinks, so this is in no untracked or ignored listing + case.ahab(&["link", "check", "--exit-code"]).code_is(4); + case.ahab(&["link", "check", "--porcelain"]).says("T>"); + // and add cannot fix it, so saying so is all check can do + case.ahab(&["link", "add", "awsdir"]) + .failed() + .says("tracked by git"); +} + +#[test] +fn a_stored_symlink_is_one_entry_rather_than_a_tree_to_walk() { + let case = Case::new("walk-out"); + fs::create_dir_all(case.outside.join("private")).unwrap(); + fs::write(case.outside.join("private/diary"), b"x\n").unwrap(); + fs::write(case.outside.join(".netrc"), b"x\n").unwrap(); + fs::create_dir_all(&case.store).unwrap(); + case.link(&case.store.join("cache"), &case.outside); + + let run = case.ahab(&["link", "list"]); + run.ok().says("cache"); + // is_dir() would follow the link and enumerate what is behind it + run.silent_about(".netrc"); + run.silent_about("diary"); +} + +#[test] +fn a_store_inside_the_repository_is_refused_however_it_is_spelled() { + let case = Case::new("store-inside"); + case.write(".env", b"SECRET=1\n"); + case.gitignore(".env\n"); + + // named directly + case.ahab(&["link", "add", "--store", "./within", ".env"]) + .failed() + .says("inside the repository"); + + // and reached through a symlink, which a prefix test does not catch + case.mkdir("within"); + let sneaky = case.repo.parent().unwrap().join("sneaky"); + case.link(&sneaky, &case.path("within")); + let run = case.ahab(&[ + "link".as_ref(), + "add".as_ref(), + "--store".as_ref(), + sneaky.as_os_str(), + ".env".as_ref(), + ]); + run.failed().says("inside the repository"); + assert!(!case.is_symlink(".env")); +} + +#[test] +fn a_relative_store_root_still_resolves_from_anywhere() { + let case = Case::new("relative-store"); + case.write("sub/.env", b"SECRET=1\n"); + case.gitignore("sub/.env\n"); + + case.ahab(&["link", "add", "--store", "../store", "sub/.env"]) + .ok(); + + // the target is written into the symlink, so a relative one would resolve + // from the link's own directory rather than the working one + assert!(case.is_symlink("sub/.env")); + assert_eq!(fs::read(case.path("sub/.env")).unwrap(), b"SECRET=1\n"); +} + +#[test] +fn the_store_directories_are_the_owners_alone() { + let case = Case::new("modes"); + case.write("deep/nested/.env", b"SECRET=1\n"); + case.gitignore("deep\n"); + + case.ahab(&["link", "add", "deep/nested/.env"]).ok(); + + for dir in [ + case.store.as_path(), + &case.store.join("deep"), + &case.store.join("deep/nested"), + ] { + assert_eq!(case.mode(dir), 0o700, "{}", dir.display()); + } +} + +#[test] +fn a_failed_link_puts_the_payload_back() { + let case = Case::new("rollback"); + case.write("x.env", b"SECRET=1\n"); + // ahab's own temp name, but a real file: it must not be removed, and the + // payload must not be left in the store with nothing pointing at it + case.write("x.env.ahab-tmp", b"THE PROJECT OWNS THIS\n"); + case.gitignore("x.env\n"); + + case.ahab(&["link", "add", "x.env"]).failed(); + + assert!(!case.is_symlink("x.env")); + assert_eq!(fs::read(case.path("x.env")).unwrap(), b"SECRET=1\n"); + assert_eq!( + fs::read(case.path("x.env.ahab-tmp")).unwrap(), + b"THE PROJECT OWNS THIS\n" + ); + assert!(!case.store.join("x.env").exists()); +} + +#[test] +fn a_directory_of_symlinks_out_keeps_its_destinations() { + let case = Case::new("collapse"); + fs::write(case.outside.join("a"), b"A\n").unwrap(); + case.link(&case.path("bundle/one"), &case.outside.join("a")); + + // collapsing to `bundle/` would drop both the destination and the code + let run = case.ahab(&["link", "check", "--porcelain"]); + run.code_is(0).says("?>").says("bundle/one"); +} + +#[test] +fn a_filename_that_is_not_utf_8_is_reported_and_moved_as_itself() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let case = Case::new("latin1"); + let name = OsStr::from_bytes(b"caf\xe9.env"); + fs::write(case.repo.join(name), b"SECRET=1\n").unwrap(); + + // the whole listing used to fail on the one entry + let run = case.ahab(&["link", "check", "-z"]); + run.code_is(0); + assert!( + run.stdout.windows(4).any(|w| w == b"caf\xe9"), + "-z must write the bytes the name actually has" + ); + + case.gitignore("caf\u{e9}.env\n"); + case.ahab(&["link".as_ref(), "add".as_ref(), name]).ok(); + assert!( + fs::symlink_metadata(case.repo.join(name)) + .unwrap() + .is_symlink() + ); +} + +#[test] +fn a_tracked_filename_that_is_not_utf_8_is_still_refused() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let case = Case::new("latin1-tracked"); + let name = OsStr::from_bytes(b"caf\xe9.env"); + fs::write(case.repo.join(name), b"TRACKED\n").unwrap(); + case.git(&["add".as_ref(), "-f".as_ref(), name]); + case.git(&["commit", "-qm", "track a latin-1 name"]); + + // asked about lossily, git answers about a path nothing has and says + // "not tracked", and a tracked file leaves the working tree + case.ahab(&["link".as_ref(), "add".as_ref(), name]) + .failed() + .says("tracked by git"); + + assert!( + !fs::symlink_metadata(case.repo.join(name)) + .unwrap() + .is_symlink() + ); +} + +#[test] +fn a_pathspec_is_asked_about_as_a_name_not_a_pattern() { + let case = Case::new("pathspec"); + // git reads pathspec magic after `--` too, so this used to have git list + // every tracked file *except* the named one, which read as "it is tracked" + case.write(":!untracked.env", b"SECRET=1\n"); + case.gitignore(":!untracked.env\n"); + + case.ahab(&["link", "add", ":!untracked.env"]).ok(); + assert!(case.is_symlink(":!untracked.env")); +} + +#[test] +fn distinct_remotes_do_not_share_one_store_directory() { + let case = Case::new("remotes"); + + case.git(&[ + "remote", + "set-url", + "origin", + "https://git.example.org/a/my_api", + ]); + let plain = case.ahab(&["link", "list"]).out(); + + case.git(&[ + "remote", + "set-url", + "origin", + "https://git.example.org/a/my~api", + ]); + let awkward = case.ahab(&["link", "list"]).out(); + + assert_ne!(plain, awkward); + // the ordinary remote keeps the path it already had + assert!(plain.contains("/a/my_api"), "{plain}"); +} diff --git a/tests/output_io.rs b/tests/output_io.rs new file mode 100644 index 0000000..f0f43c4 --- /dev/null +++ b/tests/output_io.rs @@ -0,0 +1,99 @@ +// what a command reports and how it exits, including when the write fails. +mod support; + +use std::fs; +use support::Case; + +#[test] +fn findings_are_reported_through_the_exit_code() { + let case = Case::new("findings"); + case.write("loose.txt", b"x\n"); + + case.ahab(&["link", "check"]).ok(); + case.ahab(&["link", "check", "--exit-code"]).code_is(4); +} + +#[test] +fn an_argument_error_exits_two() { + let case = Case::new("usage"); + + case.ahab(&["link", "restore"]).code_is(2); + case.ahab(&["link", "restore", "--all", "some/path"]) + .code_is(2); + case.ahab(&["postgres", "dump", "-F", "directory", "-z", "d"]) + .code_is(2); + case.ahab(&["nonsense"]).code_is(2); +} + +#[test] +fn a_write_that_cannot_be_delivered_is_a_failure_rather_than_a_success() { + let case = Case::new("devfull"); + for n in 0..200 { + case.write(&format!("file-{n}.txt"), b"x\n"); + } + + // /dev/full accepts the open and fails the write. exiting 0 there would + // report a listing that was never delivered + let Ok(full) = fs::File::create("/dev/full") else { + eprintln!("skipped: this system has no /dev/full to fail a write against"); + return; + }; + + let out = std::process::Command::new(env!("CARGO_BIN_EXE_ahab")) + .current_dir(&case.repo) + .env("XDG_DATA_HOME", case.repo.join("../xdg")) + .args(["link", "check", "--exit-code", "--porcelain"]) + .stdout(full) + .output() + .expect("running ahab"); + + assert_eq!(out.status.code(), Some(1), "{:?}", out.status); + assert!( + String::from_utf8_lossy(&out.stderr).contains("writing to stdout failed"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn every_named_path_is_moved_even_when_the_reader_leaves() { + let case = Case::new("partial"); + for name in ["a.env", "b.env", "c.env"] { + case.write(name, b"x\n"); + } + case.gitignore("a.env\nb.env\nc.env\n"); + + // a closed stdout must stop the writing, not the moving: the command had + // planned to move all three and reported success for doing so + let run = case.ahab(&["link", "add", "a.env", "b.env", "c.env"]); + run.ok(); + + for name in ["a.env", "b.env", "c.env"] { + assert!(case.is_symlink(name), "{name} was left behind"); + } +} + +#[test] +fn quiet_keeps_the_reason_a_path_failed() { + let case = Case::new("quiet"); + case.write("ok.env", b"x\n"); + case.gitignore("ok.env\n"); + + let run = case.ahab(&["-q", "link", "add", "ok.env", "missing.env"]); + run.failed() + .says("1 of 2 paths failed") + // the line saying *which* and *why* is a result, not progress + .says("missing.env"); +} + +#[test] +fn a_dry_run_changes_nothing_on_disk() { + let case = Case::new("dryrun"); + case.write(".env", b"SECRET=1\n"); + case.gitignore(".env\n"); + + case.ahab(&["--dry-run", "link", "add", ".env"]).ok(); + + assert!(!case.is_symlink(".env")); + assert!(!case.store.join(".env").exists()); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..5a66cea --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,207 @@ +// enough scaffolding to run ahab against a throwaway repository, hand-rolled +// rather than pulled in: the crate has no dependencies it does not need, and a +// dev-dependency is still something to trust and keep current. + +// each integration test file compiles this module separately, so a helper only +// one of them needs looks unused to the others +#![allow(dead_code)] +use std::ffi::OsStr; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::{env, fs}; + +// unique per case even with the suite running in parallel +static NEXT: AtomicU32 = AtomicU32::new(0); + +pub struct Case { + root: PathBuf, + pub repo: PathBuf, + pub store: PathBuf, + pub outside: PathBuf, +} + +impl Case { + // a repository with an origin remote, a store of its own, and one commit + pub fn new(label: &str) -> Self { + let root = env::temp_dir().join(format!( + "ahab-{label}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + + let case = Self { + repo: root.join("repo"), + store: root + .join("xdg/ahab/git.example.org/acme/proj") + .to_path_buf(), + outside: root.join("outside"), + root, + }; + + for dir in [&case.repo, &case.outside] { + fs::create_dir_all(dir).expect("creating the case directories"); + } + + case.git(&["init", "-q", "."]); + case.git(&["config", "user.email", "test@example.org"]); + case.git(&["config", "user.name", "test"]); + case.git(&["config", "commit.gpgsign", "false"]); + case.git(&[ + "remote", + "add", + "origin", + "https://git.example.org/acme/proj.git", + ]); + case.write("tracked.txt", b"tracked\n"); + case.git(&["add", "tracked.txt"]); + case.git(&["commit", "-qm", "init"]); + + case + } + + pub fn git>(&self, args: &[S]) -> Output { + let out = Command::new("git") + .current_dir(&self.repo) + .args(args) + .output() + .expect("running git"); + + assert!( + out.status.success(), + "git {:?} failed: {}", + args.iter() + .map(|a| a.as_ref().to_string_lossy()) + .collect::>(), + String::from_utf8_lossy(&out.stderr) + ); + out + } + + // ahab, in the repository, with a store nothing else shares + pub fn ahab>(&self, args: &[S]) -> Run { + let out = Command::new(env!("CARGO_BIN_EXE_ahab")) + .current_dir(&self.repo) + .env("XDG_DATA_HOME", self.root.join("xdg")) + // the git commands ahab runs must not read the developer's own config + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .args(args) + .output() + .expect("running ahab"); + + Run { + code: out.status.code(), + stdout: out.stdout, + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } + } + + pub fn path(&self, rel: &str) -> PathBuf { + self.repo.join(rel) + } + + pub fn write(&self, rel: &str, contents: &[u8]) { + let path = self.path(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("creating a parent directory"); + } + fs::write(path, contents).expect("writing a file"); + } + + pub fn mkdir(&self, rel: &str) { + fs::create_dir_all(self.path(rel)).expect("creating a directory"); + } + + pub fn link(&self, at: &Path, to: &Path) { + if let Some(parent) = at.parent() { + fs::create_dir_all(parent).expect("creating a parent directory"); + } + std::os::unix::fs::symlink(to, at).expect("creating a symlink"); + } + + pub fn gitignore(&self, lines: &str) { + self.write(".gitignore", lines.as_bytes()); + self.git(&["add", ".gitignore"]); + self.git(&["commit", "-qm", "ignore"]); + } + + pub fn is_symlink(&self, rel: &str) -> bool { + fs::symlink_metadata(self.path(rel)).is_ok_and(|meta| meta.is_symlink()) + } + + pub fn mode(&self, path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + + fs::symlink_metadata(path) + .expect("reading a mode") + .permissions() + .mode() + & 0o777 + } +} + +impl Drop for Case { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +pub struct Run { + pub code: Option, + pub stdout: Vec, + pub stderr: String, +} + +impl Run { + pub fn out(&self) -> String { + String::from_utf8_lossy(&self.stdout).into_owned() + } + + pub fn ok(&self) -> &Self { + assert_eq!(self.code, Some(0), "{}", self.report()); + self + } + + pub fn failed(&self) -> &Self { + assert_ne!(self.code, Some(0), "{}", self.report()); + self + } + + pub fn code_is(&self, want: i32) -> &Self { + assert_eq!(self.code, Some(want), "{}", self.report()); + self + } + + pub fn says(&self, needle: &str) -> &Self { + assert!( + self.out().contains(needle) || self.stderr.contains(needle), + "expected {needle:?}\n{}", + self.report() + ); + self + } + + pub fn silent_about(&self, needle: &str) -> &Self { + assert!( + !self.out().contains(needle) && !self.stderr.contains(needle), + "did not expect {needle:?}\n{}", + self.report() + ); + self + } + + fn report(&self) -> String { + let mut out = String::new(); + let _ = write!( + out, + "exit: {:?}\n--- stdout\n{}--- stderr\n{}", + self.code, + self.out(), + self.stderr + ); + out + } +}