merge: cli modernization

This commit is contained in:
2026-09-08 12:01:34 +02:00
9 changed files with 155 additions and 48 deletions

View File

@@ -13,11 +13,18 @@ build = "build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive", "env"] }
clap_complete = "4.6.9" clap_complete = "4.6.9"
anyhow = "1.0.104" anyhow = "1.0.104"
serde_json = "1.0.145" serde_json = "1.0.145"
[build-dependencies] [build-dependencies]
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive", "env"] }
clap_complete = "4.6.9" clap_complete = "4.6.9"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"

View File

@@ -134,4 +134,5 @@ from the git `origin` remote.
Currently `ahab` respects the following environment variables. 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`

View File

@@ -1,10 +1,12 @@
use super::{Django, Link, Postgres}; use super::{Django, Link, Postgres};
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use clap_complete::Shell; use clap_complete::Shell;
/// A program for interacting with various dockerized applications /// A program for interacting with various dockerized applications
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about=None)] #[command(version, about, long_about = None)]
#[command(styles = help_styles())]
pub struct Ahab { pub struct Ahab {
#[command(subcommand)] #[command(subcommand)]
pub command: Commands, pub command: Commands,
@@ -43,3 +45,23 @@ pub enum Commands {
shell: Shell, 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();
}
}

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use clap::Subcommand; use clap::{Args, Subcommand};
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum Link { pub enum Link {
@@ -13,6 +13,9 @@ pub enum Link {
/// Link to a store path that already exists /// Link to a store path that already exists
#[arg(long)] #[arg(long)]
force: bool, force: bool,
#[command(flatten)]
store: Store,
}, },
/// Move paths in the store back into the repository /// 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 /// Restore every path this repository has in the store
#[arg(long)] #[arg(long)]
all: bool, all: bool,
#[command(flatten)]
store: Store,
}, },
/// List untracked paths a sandbox would still see /// List untracked paths a sandbox would still see
@@ -40,5 +46,17 @@ pub enum Link {
/// Terminate porcelain entries with NUL /// Terminate porcelain entries with NUL
#[arg(short = 'z')] #[arg(short = 'z')]
null: bool, 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<PathBuf>,
}

View File

@@ -5,5 +5,5 @@ mod postgres;
pub use ahab::{Ahab, Commands}; pub use ahab::{Ahab, Commands};
pub use django::Django; pub use django::Django;
pub use link::Link; pub use link::{Link, Store};
pub use postgres::{Format, Postgres}; pub use postgres::{Format, Postgres};

View File

@@ -1,6 +1,7 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use std::{ use std::{
fmt::Display, fmt::Display,
os::unix::process::CommandExt,
process::{Child, Command, ExitStatus, Stdio}, process::{Child, Command, ExitStatus, Stdio},
sync::OnceLock, sync::OnceLock,
}; };
@@ -83,6 +84,19 @@ impl CommandBuilder {
check(&shown, status) 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<Child> { pub fn spawn(self) -> Result<Child> {
Ok(self.build()?.spawn()?) Ok(self.build()?.spawn()?)
} }

View File

@@ -1,9 +1,11 @@
use std::process::ExitCode;
use ahab::{cli, command_builder, scripts}; use ahab::{cli, command_builder, scripts};
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
fn main() -> Result<()> { fn main() -> ExitCode {
let args = cli::Ahab::parse(); let args = cli::Ahab::parse();
command_builder::set_options(command_builder::Options { command_builder::set_options(command_builder::Options {
@@ -11,33 +13,67 @@ fn main() -> Result<()> {
dry_run: args.dry_run, dry_run: args.dry_run,
}); });
match args.command { match run(args.command) {
cli::Commands::Django { command } => match command { Ok(code) => code,
cli::Django::Bash => scripts::django::bash(), Err(e) => {
cli::Django::Run { rest } => scripts::django::run(&rest), eprintln!("Error: {e:#}");
cli::Django::MakeCommand { app, name } => scripts::django::make_command(&app, &name), ExitCode::FAILURE
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(), fn run(command: cli::Commands) -> Result<ExitCode> {
}, let done = ExitCode::SUCCESS;
cli::Commands::Postgres { command } => match command {
cli::Postgres::Import { path } => scripts::postgres::import(&path), match command {
cli::Postgres::Dump { path, format, gzip } => { cli::Commands::Django { command } => {
scripts::postgres::dump(&path, format, gzip) 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::Commands::Link { command } => match command {
cli::Link::Add { paths, force } => scripts::link::add(&paths, force), cli::Link::Add {
cli::Link::Restore { paths, all } => scripts::link::restore(&paths, all), 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 { cli::Link::Check {
paths, paths,
porcelain, porcelain,
null, null,
exit_code, 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)
}
} }
} }

View File

@@ -15,7 +15,7 @@ pub fn run(service: &str, rest: &[String]) -> Result<()> {
.args("run --rm") .args("run --rm")
.arg(service) .arg(service)
.args(rest) .args(rest)
.exec() .exec_replace()
} }
pub fn exec(service: &str, rest: &[String]) -> Result<()> { pub fn exec(service: &str, rest: &[String]) -> Result<()> {

View File

@@ -2,11 +2,10 @@ use std::cell::Cell;
use std::env; use std::env;
use std::ffi::OsString; use std::ffi::OsString;
use std::fs; use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process;
use std::process::Command; use std::process::Command;
use std::process::ExitCode;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
@@ -14,8 +13,8 @@ const BACKUP_SUFFIX: &str = ".ahab-bak";
const LOCAL_NAMESPACE: &str = "_local"; const LOCAL_NAMESPACE: &str = "_local";
// 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(paths: &[PathBuf], force: bool) -> Result<()> { pub fn add(paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> {
let repo = Repo::discover()?; let repo = Repo::discover(store)?;
let report = Report::new(&repo); let report = Report::new(&repo);
if let [path] = paths { 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 // move paths in the store back into the repo, the inverse of add
pub fn restore(paths: &[PathBuf], all: bool) -> Result<()> { pub fn restore(paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> {
let repo = Repo::discover()?; let repo = Repo::discover(store)?;
let report = Report::new(&repo); let report = Report::new(&repo);
let paths = match (all, paths) { 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 // 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<()> { pub fn check(
let repo = Repo::discover()?; paths: &[PathBuf],
porcelain: bool,
null: bool,
exit_code: bool,
store: Option<&Path>,
) -> Result<ExitCode> {
let repo = Repo::discover(store)?;
let pathspecs = relative_pathspecs(&repo, paths)?; let pathspecs = relative_pathspecs(&repo, paths)?;
let mut exposed = Vec::new(); 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 // git's --exit-code convention: nothing to report is 0, anything is 1
if exit_code && !exposed.is_empty() { if exit_code && !exposed.is_empty() {
io::stdout().flush().context("writing the listing")?; return Ok(ExitCode::FAILURE);
process::exit(1);
} }
Ok(()) Ok(ExitCode::SUCCESS)
} }
fn print_porcelain(exposed: &[Exposed], null: bool) { fn print_porcelain(exposed: &[Exposed], null: bool) {
@@ -424,10 +428,17 @@ struct Repo {
} }
impl Repo { impl Repo {
fn discover() -> Result<Self> { fn discover(store: Option<&Path>) -> Result<Self> {
let root = git_root()?; let root = git_root()?;
let store = store_root()?.join(repo_components(&root)?); let store = match store {
Ok(Self { root, 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<PathBuf> { fn relative(&self, src: &Path) -> Result<PathBuf> {
@@ -679,18 +690,16 @@ fn sanitize(s: &str) -> String {
} }
fn store_root() -> Result<PathBuf> { fn store_root() -> Result<PathBuf> {
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") { if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
return Ok(PathBuf::from(xdg).join("ahab")); return Ok(PathBuf::from(xdg).join("ahab"));
} }
let home = non_empty_var("HOME") 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")) Ok(PathBuf::from(home).join(".local/share/ahab"))
} }
fn non_empty_var(name: &str) -> Option<OsString> { fn non_empty_var(name: &str) -> Option<OsString> {
env::var_os(name).filter(|v| !v.is_empty()) env::var_os(name).filter(|v| !v.is_empty())
} }