Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements to command querying #437

Merged
merged 4 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ jobs:

- name: Install toolchain
run: rustup target add ${{ matrix.platform.target }}
env:
RUST_BACKTRACE: 1

- name: Run tests
run: cargo test --workspace
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
5 changes: 3 additions & 2 deletions crates/tui/src/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,11 @@ impl TestTerminal {

/// Run a future in a local set, so it can use [tokio::task::spawn_local]. This
/// will wait until all spawned tasks are done.
pub async fn run_local(future: impl Future<Output = ()>) {
pub async fn run_local<T>(future: impl Future<Output = T>) -> T {
let local = LocalSet::new();
local.run_until(future).await; // Let the future spawn tasks
let output = local.run_until(future).await; // Let the future spawn tasks
local.await; // Wait until all tasks are done
output
}

/// Assert that the event queue matches the given list of patterns. Each event
Expand Down
66 changes: 55 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,38 @@ 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)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -305,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 @@ -378,4 +398,28 @@ 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")]
// I don't feel like getting this case to work with powershell
#[cfg_attr(not(windows), 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);
}
}
4 changes: 1 addition & 3 deletions crates/tui/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ use crate::{
component::{Component, Root, RootProps},
debug::DebugMonitor,
event::{Event, Update},
state::Notification,
},
};
use anyhow::anyhow;
Expand Down Expand Up @@ -137,8 +136,7 @@ impl View {

/// Queue an event to send an informational notification to the user
pub fn notify(&mut self, message: impl ToString) {
let notification = Notification::new(message.to_string());
ViewContext::push_event(Event::Notify(notification));
ViewContext::notify(message);
}

/// Queue an event to update the view according to an input event from the
Expand Down
44 changes: 23 additions & 21 deletions crates/tui/src/view/common/text_box.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ pub struct TextBox {

// State
state: TextState,
/// Text box has some related error. This is set externally by
/// [Self::set_error], and cleared whenever text changes
has_error: bool,
on_change_debounce: Option<Debounce>,
}

Expand All @@ -50,6 +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 {
// Don't call set_text here, because we don't want to emit an event
self.state.text = default;
self.state.end();
self
Expand Down Expand Up @@ -104,15 +102,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();
}

/// Enable error state, to show something invalid to the user
pub fn set_error(&mut self) {
self.has_error = true;
if changed {
self.change();
}
}

/// Check if the current input text is valid. Always returns true if there
Expand Down Expand Up @@ -174,7 +172,7 @@ impl TextBox {

/// Emit a change event. Should be called whenever text _content_ is changed
fn change(&mut self) {
self.has_error = false; // Clear existing error for the new text
println!("change");
let is_valid = self.is_valid();
if let Some(debounce) = &self.on_change_debounce {
if self.is_valid() {
Expand Down Expand Up @@ -242,8 +240,13 @@ impl EventHandler for TextBox {
}
}

impl Draw for TextBox {
fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) {
impl Draw<TextBoxProps> for TextBox {
fn draw(
&self,
frame: &mut Frame,
props: TextBoxProps,
metadata: DrawMetadata,
) {
let styles = &TuiContext::get().styles;

let text: Text = if self.state.text.is_empty() {
Expand All @@ -266,7 +269,7 @@ impl Draw for TextBox {
};

// Draw the text
let style = if self.is_valid() && !self.has_error {
let style = if self.is_valid() && !props.has_error {
styles.text_box.text
} else {
// Invalid and error state look the same
Expand All @@ -289,6 +292,11 @@ impl Draw for TextBox {
}
}

#[derive(Clone, Debug, Default)]
pub struct TextBoxProps {
pub has_error: bool,
}

/// Encapsulation of text/cursor state. Encapsulating this makes reading and
/// testing the functionality easier.
#[derive(Debug, Default)]
Expand Down Expand Up @@ -401,7 +409,6 @@ impl PersistedContainer for TextBox {

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

Expand Down Expand Up @@ -462,7 +469,7 @@ mod tests {
#[with(10, 1)] terminal: TestTerminal,
) {
let mut component =
TestComponent::new(&harness, &terminal, TextBox::default(), ());
TestComponent::new(&harness, &terminal, TextBox::default());

// Assert initial state/view
assert_state(&component.data().state, "", 0);
Expand Down Expand Up @@ -524,7 +531,6 @@ mod tests {
&harness,
&terminal,
TextBox::default().debounce(),
(),
);
run_local(async {
// Type some text. Change event isn't emitted immediately
Expand All @@ -545,7 +551,7 @@ mod tests {
#[with(10, 1)] terminal: TestTerminal,
) {
let mut component =
TestComponent::new(&harness, &terminal, TextBox::default(), ());
TestComponent::new(&harness, &terminal, TextBox::default());

// Type some text
component.send_text("hello!").assert_emitted([
Expand Down Expand Up @@ -592,7 +598,6 @@ mod tests {
&harness,
&terminal,
TextBox::default().sensitive(true),
(),
);

component
Expand All @@ -612,7 +617,6 @@ mod tests {
&harness,
&terminal,
TextBox::default().placeholder("hello"),
(),
);

assert_state(&component.data().state, "", 0);
Expand All @@ -635,7 +639,6 @@ mod tests {
TextBox::default()
.placeholder("unfocused")
.placeholder_focused("focused"),
(),
);
let styles = &TuiContext::get().styles.text_box;

Expand Down Expand Up @@ -665,7 +668,6 @@ mod tests {
&harness,
&terminal,
TextBox::default().validator(|text| text.len() <= 2),
(),
);

// Valid text, everything is normal
Expand Down
Loading
Loading