From a046bfb8184423a242db2d3c700da349b12e3bee Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Fri, 26 Jan 2024 11:41:40 -0500 Subject: [PATCH] Improve `show` subcommand Also reorganize all subcommand logic into modules --- CHANGELOG.md | 1 + src/commands/init.rs | 22 ++++ src/commands/mod.rs | 131 +++++++++++++++++++++ src/commands/run.rs | 54 +++++++++ src/commands/set.rs | 33 ++++++ src/commands/show.rs | 60 ++++++++++ src/main.rs | 263 +++---------------------------------------- 7 files changed, 317 insertions(+), 247 deletions(-) create mode 100644 src/commands/init.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/run.rs create mode 100644 src/commands/set.rs create mode 100644 src/commands/show.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9572e..ea4f594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Use `es` instead of `env-select` in CLI help output +- `es show config` now accepts optional arguments to print for a single application or profile ## [0.12.0] - 2024-01-26 diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..c0bd1a5 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,22 @@ +use crate::{ + commands::{CommandContext, SubcommandTrait}, + console, +}; +use anyhow::Context; +use clap::Parser; + +/// Configure the shell environment for env-select. Intended to be piped +/// to `source` as part of your shell startup. +#[derive(Clone, Debug, Parser)] +pub struct InitCommand; + +impl SubcommandTrait for InitCommand { + fn execute(self, context: CommandContext) -> anyhow::Result<()> { + let script = context + .shell + .init_script() + .context("Error generating shell init script")?; + println!("{script}"); + console::print_installation_hint() + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..4252600 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,131 @@ +//! All CLI subcommands are defined here. One sub-module per subcommand. Common +//! components that are specific to subcommands (and not the CLI as a whole) are +//! in this root module. + +use crate::{ + commands::{ + init::InitCommand, run::RunCommand, set::SetCommand, show::ShowCommand, + }, + config::{Config, Name, Profile}, + console::prompt_options, + environment::Environment, + execute::apply_side_effects, + shell::{Shell, ShellKind}, + GlobalArgs, +}; +use clap::Subcommand; +use std::path::PathBuf; + +mod init; +mod run; +mod set; +mod show; + +/// Subcommand to execute +#[derive(Clone, Debug, Subcommand)] +pub enum Commands { + Init(InitCommand), + Run(RunCommand), + Set(SetCommand), + Show(ShowCommand), +} + +impl Commands { + /// Execute a non-TUI command + pub fn execute(self, global: GlobalArgs) -> anyhow::Result<()> { + let context = CommandContext::new(global.source_file, global.shell)?; + match self { + Self::Init(command) => command.execute(context), + Self::Run(command) => command.execute(context), + Self::Set(command) => command.execute(context), + Self::Show(command) => command.execute(context), + } + } +} + +/// An executable subcommand. This trait isn't strictly necessary because we do +/// static dispatch via the command enum, but it's helpful to enforce a +/// consistent interface for each subcommand. +trait SubcommandTrait { + /// Execute the subcommand + fn execute(self, context: CommandContext) -> anyhow::Result<()>; +} + +/// Arguments required for any subcommand that allows applcation/profile +/// selection. +#[derive(Clone, Debug, clap::Args)] +pub struct Selection { + /// Application to select a profile for. If omitted, an interactive prompt + /// will be shown to select between possible options + pub application: Option, + + /// Profile to select. If omitted, an interactive prompt will be shown to + /// select between possible options. + pub profile: Option, +} + +/// Data container with helper methods for all CLI subcommands +struct CommandContext { + source_file: Option, + config: Config, + shell: Shell, +} + +impl CommandContext { + fn new( + source_file: Option, + shell_kind: Option, + ) -> anyhow::Result { + // This handler will put the terminal cursor back if the user ctrl-c's + // during the interactive dialogue + // https://github.com/mitsuhiko/dialoguer/issues/77 + ctrlc::set_handler(move || { + let term = dialoguer::console::Term::stdout(); + let _ = term.show_cursor(); + })?; + + let config = Config::load()?; + let shell = match shell_kind { + Some(kind) => Shell::from_kind(kind), + None => Shell::detect()?, + }; + + Ok(Self { + source_file, + config, + shell, + }) + } + + /// Select an application+profile, based on user input. For both application + /// and profile, if a default name was given, then that will be used. + /// Otherwise, the user will be prompted to select an option via TUI. + fn select_profile<'a>( + &'a self, + selection: &'a Selection, + ) -> anyhow::Result<&'a Profile> { + let application = prompt_options( + &self.config.applications, + selection.application.as_ref(), + )?; + prompt_options(&application.profiles, selection.profile.as_ref()) + } + + /// Build an [Environment] from a profile. This will also run pre-setup and + /// post-setup side effects. + fn load_environment( + &self, + profile: &Profile, + ) -> anyhow::Result { + // Run pre- and post-resolution side effects + apply_side_effects( + &profile.pre_export, + &self.shell, + &Environment::default(), + )?; + let environment = Environment::from_profile(&self.shell, profile)?; + apply_side_effects(&profile.post_export, &self.shell, &environment)?; + + Ok(environment) + } +} diff --git a/src/commands/run.rs b/src/commands/run.rs new file mode 100644 index 0000000..df6898d --- /dev/null +++ b/src/commands/run.rs @@ -0,0 +1,54 @@ +use crate::{ + commands::{CommandContext, Selection, SubcommandTrait}, + environment::Environment, + error::ExitCodeError, + execute::{revert_side_effects, Executable}, +}; +use clap::Parser; + +/// Run a shell command in an augmented environment, via a configured +/// variable/application +#[derive(Clone, Debug, Parser)] +pub struct RunCommand { + #[command(flatten)] + selection: Selection, + + /// Shell command to execute + #[arg(required = true, last = true)] + command: Vec, +} + +impl SubcommandTrait for RunCommand { + fn execute(self, context: CommandContext) -> anyhow::Result<()> { + let profile = context.select_profile(&self.selection)?; + let environment = context.load_environment(profile)?; + + // Undo clap's tokenization + let mut executable: Executable = + context.shell.executable(&self.command.join(" ").into()); + + // Execute the command + let status = executable.environment(&environment).status()?; + + // Clean up side effects, in reverse order + revert_side_effects( + &profile.post_export, + &context.shell, + &environment, + )?; + // Teardown of pre-export should *not* have access to the environment, + // to mirror the setup conditions + revert_side_effects( + &profile.pre_export, + &context.shell, + &Environment::default(), + )?; + + if status.success() { + Ok(()) + } else { + // Map to our own exit code error type so we can forward to the user + Err(ExitCodeError::from(&status).into()) + } + } +} diff --git a/src/commands/set.rs b/src/commands/set.rs new file mode 100644 index 0000000..a5f8dd5 --- /dev/null +++ b/src/commands/set.rs @@ -0,0 +1,33 @@ +use crate::commands::{CommandContext, Selection, SubcommandTrait}; +use anyhow::{anyhow, Context}; +use clap::Parser; +use std::fs; + +/// Modify shell environment via a configured variable/application +#[derive(Clone, Debug, Parser)] +pub struct SetCommand { + #[command(flatten)] + selection: Selection, +} + +impl SubcommandTrait for SetCommand { + fn execute(self, context: CommandContext) -> anyhow::Result<()> { + let source_file = context.source_file.as_ref().ok_or_else(|| { + anyhow!("--source-file argument required for subcommand `set`") + })?; + + let profile = context.select_profile(&self.selection)?; + let environment = context.load_environment(profile)?; + + let source_output = context.shell.export(&environment); + fs::write(source_file, source_output).with_context(|| { + format!("Error writing sourceable output to file {source_file:?}") + })?; + + // Tell the user what we exported + println!("The following variables will be set:"); + println!("{environment:#}"); + + Ok(()) + } +} diff --git a/src/commands/show.rs b/src/commands/show.rs new file mode 100644 index 0000000..38d5d27 --- /dev/null +++ b/src/commands/show.rs @@ -0,0 +1,60 @@ +use crate::{ + commands::{CommandContext, SubcommandTrait}, + config::{MapExt, Name}, +}; +use clap::{Parser, Subcommand}; + +/// Print configuration and meta information +#[derive(Clone, Debug, Parser)] +pub struct ShowCommand { + #[command(subcommand)] + command: ShowSubcommand, +} + +#[derive(Clone, Debug, Subcommand)] +enum ShowSubcommand { + /// Print configuration for a profile, in TOML format + Config { + // We can't use the Selection helper type here because the doc strings + // are incorrect for this use case + /// Application to show configuration for. If omitted, show all + /// applications. + application: Option, + /// Profile to show configuration for. If omitted, show all profiles + /// for the selected application. + profile: Option, + }, + /// Print the name or path to the shell in use + Shell, +} + +impl SubcommandTrait for ShowCommand { + fn execute(self, context: CommandContext) -> anyhow::Result<()> { + match self.command { + ShowSubcommand::Config { + application, + profile, + } => { + // Serialize isn't object-safe, so there's no way to return a + // dynamic object of what to serialize. That means each branch + // has to serialize itself + let content = if let Some(application) = application { + let application = + context.config.applications.try_get(&application)?; + if let Some(profile) = profile { + let profile = application.profiles.try_get(&profile)?; + toml::to_string(profile) + } else { + toml::to_string(application) + } + } else { + // Print entire config + toml::to_string(&context.config) + }?; + println!("{}", content); + } + ShowSubcommand::Shell => println!("{}", context.shell), + } + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 29b4207..b7b7737 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod commands; mod config; mod console; mod environment; @@ -8,36 +9,30 @@ mod test_util; mod shell; -use crate::{ - config::{Config, Name, Profile}, - console::prompt_options, - environment::Environment, - error::ExitCodeError, - execute::{apply_side_effects, revert_side_effects, Executable}, - shell::{Shell, ShellKind}, -}; -use anyhow::{anyhow, Context}; -use clap::{Parser, Subcommand}; +use crate::{commands::Commands, error::ExitCodeError, shell::ShellKind}; +use clap::Parser; use log::{error, LevelFilter}; // https://github.com/la10736/rstest/tree/master/rstest_reuse#cavelets #[cfg(test)] #[allow(clippy::single_component_path_imports)] use rstest_reuse; -use std::{ - fs, - path::{Path, PathBuf}, - process::ExitCode, -}; +use std::{path::PathBuf, process::ExitCode}; /// A utility to select between predefined values or sets of environment /// variables. -#[derive(Clone, Debug, Parser)] +#[derive(Debug, Parser)] #[clap(bin_name = "es", author, version, about, long_about = None)] -struct Args { - /// Subcommand to execute +pub struct Args { #[command(subcommand)] command: Commands, + #[command(flatten)] + global: GlobalArgs, +} + +/// Args available to all subcommands +#[derive(Debug, Parser)] +pub struct GlobalArgs { /// File that env-select should write sourceable output to. Used only by /// commands that intend to modify the parent environment. Shell wrappers /// will pass a temporary path here. This needs to be a global arg because @@ -55,81 +50,22 @@ struct Args { verbose: u8, } -#[derive(Clone, Debug, Subcommand)] -enum Commands { - /// Configure the shell environment for env-select. Intended to be piped - /// to `source` as part of your shell startup. - Init, - - /// Run a shell command in an augmented environment, via a configured - /// variable/application - Run { - #[command(flatten)] - selection: Selection, - - /// Shell command to execute - #[arg(required = true, last = true)] - command: Vec, - }, - - /// Modify shell environment via a configured variable/application - Set { - #[command(flatten)] - selection: Selection, - }, - - /// Print configured values. Useful for debugging and completions - Show(ShowArgs), -} - -/// Arguments required for any subcommand that allows applcation/profile -/// selection. -#[derive(Clone, Debug, clap::Args)] -struct Selection { - /// The name of the application to select a profile for. If omitted, an - /// interactive prompt will be shown to select between possible options - application: Option, - - /// Profile to select. If omitted, an interactive prompt will be shown to - /// select between possible options. - profile: Option, -} - -#[derive(Clone, Debug, clap::Args)] -struct ShowArgs { - #[command(subcommand)] - command: ShowSubcommand, -} - -#[derive(Clone, Debug, Subcommand)] -enum ShowSubcommand { - /// Print full resolved configuration, in TOML format - Config, - /// Print the name or path to the shell in use - Shell, -} - fn main() -> ExitCode { let args = Args::parse(); env_logger::Builder::new() .format_timestamp(None) .format_module_path(false) .format_target(false) - .filter_level(match args.verbose { + .filter_level(match args.global.verbose { 0 => LevelFilter::Warn, 1 => LevelFilter::Info, 2 => LevelFilter::Debug, _ => LevelFilter::Trace, }) .init(); - let verbose = args.verbose > 0; + let verbose = args.global.verbose > 0; - fn run(args: Args) -> anyhow::Result<()> { - let executor = Executor::new(args.shell)?; - executor.run(args) - } - - match run(args) { + match args.command.execute(args.global) { Ok(()) => ExitCode::SUCCESS, Err(error) => { // If the error includes an exit code, use it @@ -156,170 +92,3 @@ fn main() -> ExitCode { } } } - -/// Singleton container for executing commands -struct Executor { - config: Config, - shell: Shell, -} - -impl Executor { - fn new(shell_kind: Option) -> anyhow::Result { - // This handler will put the terminal cursor back if the user ctrl-c's - // during the interactive dialogue - // https://github.com/mitsuhiko/dialoguer/issues/77 - ctrlc::set_handler(move || { - let term = dialoguer::console::Term::stdout(); - let _ = term.show_cursor(); - })?; - - let config = Config::load()?; - let shell = match shell_kind { - Some(kind) => Shell::from_kind(kind), - None => Shell::detect()?, - }; - - Ok(Self { config, shell }) - } - - /// Fallible main function - fn run(self, args: Args) -> anyhow::Result<()> { - match args.command { - Commands::Init => self.print_init_script(), - Commands::Run { - selection: - Selection { - application, - profile, - }, - command, - } => self.run_command( - command, - application.as_ref(), - profile.as_ref(), - ), - Commands::Set { - selection: - Selection { - application, - profile, - }, - } => self.write_export_commands( - application.as_ref(), - profile.as_ref(), - &args.source_file.ok_or_else(|| { - anyhow!( - "--source-file argument required for subcommand `set`" - ) - })?, - ), - Commands::Show(ShowArgs { command }) => { - match command { - ShowSubcommand::Config => { - println!("{}", toml::to_string(&self.config)?) - } - ShowSubcommand::Shell => println!("{}", self.shell), - } - Ok(()) - } - } - } - - /// Select an application+profile, based on user input. For both application - /// and profile, if a default name was given, then that will be used. - /// Otherwise, the user will be prompted to select an option via TUI. - fn select_profile<'a>( - &'a self, - application_name: Option<&'a Name>, - profile_name: Option<&'a Name>, - ) -> anyhow::Result<&'a Profile> { - let application = - prompt_options(&self.config.applications, application_name)?; - prompt_options(&application.profiles, profile_name) - } - - /// Build an [Environment] from a profile. This will also run pre-setup and - /// post-setup side effects. - fn load_environment( - &self, - profile: &Profile, - ) -> anyhow::Result { - // Run pre- and post-resolution side effects - apply_side_effects( - &profile.pre_export, - &self.shell, - &Environment::default(), - )?; - let environment = Environment::from_profile(&self.shell, profile)?; - apply_side_effects(&profile.post_export, &self.shell, &environment)?; - - Ok(environment) - } - - /// Print the shell init script, which should be piped to `source` - fn print_init_script(&self) -> anyhow::Result<()> { - let script = self - .shell - .init_script() - .context("Error generating shell init script")?; - println!("{script}"); - console::print_installation_hint() - } - - /// Run a command in a sub-environment - fn run_command( - &self, - command: Vec, - application_name: Option<&Name>, - profile_name: Option<&Name>, - ) -> anyhow::Result<()> { - let profile = self.select_profile(application_name, profile_name)?; - let environment = self.load_environment(profile)?; - - // Undo clap's tokenization - let mut executable: Executable = - self.shell.executable(&command.join(" ").into()); - - // Execute the command - let status = executable.environment(&environment).status()?; - - // Clean up side effects, in reverse order - revert_side_effects(&profile.post_export, &self.shell, &environment)?; - // Teardown of pre-export should *not* have access to the environment, - // to mirror the setup conditions - revert_side_effects( - &profile.pre_export, - &self.shell, - &Environment::default(), - )?; - - if status.success() { - Ok(()) - } else { - // Map to our own exit code error type so we can forward to the user - Err(ExitCodeError::from(&status).into()) - } - } - - /// Write export commands to a file - fn write_export_commands( - &self, - application_name: Option<&Name>, - profile_name: Option<&Name>, - source_file: &Path, - ) -> anyhow::Result<()> { - let profile = self.select_profile(application_name, profile_name)?; - let environment = self.load_environment(profile)?; - - let source_output = self.shell.export(&environment); - fs::write(source_file, source_output).with_context(|| { - format!("Error writing sourceable output to file {source_file:?}") - })?; - - // Tell the user what we exported - println!("The following variables will be set:"); - println!("{environment:#}"); - - Ok(()) - } -}