diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f1f720..b678455b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ ### Added -- Add ability to preview template values. This will show the rendered value under current settings +- Add ability to preview template values. This will show the rendered value under current settings [#29](https://github.com/LucasPickering/slumber/issues/29) - This includes a new modal to toggle the setting on/off, via the `X` key +- Add `command` source type for chained values, which uses stdout from an executed subprocess command [#31](https://github.com/LucasPickering/slumber/issues/31) ### Changed diff --git a/docs/src/api/chain_source.md b/docs/src/api/chain_source.md index ba11cff8..060a54ad 100644 --- a/docs/src/api/chain_source.md +++ b/docs/src/api/chain_source.md @@ -4,11 +4,12 @@ A chain source defines how a [Chain](./chain.md) gets its value. It populates th ## Types -| Type | Value | Chained Value | -| --------- | ------------------------------------ | --------------------------------------------------------------- | -| `request` | Request Recipe ID | Body of the most recent response for a specific request recipe. | -| `file` | Path (relative to current directory) | Contents of the file | -| `prompt` | Descriptive prompt for the user | Value entered by the user | +| Type | Type | Value | Chained Value | +| --------- | ---------- | ------------------------------------ | --------------------------------------------------------------- | +| `request` | `string` | Request Recipe ID | Body of the most recent response for a specific request recipe. | +| `command` | `string[]` | `[program, ...arguments]` | Stdout of the executed command | +| `file` | `string` | Path (relative to current directory) | Contents of the file | +| `prompt` | `string` | Descriptive prompt for the user | Value entered by the user | ## Examples @@ -17,6 +18,8 @@ See the [`Chain`](./chain.md) docs for more holistic examples. ```yaml !request login --- +!command ["echo", "-n", "hello"] +--- !file ./username.txt --- !prompt Enter Password diff --git a/slumber.yml b/slumber.yml index 6d2d61d7..7b436388 100644 --- a/slumber.yml +++ b/slumber.yml @@ -17,7 +17,7 @@ profiles: chains: - id: username - source: !prompt Username + source: !command ["sh", "-c", "whoami | tr -d '\n'"] - id: password source: !prompt Password sensitive: true diff --git a/src/config/mod.rs b/src/config/mod.rs index d91dfd53..77362555 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -126,6 +126,8 @@ pub struct Chain { pub enum ChainSource { /// Load data from the most recent response of a particular request recipe Request(RequestRecipeId), + /// Run an external command to get a result + Command(Vec), /// Load data from a file File(PathBuf), /// Prompt the user for a value, with an optional label diff --git a/src/factory.rs b/src/factory.rs index ceeb9298..87816076 100644 --- a/src/factory.rs +++ b/src/factory.rs @@ -49,7 +49,7 @@ factori!(RequestRecord, { factori!(Chain, { default { - id = String::new(), + id = String::from("chain1"), source = ChainSource::Request(RequestRecipeId::default()), sensitive = false, selector = None, diff --git a/src/template/error.rs b/src/template/error.rs index eead681c..ef009318 100644 --- a/src/template/error.rs +++ b/src/template/error.rs @@ -1,5 +1,5 @@ use serde_json_path::ExactlyOneError; -use std::{env::VarError, io, path::PathBuf}; +use std::{env::VarError, io, path::PathBuf, string::FromUtf8Error}; use thiserror::Error; pub type TemplateResult = Result; @@ -67,6 +67,21 @@ pub enum ChainError { #[source] error: ExactlyOneError, }, + /// User gave an empty list for the command + #[error("No command given")] + CommandMissing, + #[error("Error executing command {command:?}")] + Command { + command: Vec, + #[source] + error: io::Error, + }, + #[error("Error decoding output for {command:?}")] + CommandInvalidUtf8 { + command: Vec, + #[source] + error: FromUtf8Error, + }, #[error("Error reading from file {path:?}")] File { path: PathBuf, diff --git a/src/template/mod.rs b/src/template/mod.rs index 362609d2..e1f1f808 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -23,8 +23,8 @@ use std::{ path::Path, sync::OnceLock, }; -use tokio::{fs, sync::oneshot}; -use tracing::{instrument, trace}; +use tokio::{fs, process::Command, sync::oneshot}; +use tracing::{info, instrument, trace}; static TEMPLATE_REGEX: OnceLock = OnceLock::new(); @@ -297,6 +297,9 @@ impl<'a> TemplateSource<'a> for ChainTemplateSource<'a> { self.render_request(context, recipe_id).await? } ChainSource::File(path) => self.render_file(path).await?, + ChainSource::Command(command) => { + self.render_command(command).await? + } ChainSource::Prompt(label) => { self.render_prompt( context, @@ -343,12 +346,43 @@ impl<'a> ChainTemplateSource<'a> { async fn render_file(&self, path: &'a Path) -> Result { fs::read_to_string(path) .await - .map_err(|err| ChainError::File { + .map_err(|error| ChainError::File { path: path.to_owned(), - error: err, + error, }) } + /// Render a chained value from an external command + async fn render_command( + &self, + command: &[String], + ) -> Result { + match command { + [] => Err(ChainError::CommandMissing), + [program, args @ ..] => { + let output = + Command::new(program).args(args).output().await.map_err( + |error| ChainError::Command { + command: command.to_owned(), + error, + }, + )?; + info!( + ?command, + stdout = %String::from_utf8_lossy(&output.stdout), + stderr = %String::from_utf8_lossy(&output.stderr), + "Executing subcommand" + ); + String::from_utf8(output.stdout).map_err(|error| { + ChainError::CommandInvalidUtf8 { + command: command.to_owned(), + error, + } + }) + } + } + } + /// Render a value by asking the user to provide it async fn render_prompt( &self, @@ -502,7 +536,6 @@ mod tests { let selector = selector.map(|s| s.parse().unwrap()); let chains = vec![create!( Chain, - id: "chain1".into(), source: ChainSource::Request(recipe_id), selector: selector, )]; @@ -519,16 +552,15 @@ mod tests { /// Test all possible error cases for chained requests. This covers all /// chain-specific error variants #[rstest] - #[case(create!(Chain), None, "Unknown chain")] + #[case(create!(Chain, id: "unknown".into()), None, "Unknown chain")] #[case( - create!(Chain, id: "chain1".into(), source: ChainSource::Request("unknown".into())), + create!(Chain, source: ChainSource::Request("unknown".into())), None, "No response available", )] #[case( create!( Chain, - id: "chain1".into(), source: ChainSource::Request("recipe1".into()), selector: Some("$.message".parse().unwrap()), ), @@ -541,7 +573,6 @@ mod tests { #[case( create!( Chain, - id: "chain1".into(), source: ChainSource::Request("recipe1".into()), selector: Some("$.*".parse().unwrap()), ), @@ -578,6 +609,42 @@ mod tests { ); } + /// Test success with chained command + #[tokio::test] + async fn test_chain_command() { + let command = vec!["echo".into(), "-n".into(), "hello!".into()]; + let chains = vec![create!( + Chain, + source: ChainSource::Command(command), + )]; + let context = create!(TemplateContext, chains: chains); + + assert_eq!(render!("{{chains.chain1}}", context).unwrap(), "hello!"); + } + + /// Test failure with chained command + #[rstest] + #[case(&[], "No command given")] + #[case(&["totally not a program"], "No such file or directory")] + #[case(&["head", "/dev/random"], "invalid utf-8 sequence")] + #[tokio::test] + async fn test_chain_command_error( + #[case] command: &[&str], + #[case] expected_error: &str, + ) { + let source = ChainSource::Command( + command.iter().cloned().map(String::from).collect(), + ); + let chains = vec![create!(Chain, source: source)]; + let context = create!(TemplateContext, chains: chains); + + assert_err!( + render!("{{chains.chain1}}", context), + expected_error, + true + ); + } + /// Test success with chained file #[tokio::test] async fn test_chain_file() { @@ -588,7 +655,7 @@ mod tests { let chains = vec![create!( Chain, - id: "chain1".into(), + source: ChainSource::File(file_path), )]; let context = create!(TemplateContext, chains: chains); @@ -601,7 +668,7 @@ mod tests { async fn test_chain_file_error() { let chains = vec![create!( Chain, - id: "chain1".into(), + source: ChainSource::File("not-a-real-file".into()), )]; let context = create!(TemplateContext, chains: chains); @@ -617,7 +684,7 @@ mod tests { async fn test_chain_prompt() { let chains = vec![create!( Chain, - id: "chain1".into(), + source: ChainSource::Prompt(Some("password".into())), )]; let context = create!( @@ -635,7 +702,7 @@ mod tests { async fn test_chain_prompt_error() { let chains = vec![create!( Chain, - id: "chain1".into(), + source: ChainSource::Prompt(Some("password".into())), )]; let context = create!(