Skip to content

Commit

Permalink
Add command chain source (closes #31)
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Nov 11, 2023
1 parent d21e01d commit 21387c6
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 22 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions docs/src/api/chain_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion slumber.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>),
/// Load data from a file
File(PathBuf),
/// Prompt the user for a value, with an optional label
Expand Down
2 changes: 1 addition & 1 deletion src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/template/error.rs
Original file line number Diff line number Diff line change
@@ -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<String, TemplateError>;
Expand Down Expand Up @@ -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<String>,
#[source]
error: io::Error,
},
#[error("Error decoding output for {command:?}")]
CommandInvalidUtf8 {
command: Vec<String>,
#[source]
error: FromUtf8Error,
},
#[error("Error reading from file {path:?}")]
File {
path: PathBuf,
Expand Down
93 changes: 80 additions & 13 deletions src/template/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Regex> = OnceLock::new();

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -343,12 +346,43 @@ impl<'a> ChainTemplateSource<'a> {
async fn render_file(&self, path: &'a Path) -> Result<String, ChainError> {
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<String, ChainError> {
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,
Expand Down Expand Up @@ -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,
)];
Expand All @@ -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()),
),
Expand All @@ -541,7 +573,6 @@ mod tests {
#[case(
create!(
Chain,
id: "chain1".into(),
source: ChainSource::Request("recipe1".into()),
selector: Some("$.*".parse().unwrap()),
),
Expand Down Expand Up @@ -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() {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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!(
Expand All @@ -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!(
Expand Down

0 comments on commit 21387c6

Please sign in to comment.