diff --git a/Cargo.toml b/Cargo.toml index 3663553..d6e03fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,18 @@ build = "build.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = { version = "4.6.6", features = ["derive"] } +clap = { version = "4.6.6", features = ["derive", "env"] } clap_complete = "4.6.9" anyhow = "1.0.104" serde_json = "1.0.145" [build-dependencies] -clap = { version = "4.6.6", features = ["derive"] } +clap = { version = "4.6.6", features = ["derive", "env"] } clap_complete = "4.6.9" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" diff --git a/README.md b/README.md index c9a693a..feea069 100644 --- a/README.md +++ b/README.md @@ -134,4 +134,5 @@ from the git `origin` remote. Currently `ahab` respects the following environment variables. -- `AHAB_LINK_ROOT`: root of the out-of-repo store `ahab link` moves paths into - defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab` +- `AHAB_LINK_ROOT`: where `ahab link` keeps its store - the same as `--store`, + which takes precedence, and defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab` diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 095aa17..20d9c73 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -1,10 +1,12 @@ use super::{Django, Link, Postgres}; +use clap::builder::styling::{AnsiColor, Effects, Styles}; use clap::{Parser, Subcommand}; use clap_complete::Shell; /// A program for interacting with various dockerized applications #[derive(Parser, Debug)] -#[command(author, version, about, long_about=None)] +#[command(version, about, long_about = None)] +#[command(styles = help_styles())] pub struct Ahab { #[command(subcommand)] pub command: Commands, @@ -43,3 +45,23 @@ pub enum Commands { shell: Shell, }, } + +// clap styles headers bold by default but adds no colour +fn help_styles() -> Styles { + Styles::styled() + .header(AnsiColor::Green.on_default() | Effects::BOLD) + .usage(AnsiColor::Green.on_default() | Effects::BOLD) + .literal(AnsiColor::Cyan.on_default() | Effects::BOLD) + .placeholder(AnsiColor::Cyan.on_default()) +} + +#[cfg(test)] +mod tests { + use super::Ahab; + use clap::CommandFactory; + + #[test] + fn the_command_is_well_formed() { + Ahab::command().debug_assert(); + } +} diff --git a/src/cli/link.rs b/src/cli/link.rs index c39d511..b54be85 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::Subcommand; +use clap::{Args, Subcommand}; #[derive(Subcommand, Debug)] pub enum Link { @@ -13,6 +13,9 @@ pub enum Link { /// Link to a store path that already exists #[arg(long)] force: bool, + + #[command(flatten)] + store: Store, }, /// Move paths in the store back into the repository @@ -22,6 +25,9 @@ pub enum Link { /// Restore every path this repository has in the store #[arg(long)] all: bool, + + #[command(flatten)] + store: Store, }, /// List untracked paths a sandbox would still see @@ -40,5 +46,17 @@ pub enum Link { /// Terminate porcelain entries with NUL #[arg(short = 'z')] null: bool, + + #[command(flatten)] + store: Store, }, } + +#[derive(Args, Debug)] +pub struct Store { + /// Where the out-of-repo store lives + /// + /// Defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab`. + #[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")] + pub root: Option, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 65361e1..1957def 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -5,5 +5,5 @@ mod postgres; pub use ahab::{Ahab, Commands}; pub use django::Django; -pub use link::Link; +pub use link::{Link, Store}; pub use postgres::{Format, Postgres}; diff --git a/src/command_builder.rs b/src/command_builder.rs index 3eaf0c9..96fe2c4 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, bail}; use std::{ fmt::Display, + os::unix::process::CommandExt, process::{Child, Command, ExitStatus, Stdio}, sync::OnceLock, }; @@ -83,6 +84,19 @@ impl CommandBuilder { check(&shown, status) } + // replaces this process, so the command's exit code and signals become ours + pub fn exec_replace(self) -> Result<()> { + if options().dry_run { + eprintln!("would run `{self}`"); + return Ok(()); + } + + let shown = self.to_string(); + let error = self.build()?.exec(); + + Err(error).with_context(|| format!("running `{shown}`")) + } + pub fn spawn(self) -> Result { Ok(self.build()?.spawn()?) } diff --git a/src/main.rs b/src/main.rs index 892bdd9..7ec9aac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ +use std::process::ExitCode; + use ahab::{cli, command_builder, scripts}; use anyhow::Result; use clap::Parser; -fn main() -> Result<()> { +fn main() -> ExitCode { let args = cli::Ahab::parse(); command_builder::set_options(command_builder::Options { @@ -11,33 +13,67 @@ fn main() -> Result<()> { dry_run: args.dry_run, }); - match args.command { - cli::Commands::Django { command } => match command { - cli::Django::Bash => scripts::django::bash(), - cli::Django::Run { rest } => scripts::django::run(&rest), - cli::Django::MakeCommand { app, name } => scripts::django::make_command(&app, &name), - cli::Django::Makemigrations => scripts::django::makemigrations(), - cli::Django::Manage { rest } => scripts::django::manage(&rest), - cli::Django::Migrate { rest } => scripts::django::migrate(&rest), - cli::Django::Shell => scripts::django::shell(), - cli::Django::Test => scripts::django::test(), - }, - cli::Commands::Postgres { command } => match command { - cli::Postgres::Import { path } => scripts::postgres::import(&path), - cli::Postgres::Dump { path, format, gzip } => { - scripts::postgres::dump(&path, format, gzip) - } - }, + match run(args.command) { + Ok(code) => code, + Err(e) => { + eprintln!("Error: {e:#}"); + ExitCode::FAILURE + } + } +} + +fn run(command: cli::Commands) -> Result { + let done = ExitCode::SUCCESS; + + match command { + cli::Commands::Django { command } => { + match command { + cli::Django::Bash => scripts::django::bash(), + cli::Django::Run { rest } => scripts::django::run(&rest), + cli::Django::MakeCommand { app, name } => { + scripts::django::make_command(&app, &name) + } + cli::Django::Makemigrations => scripts::django::makemigrations(), + cli::Django::Manage { rest } => scripts::django::manage(&rest), + cli::Django::Migrate { rest } => scripts::django::migrate(&rest), + cli::Django::Shell => scripts::django::shell(), + cli::Django::Test => scripts::django::test(), + }?; + + Ok(done) + } + cli::Commands::Postgres { command } => { + match command { + cli::Postgres::Import { path } => scripts::postgres::import(&path), + cli::Postgres::Dump { path, format, gzip } => { + scripts::postgres::dump(&path, format, gzip) + } + }?; + + Ok(done) + } cli::Commands::Link { command } => match command { - cli::Link::Add { paths, force } => scripts::link::add(&paths, force), - cli::Link::Restore { paths, all } => scripts::link::restore(&paths, all), + cli::Link::Add { + paths, + force, + store, + } => scripts::link::add(&paths, force, store.root.as_deref()).map(|()| done), + cli::Link::Restore { paths, all, store } => { + scripts::link::restore(&paths, all, store.root.as_deref()).map(|()| done) + } + // the one command with something to say through its exit code cli::Link::Check { paths, porcelain, null, exit_code, - } => scripts::link::check(&paths, porcelain, null, exit_code), + store, + } => scripts::link::check(&paths, porcelain, null, exit_code, store.root.as_deref()), }, - cli::Commands::Completions { shell } => scripts::completions::completions(shell), + cli::Commands::Completions { shell } => { + scripts::completions::completions(shell)?; + + Ok(done) + } } } diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index 763f53b..c80a1d8 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -15,7 +15,7 @@ pub fn run(service: &str, rest: &[String]) -> Result<()> { .args("run --rm") .arg(service) .args(rest) - .exec() + .exec_replace() } pub fn exec(service: &str, rest: &[String]) -> Result<()> { diff --git a/src/scripts/link.rs b/src/scripts/link.rs index 504da76..dad1596 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -2,11 +2,10 @@ use std::cell::Cell; use std::env; use std::ffi::OsString; use std::fs; -use std::io::{self, Write}; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; -use std::process; use std::process::Command; +use std::process::ExitCode; use anyhow::{Context, Result, anyhow, bail}; @@ -14,8 +13,8 @@ const BACKUP_SUFFIX: &str = ".ahab-bak"; const LOCAL_NAMESPACE: &str = "_local"; // move untracked paths out of the repo and symlink them back -pub fn add(paths: &[PathBuf], force: bool) -> Result<()> { - let repo = Repo::discover()?; +pub fn add(paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(store)?; let report = Report::new(&repo); if let [path] = paths { @@ -37,8 +36,8 @@ pub fn add(paths: &[PathBuf], force: bool) -> Result<()> { } // move paths in the store back into the repo, the inverse of add -pub fn restore(paths: &[PathBuf], all: bool) -> Result<()> { - let repo = Repo::discover()?; +pub fn restore(paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(store)?; let report = Report::new(&repo); let paths = match (all, paths) { @@ -179,8 +178,14 @@ fn warn(msg: impl std::fmt::Display) { } // list untracked paths not in the store, i.e. what a sandbox can still read -pub fn check(paths: &[PathBuf], porcelain: bool, null: bool, exit_code: bool) -> Result<()> { - let repo = Repo::discover()?; +pub fn check( + paths: &[PathBuf], + porcelain: bool, + null: bool, + exit_code: bool, + store: Option<&Path>, +) -> Result { + let repo = Repo::discover(store)?; let pathspecs = relative_pathspecs(&repo, paths)?; let mut exposed = Vec::new(); @@ -208,11 +213,10 @@ pub fn check(paths: &[PathBuf], porcelain: bool, null: bool, exit_code: bool) -> // git's --exit-code convention: nothing to report is 0, anything is 1 if exit_code && !exposed.is_empty() { - io::stdout().flush().context("writing the listing")?; - process::exit(1); + return Ok(ExitCode::FAILURE); } - Ok(()) + Ok(ExitCode::SUCCESS) } fn print_porcelain(exposed: &[Exposed], null: bool) { @@ -424,10 +428,17 @@ struct Repo { } impl Repo { - fn discover() -> Result { + fn discover(store: Option<&Path>) -> Result { let root = git_root()?; - let store = store_root()?.join(repo_components(&root)?); - Ok(Self { root, store }) + let store = match store { + Some(store) => store.to_path_buf(), + None => store_root()?, + }; + + Ok(Self { + root: root.clone(), + store: store.join(repo_components(&root)?), + }) } fn relative(&self, src: &Path) -> Result { @@ -679,18 +690,16 @@ fn sanitize(s: &str) -> String { } fn store_root() -> Result { - if let Some(root) = non_empty_var("AHAB_LINK_ROOT") { - return Ok(PathBuf::from(root)); - } if let Some(xdg) = non_empty_var("XDG_DATA_HOME") { return Ok(PathBuf::from(xdg).join("ahab")); } let home = non_empty_var("HOME") - .ok_or_else(|| anyhow!("none of AHAB_LINK_ROOT, XDG_DATA_HOME or HOME is set"))?; + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?; + Ok(PathBuf::from(home).join(".local/share/ahab")) } - fn non_empty_var(name: &str) -> Option { env::var_os(name).filter(|v| !v.is_empty()) }