Skip to content

Commit

Permalink
Improve show subcommand
Browse files Browse the repository at this point in the history
Also reorganize all subcommand logic into modules
  • Loading branch information
LucasPickering committed Jan 26, 2024
1 parent 215f853 commit a046bfb
Show file tree
Hide file tree
Showing 7 changed files with 317 additions and 247 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
131 changes: 131 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Name>,

/// Profile to select. If omitted, an interactive prompt will be shown to
/// select between possible options.
pub profile: Option<Name>,
}

/// Data container with helper methods for all CLI subcommands
struct CommandContext {
source_file: Option<PathBuf>,
config: Config,
shell: Shell,
}

impl CommandContext {
fn new(
source_file: Option<PathBuf>,
shell_kind: Option<ShellKind>,
) -> anyhow::Result<Self> {
// 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<Environment> {
// 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)
}
}
54 changes: 54 additions & 0 deletions src/commands/run.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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())
}
}
}
33 changes: 33 additions & 0 deletions src/commands/set.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
}
60 changes: 60 additions & 0 deletions src/commands/show.rs
Original file line number Diff line number Diff line change
@@ -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<Name>,
/// Profile to show configuration for. If omitted, show all profiles
/// for the selected application.
profile: Option<Name>,
},
/// 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(())
}
}
Loading

0 comments on commit a046bfb

Please sign in to comment.