Skip to content

Commit

Permalink
Add commands.query_default config field
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Dec 31, 2024
1 parent 2bb4b3e commit e99bb2e
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 47 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Replace JSONPath querying with general purpose shell commands for querying response bodies
- Now you can access any CLI tools you want for transforming response bodies, such as `jq` or `grep`
- By default, commands are executed via `sh` (or `cmd` on Windows), but this is configured via the [`commands.shell` field](https://slumber.lucaspickering.me/book/api/configuration/index.html)
- Add `slumber history` subcommand. Currently it has two operations:
- `slumber history list` lists all stored requests for a recipe
- `slumber history get` prints a specific request/response
Expand Down
8 changes: 1 addition & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const FILE: &str = "config.yml";
#[derive(Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Configuration for in-app query and side effect commands
pub commands: CommandsConfig,
/// Command to use for in-app editing. If provided, overrides
/// `VISUAL`/`EDITOR` environment variables
pub editor: Option<String>,
Expand Down Expand Up @@ -114,6 +116,7 @@ impl Config {
impl Default for Config {
fn default() -> Self {
Self {
commands: CommandsConfig::default(),
editor: None,
pager: None,
http: HttpEngineConfig::default(),
Expand All @@ -125,6 +128,35 @@ impl Default for Config {
}
}

/// Configuration for in-app query and side effect commands
#[derive(Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CommandsConfig {
/// Wrapping shell to parse and execute commands
/// If empty, commands will be parsed with shell-words and run natievly
pub shell: Vec<String>,
/// Default query command for responses
pub query_default: Option<String>,
}

impl Default for CommandsConfig {
fn default() -> Self {
// We use the defaults from docker, because it's well tested and
// reasonably intuitive
// https://docs.docker.com/reference/dockerfile/#shell
let default_shell: &[&str] = if cfg!(windows) {
&["cmd", "/S", "/C"]
} else {
&["/bin/sh", "-c"]
};

Self {
shell: default_shell.iter().map(|s| s.to_string()).collect(),
query_default: None,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ ratatui = {workspace = true, features = ["crossterm", "underline-color", "unstab
reqwest = {workspace = true}
serde = {workspace = true}
serde_yaml = {workspace = true}
shellish_parse = "2.2.0"
shell-words = "1.1.0"
slumber_config = {workspace = true}
slumber_core = {workspace = true}
strum = {workspace = true}
Expand Down
62 changes: 51 additions & 11 deletions crates/tui/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::{
message::{Message, MessageSender},
view::Confirm,
};
use anyhow::{anyhow, bail, Context};
use anyhow::{bail, Context};
use bytes::Bytes;
use crossterm::event;
use editor_command::EditorBuilder;
Expand Down Expand Up @@ -230,19 +230,35 @@ pub fn get_pager_command(file: &Path) -> anyhow::Result<Command> {
})
}

/// Run a shellish command, optionally piping some stdin to it
/// Run a command, optionally piping some stdin to it. This will use given shell
/// (e.g. `["sh", "-c"]`) to execute the command, or parse+run it natively if no
/// shell is set. The shell should generally come from the config, but is
/// taken as param for testing.
pub async fn run_command(
command: &str,
shell: &[String],
command_str: &str,
stdin: Option<&[u8]>,
) -> anyhow::Result<Vec<u8>> {
let _ = debug_span!("Command", command).entered();

let mut parsed = shellish_parse::parse(command, true)?;
let mut tokens = parsed.drain(..);
let program = tokens.next().ok_or_else(|| anyhow!("Command is empty"))?;
let args = tokens;
let mut process = tokio::process::Command::new(program)
.args(args)
let _ = debug_span!("Command", command = command_str).entered();

let mut command = if let [program, args @ ..] = shell {
// Invoke the shell with our command as the final arg
let mut command = tokio::process::Command::new(program);
command.args(args).arg(command_str);
command
} else {
// Shell command is empty - we should execute the command directly.
// We'll have to do our own parsing of it
let tokens = shell_words::split(command_str)?;
let [program, args @ ..] = tokens.as_slice() else {
bail!("Command is empty")
};
let mut command = tokio::process::Command::new(program);
command.args(args);
command
};

let mut process = command
// Stop the command on drop. This will leave behind a zombie process,
// but tokio should reap it in the background. See method docs
.kill_on_drop(true)
Expand Down Expand Up @@ -308,6 +324,7 @@ mod tests {
use super::*;
use crate::test_util::{harness, TestHarness};
use rstest::rstest;
use slumber_config::CommandsConfig;
use slumber_core::{
assert_matches,
test_util::{temp_dir, TempDir},
Expand Down Expand Up @@ -381,4 +398,27 @@ mod tests {
"{expected_path:?}"
);
}

#[rstest]
#[case::default_shell(
&CommandsConfig::default().shell,
"echo test | head -c 1",
"t",
)]
#[case::no_shell(&[], "echo -n test | head -c 1", "test | head -c 1")]
#[case::custom_shell(
&["bash".into(), "-c".into()],
"echo test | head -c 1",
"t",
)]
#[tokio::test]
async fn test_run_command(
#[case] shell: &[String],
#[case] command: &str,
#[case] expected: &str,
) {
let bytes = run_command(shell, command, None).await.unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert_eq!(s, expected);
}
}
11 changes: 7 additions & 4 deletions crates/tui/src/view/common/text_box.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ type Validator = Box<dyn Fn(&str) -> bool>;
impl TextBox {
/// Set initialize value for the text box
pub fn default_value(mut self, default: String) -> Self {
self.state.text = default;
self.state.end();
self.set_text(default);
self
}

Expand Down Expand Up @@ -101,10 +100,15 @@ impl TextBox {
self.state.text
}

/// Set text, and move the cursor to the end. This will **not** emit events
/// Set text, and move the cursor to the end. If the text changed, emit a
/// change event.
pub fn set_text(&mut self, text: String) {
let changed = text != self.state.text;
self.state.text = text;
self.state.end();
if changed {
self.change();
}
}

/// Check if the current input text is valid. Always returns true if there
Expand Down Expand Up @@ -402,7 +406,6 @@ impl PersistedContainer for TextBox {

fn restore_persisted(&mut self, value: Self::Value) {
self.set_text(value);
self.submit();
}
}

Expand Down
52 changes: 40 additions & 12 deletions crates/tui/src/view/component/queryable_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ impl QueryableBody {
let input_engine = &TuiContext::get().input_engine;
let binding = input_engine.binding_display(Action::Search);

let text_box = TextBox::default()
let query_text_box = TextBox::default()
.placeholder(format!("{binding} to filter"))
.placeholder_focused("Enter command (ex: `jq .results`)")
.default_value(Self::default_query())
.debounce();
let text_state =
TextState::new(response.content_type(), &response.body, true);
Expand All @@ -75,7 +76,7 @@ impl QueryableBody {
query_focused: false,
query_command: None,
query_state: QueryState::None,
query_text_box: text_box.into(),
query_text_box: query_text_box.into(),
text_window: Default::default(),
text_state,
}
Expand All @@ -99,10 +100,26 @@ impl QueryableBody {
&self.text_state.text
}

/// Get the query command to use by default on initial load
fn default_query() -> String {
TuiContext::get()
.config
.commands
.query_default
.clone()
.map(|mut s| {
// Assume the user wants to start by typing a new token, so add
// a space
s.push(' ');
s
})
.unwrap_or_default()
}

/// Update query command based on the current text in the box, and start
/// a task to run the command
fn update_query(&mut self) {
let command = self.query_text_box.data().text();
let command = self.query_text_box.data().text().trim();
let response = &self.response;

// If a command is already running, abort it
Expand All @@ -128,7 +145,8 @@ impl QueryableBody {
let command = command.to_owned();
let emitter = self.detach();
let handle = task::spawn_local(async move {
let result = run_command(&command, Some(&body))
let shell = &TuiContext::get().config.commands.shell;
let result = run_command(shell, &command, Some(&body))
.await
.with_context(|| format!("Error running `{command}`"));
emitter.emit(QueryComplete(result.map_err(Rc::new)));
Expand Down Expand Up @@ -247,7 +265,17 @@ impl PersistedContainer for QueryableBody {
}

fn restore_persisted(&mut self, value: Self::Value) {
self.query_text_box.data_mut().restore_persisted(value)
let text_box = self.query_text_box.data_mut();
text_box.restore_persisted(value);

// It's pretty common to clear the whole text box without thinking about
// it. In that case, we want to restore the default the next time we
// reload from persistence (probably either app restart or next response
// for this recipe). It's possible the user really wants an empty box
// and this is annoying, but I think it'll be more good than bad.
if text_box.text().is_empty() {
text_box.set_text(Self::default_query());
}
}
}

Expand Down Expand Up @@ -355,13 +383,13 @@ enum QueryState {

impl QueryState {
fn take_abort_handle(&mut self) -> Option<AbortHandle> {
if let Self::Running(_) = self {
let Self::Running(handle) = mem::take(self) else {
unreachable!()
};
Some(handle)
} else {
None
match mem::take(self) {
Self::Running(handle) => Some(handle),
other => {
// Put it back!
*self = other;
None
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/src/api/configuration/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pager: bat

To open a body in the pager, use the actions menu keybinding (`x` by default, see [input bindings](./input_bindings.md)), and select `View Body`.

Some popular file viewers:
Some popular pagers:

- [bat](https://github.com/sharkdp/bat)
- [fx](https://fx.wtf/)
Expand Down
44 changes: 33 additions & 11 deletions docs/src/api/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,36 @@ SLUMBER_CONFIG_PATH=~/dotfiles/slumber.yml slumber

## Fields

| Field | Type | Description | Default |
| -------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `debug` | `boolean` | Enable developer information | `false` |
| `editor` | `string` | Command to use when opening files for in-app editing. [More info](./editor.md) | `VISUAL`/`EDITOR` env vars, or `vim` |
| `ignore_certificate_hosts` | `string[]` | Hostnames whose TLS certificate errors will be ignored. [More info](../../troubleshooting/tls.md) | `[]` |
| `input_bindings` | `mapping[Action, KeyCombination[]]` | Override default input bindings. [More info](./input_bindings.md) | `{}` |
| `large_body_size` | `number` | Size over which request/response bodies are not formatted/highlighted, for performance (bytes) | `1000000` (1 MB) |
| `preview_templates` | `boolean` | Render template values in the TUI? If false, the raw template will be shown. | `true` |
| `theme` | [`Theme`](./theme.md) | Visual customizations | `{}` |
| `pager` | `string` | Command to use when opening files for viewing. [More info](./editor.md) | `less` (Unix), `more` (Windows) |
| `viewer` | See `pager` | Alias for `pager`, for backward compatibility | See `pager` |
| Field | Type | Description | Default |
| -------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `commands.shell` | `string[]` | Shell used to execute commands within the TUI. [More info](#commands) | `[sh, -c]` (Unix), `[cmd, /S, /C]` (Windows) |
| `commands.query_default` | `string` | Default query command for all responses | `""` |
| `debug` | `boolean` | Enable developer information | `false` |
| `editor` | `string` | Command to use when opening files for in-app editing. [More info](./editor.md) | `VISUAL`/`EDITOR` env vars, or `vim` |
| `ignore_certificate_hosts` | `string[]` | Hostnames whose TLS certificate errors will be ignored. [More info](../../troubleshooting/tls.md) | `[]` |
| `input_bindings` | `mapping[Action, KeyCombination[]]` | Override default input bindings. [More info](./input_bindings.md) | `{}` |
| `large_body_size` | `number` | Size over which request/response bodies are not formatted/highlighted, for performance (bytes) | `1000000` (1 MB) |
| `preview_templates` | `boolean` | Render template values in the TUI? If false, the raw template will be shown. | `true` |
| `theme` | [`Theme`](./theme.md) | Visual customizations | `{}` |
| `pager` | `string` | Command to use when opening files for viewing. [More info](./editor.md) | `less` (Unix), `more` (Windows) |
| `viewer` | See `pager` | Alias for `pager`, for backward compatibility | See `pager` |

## Commands

Slumber allows you to execute shell commands within the TUI, e.g. for querying and transforming response bodies. By default, the command you enter is passed to `sh` (or `cmd` on Windows) for parsing and execution. This allows you to access shell behavior such as piping. The command to execute is passed as the final argument to the shell, and the response body is passed as stdin to the spawned process.

If you want to use a different shell (e.g. to access your shell aliases), you can override the `commands.shell` config field. For example, to use [fish](https://fishshell.com/):

```yaml
commands:
shell: ["fish", "-c"]
```
If you don't want to use a shell at all, you can pass `[]`:

```yaml
commands:
shell: []
```

In this case, any commands to be executed will be parsed with [shell-words](https://docs.rs/shell-words/1.1.0/shell_words/fn.split.html) and executed directly. For example, `echo -n test` will run `echo` with the arguments `-n` and `test`.

0 comments on commit e99bb2e

Please sign in to comment.