From 961c54b161749aff3353154ac4a210623e6b8eb0 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 18:39:45 +0100 Subject: [PATCH 01/15] feat!: use jinja for templating Swap out our own implementation of substitution with something a lot more powerful like jinja --- Cargo.lock | 18 ++ Cargo.toml | 1 + example/create_issue.http | 4 +- example/get_issues.http | 2 +- example/get_repos.http | 2 +- src/prompt.rs | 80 ++---- src/substitute.rs | 506 ++++++++++---------------------------- src/ui/app.rs | 79 ++---- src/ui/datepicker.rs | 13 +- src/ui/mod.rs | 4 +- src/ui/prompt.rs | 17 +- src/ui/select.rs | 28 ++- 12 files changed, 223 insertions(+), 531 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1df983..ae01436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "inquire", "jsonpath", "log", + "minijinja", "mktemp", "notify", "ratatui", @@ -1300,12 +1301,29 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minijinja" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d" +dependencies = [ + "memo-map", + "serde", + "serde_json", +] + [[package]] name = "miniz_oxide" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index bb9a8a2..2fefa87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ tui-input = "0.11.0" syntect-tui = "3.0.4" syntect = "5.2.0" graphql-parser = "0.4.0" +minijinja = { version = "2", features = ["json"] } [dev-dependencies] mktemp = "0.5.1" diff --git a/example/create_issue.http b/example/create_issue.http index 287013b..23fdb95 100644 --- a/example/create_issue.http +++ b/example/create_issue.http @@ -1,8 +1,8 @@ -POST {{base_url}}/repos/{{owner}}/{{repo}}/issues HTTP/1.1 +POST {{ base_url }}/repos/{{ owner }}/{{ repo }}/issues HTTP/1.1 Accept: application/vnd.github+json { "title": "Found a bug", "body": "I'm having a problem with this.", - "labels": [ {{ label[, ]["] }} ], + "labels": [{% for l in label %}"{{ l.value }}"{% if not loop.last %}, {% endif %}{% endfor %}], } diff --git a/example/get_issues.http b/example/get_issues.http index b4f77b8..be440a0 100644 --- a/example/get_issues.http +++ b/example/get_issues.http @@ -1,2 +1,2 @@ -GET {{base_url}}/repos/{{reponame}}/issues?page={{page|1}}&per_page=10 HTTP/1.1 +GET {{ base_url }}/repos/{{ reponame }}/issues?page={{ page | default(1) }}&per_page=10 HTTP/1.1 Accept: application/vnd.github+json diff --git a/example/get_repos.http b/example/get_repos.http index 8e8aac5..c4cde8a 100644 --- a/example/get_repos.http +++ b/example/get_repos.http @@ -1,2 +1,2 @@ -GET {{base_url}}/users/{{username}}/repos HTTP/1.1 +GET {{ base_url }}/users/{{ username }}/repos HTTP/1.1 Accept: application/vnd.github+json diff --git a/src/prompt.rs b/src/prompt.rs index 679663e..4a8d2b7 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Result}; use fuzzy_matcher::skim::SkimMatcherV2; use inquire::{list_option::ListOption, DateSelect, MultiSelect, Select, Text}; +use minijinja::Value as JinjaValue; use std::{collections::HashMap, env, string::ToString}; use toml::Value; @@ -11,7 +12,6 @@ use crate::{ substitute::{ prepare_request, Substitution::{Complete, ValueMissing}, - SubstitutionValue, }, }; @@ -46,13 +46,13 @@ pub fn get_interaction() -> Box { } pub trait UserInteraction { - fn prompt(&self, key: &str, fallback: Option<&str>) -> Result; + fn prompt(&self, key: &str) -> Result; fn select(&self, key: &str, values: &[Value]) -> Result; fn select_multiple( &self, key: &str, values: &[Value], - ) -> Result>; + ) -> Result; } pub fn prepare_request_interactive( @@ -63,35 +63,19 @@ pub fn prepare_request_interactive( where I: UserInteraction + ?Sized, { - let mut vars = HashMap::new(); + let mut vars: HashMap = HashMap::new(); loop { match prepare_request(resolved, &vars)? { Complete(req) => return Ok(req), - ValueMissing { - key, - fallback, - multiple, - } => { + ValueMissing { key } => { let value = match scope.lookup(&key)? { - Replacement::Value(value) => { - SubstitutionValue::Single(value) - } + Replacement::Value(value) => JinjaValue::from(value), Replacement::ValueNotFound { key } => { - SubstitutionValue::Single( - interaction.prompt(&key, fallback.as_deref())?, - ) + JinjaValue::from(interaction.prompt(&key)?) } Replacement::MultipleValuesFound { key, values } => { - if multiple { - SubstitutionValue::Multiple( - interaction.select_multiple(&key, &values)?, - ) - } else { - SubstitutionValue::Single( - interaction.select(&key, &values)?, - ) - } + interaction.select_multiple(&key, &values)? } }; @@ -118,11 +102,7 @@ impl NoUserInteraction { } impl UserInteraction for NoUserInteraction { - fn prompt(&self, key: &str, fallback: Option<&str>) -> Result { - if let Some(val) = fallback.map(ToString::to_string) { - return Ok(val); - } - + fn prompt(&self, key: &str) -> Result { bail!("Replacement not found: {key}"); } @@ -135,7 +115,7 @@ impl UserInteraction for NoUserInteraction { &self, key: &str, values: &[toml::Value], - ) -> Result> { + ) -> Result { let suggestions = self.get_suggestions(key, values); bail!("Replacement not selected: {key}\nSuggestions:\n{suggestions}"); } @@ -144,8 +124,8 @@ impl UserInteraction for NoUserInteraction { pub struct CliUserInteraction; impl UserInteraction for CliUserInteraction { - fn prompt(&self, key: &str, fallback: Option<&str>) -> Result { - prompt_user(key, fallback) + fn prompt(&self, key: &str) -> Result { + prompt_user(key) } fn select(&self, key: &str, values: &[toml::Value]) -> Result { @@ -156,23 +136,19 @@ impl UserInteraction for CliUserInteraction { &self, key: &str, values: &[Value], - ) -> Result> { + ) -> Result { select_replacement_multiple(key, values) } } -fn prompt_user(key: &str, fallback: Option<&str>) -> Result { - let fb = fallback.unwrap_or(""); - +fn prompt_user(key: &str) -> Result { if key.ends_with("_date") || key.ends_with("Date") { if let Some(date) = prompt_for_date(key)? { return Ok(date); } } - let input = Text::new(&format!("Enter value for {key}")) - .with_default(fb) - .prompt()?; + let input = Text::new(&format!("Enter value for {key}")).prompt()?; Ok(input) } @@ -198,13 +174,13 @@ fn select_replacement(key: &str, values: &[Value]) -> Result { .with_page_size(15) .prompt()?; - list_option_to_string(key, values, &selected) + Ok(JinjaValue::from_serialize(&values[selected.index]).to_string()) } fn select_replacement_multiple( key: &str, values: &[Value], -) -> Result> { +) -> Result { let list_options = values_to_list_options(values); let selected = MultiSelect::new(&format!("Select value for {key}"), list_options) @@ -212,10 +188,12 @@ fn select_replacement_multiple( .with_page_size(15) .prompt()?; - selected + let items: Vec = selected .iter() - .map(|item| list_option_to_string(key, values, item)) - .collect() + .map(|item| JinjaValue::from_serialize(&values[item.index])) + .collect(); + + Ok(JinjaValue::from(items)) } fn values_to_list_options(values: &[Value]) -> Vec> { @@ -238,20 +216,6 @@ fn values_to_list_options(values: &[Value]) -> Vec> { .collect() } -fn list_option_to_string( - key: &str, - values: &[Value], - selected: &ListOption, -) -> Result { - match &values[selected.index] { - Value::Table(t) => match t.get("value") { - Some(Value::String(value)) => Ok(value.clone()), - Some(value) => Ok(value.to_string()), - _ => bail!("Replacement not found: {key}"), - }, - other => Ok(other.to_string()), - } -} #[cfg(test)] mod tests { diff --git a/src/substitute.rs b/src/substitute.rs index 6386aae..66baed4 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -1,5 +1,8 @@ -use anyhow::{bail, Context}; +use std::sync::{Arc, Mutex}; + +use anyhow::Context; use httparse::Status; +use minijinja::{value::Object, Environment, UndefinedBehavior, Value}; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Method, Url, @@ -18,40 +21,60 @@ use crate::{ #[derive(Debug, PartialEq, Eq)] pub enum Substitution { Complete(T), - ValueMissing { - key: String, - fallback: Option, - multiple: bool, - }, + ValueMissing { key: String }, } pub use Substitution::{Complete, ValueMissing}; +// Used by UI layer to track whether a single or multiple values were selected +#[derive(Debug, Clone)] +pub enum SubstitutionValue { + Single(T), + Multiple(Vec), +} + +struct TrackingContext { + vars: HashMap, + last_missing: Arc>>, +} + +impl std::fmt::Debug for TrackingContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TrackingContext") + } +} + +impl std::fmt::Display for TrackingContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TrackingContext") + } +} + +impl Object for TrackingContext { + fn get_value(self: &Arc, key: &Value) -> Option { + let key_str = key.as_str()?; + match self.vars.get(key_str) { + Some(v) => Some(v.clone()), + None => { + // Track the last missing key. Using "last" rather than + // "first" correctly handles {{ var | default('x') }} {{ other }}: + // the default filter handles `var` silently, then `other` + // causes the render to fail, and we report `other`. + *self.last_missing.lock().unwrap() = Some(key_str.to_string()); + Some(Value::UNDEFINED) + } + } + } +} + pub fn prepare_request( resolved: &Resolved, - vars: &HashMap>, + vars: &HashMap, ) -> anyhow::Result> { - // FIXME This is still doing too much: - // - Substituting placeholders in the raw input text - // - Parsing the result as HTTP, yielding method, url, headers and body - // - Loading and parsing GraphQL - // - Generating variables for GraphQL (quite different for raw text - // substitution) - let input = read_to_string(resolved.http_file())?; let buf = match substitute(&input, vars)? { Complete(buf) => buf, - ValueMissing { - key, - fallback, - multiple, - } => { - return Ok(ValueMissing { - key, - fallback, - multiple, - }) - } + ValueMissing { key } => return Ok(ValueMissing { key }), }; let mut headers_buf = [httparse::EMPTY_HEADER; 64]; @@ -83,21 +106,10 @@ pub fn prepare_request( for key in args { let Some(value) = vars.get(&key.name) else { - return Ok(ValueMissing { - key: key.name, - fallback: None, - multiple: key.list, - }); + return Ok(ValueMissing { key: key.name }); }; - match value { - SubstitutionValue::Single(item) => { - map.insert(key.name, serde_json::to_value(item)?); - } - SubstitutionValue::Multiple(items) => { - map.insert(key.name, serde_json::to_value(items)?); - } - }; + map.insert(key.name, serde_json::to_value(value)?); } let variables = serde_json::to_value(map)?; @@ -119,8 +131,6 @@ pub fn prepare_request( let mut headers = HeaderMap::new(); for header in req.headers { - // The parse_http crate is weird, it fills the array with empty headers - // if a partial request is parsed. if header.name.is_empty() { break; } @@ -138,185 +148,30 @@ pub fn prepare_request( })) } -#[derive(Debug, Clone)] -pub enum SubstitutionValue { - Single(T), - Multiple(Vec), -} - pub fn substitute( input: &str, - vars: &HashMap>, -) -> anyhow::Result> { - let mut output = String::new(); - - for line in input.lines() { - let res = match substitute_line(line, vars)? { - Complete(l) => l, - m @ ValueMissing { .. } => return Ok(m), - }; - output.push_str(&res); - output.push('\n'); - } - - Ok(Complete(output)) -} - -fn substitute_line( - line: &str, - vars: &HashMap>, + vars: &HashMap, ) -> anyhow::Result> { - let mut output = String::new(); - let mut slice = line; - loop { - match slice.find("{{") { - None => { - if slice.contains("}}") { - bail!("Syntax error"); - } - output.push_str(slice); - break; - } - Some(pos) => { - output.push_str(&slice[..pos]); - slice = &slice[pos..]; - - let Some(end) = slice.find("}}").map(|i| i + 2) else { - bail!("Syntax error"); - }; - - let rep = match substitute_inner(&slice[2..end - 2], vars) { - Complete(v) => v, - m @ ValueMissing { .. } => return Ok(m), - }; - - // Nested substitution - let rep = match substitute_line(&rep, vars)? { - Complete(v) => v, - m @ ValueMissing { .. } => return Ok(m), - }; - output.push_str(&rep); - - slice = &slice[end..]; - } - } - } - - Ok(Complete(output)) -} - -#[derive(Debug)] -struct Pair { - open: String, - close: String, -} - -#[derive(Debug)] -struct ListSyntax { - separator: String, - pair: Option, -} - -fn parse_list_syntax(s: &str) -> anyhow::Result { - let first_open = s.find('[').context("Invalid list syntax")?; - let first_close = s[first_open..] - .find(']') - .map(|i| i + first_open) - .context("Invalid list syntax")?; - - let separator = &s[first_open + 1..first_close]; - - let Some(second_open) = s[first_close..].find('[').map(|i| i + first_close) - else { - return Ok(ListSyntax { - separator: separator.to_string(), - pair: None, - }); - }; - - let second_close = s[second_open..] - .find(']') - .map(|i| i + second_open) - .context("Invalid list syntax")?; - - let Some(third_open) = - s[second_close..].find('[').map(|i| i + second_close) - else { - return Ok(ListSyntax { - separator: separator.to_string(), - pair: Some(Pair { - open: s[second_open + 1..second_close].to_string(), - close: s[second_open + 1..second_close].to_string(), - }), - }); + let last_missing = Arc::new(Mutex::new(None::)); + let ctx = TrackingContext { + vars: vars.clone(), + last_missing: last_missing.clone(), }; - let third_close = s[third_open..] - .find(']') - .map(|i| i + third_open) - .context("Invalid list syntax")?; - - Ok(ListSyntax { - separator: separator.to_string(), - pair: Some(Pair { - open: s[second_open + 1..second_close].to_string(), - close: s[third_open + 1..third_close].to_string(), - }), - }) -} - -fn substitute_inner( - inner: &str, - vars: &HashMap>, -) -> Substitution { - let mut parts = inner.split('|'); + let mut env = Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Strict); + env.set_keep_trailing_newline(true); - // Only valid with ascii_alphabetic, ascii_digit or underscores in key name - let valid_character = |c: &char| -> bool { - c.is_ascii_alphabetic() || c.is_ascii_digit() || *c == '_' - }; - - let key = parts.next().unwrap_or("").trim(); - let parsed_key = key - .chars() - .skip_while(|c| !valid_character(c)) - .take_while(valid_character) - .collect::(); - - let fallback = parts.next().map(str::trim); - - let list_syntax = parse_list_syntax(key); - - let substitution = vars.get(&parsed_key).map(|v| match v { - SubstitutionValue::Single(s) => key.replace(&parsed_key, s), - SubstitutionValue::Multiple(list) => { - let syntax = list_syntax.as_ref().unwrap(); - - let start = inner.find(parsed_key.as_str()).unwrap(); - let end = inner.rfind(']').unwrap(); - let joined = list - .iter() - .map(|s| { - if let Some(pair) = &syntax.pair { - format!("{}{}{}", pair.open, s, pair.close) - } else { - s.to_string() - } - }) - .collect::>() - .join(&syntax.separator); + let ctx_val = Value::from_object(ctx); - key.replace(&inner[start..=end], &joined) + match env.render_str(input, ctx_val) { + Ok(output) => Ok(Complete(output)), + Err(e) => { + if let Some(key) = last_missing.lock().unwrap().take() { + return Ok(ValueMissing { key }); + } + Err(e.into()) } - }); - - match substitution { - Some(s) => Complete(s), - None => ValueMissing { - key: parsed_key, - fallback: fallback.map(ToString::to_string), - multiple: list_syntax.is_ok(), - }, } } @@ -324,35 +179,26 @@ fn substitute_inner( mod tests { use super::*; - fn create_vars() -> HashMap> { + fn create_vars() -> HashMap { let mut vars = HashMap::new(); + vars.insert("url".to_string(), Value::from("example.com")); + vars.insert("token".to_string(), Value::from("abc123")); + vars.insert("integer".to_string(), Value::from(42i64)); + vars.insert("api_url1".to_string(), Value::from("foo.com")); vars.insert( - "url".to_string(), - SubstitutionValue::Single("example.com".to_string()), - ); - vars.insert( - "token".to_string(), - SubstitutionValue::Single("abc123".to_string()), - ); - vars.insert( - "integer".to_string(), - SubstitutionValue::Single("42".to_string()), - ); - vars.insert( - "api_url1".to_string(), - SubstitutionValue::Single("foo.com".to_string()), - ); - vars.insert( - "nested".to_string(), - SubstitutionValue::Single("the answer is {{integer}}".to_string()), + "list".to_string(), + Value::from(vec![ + Value::from("1"), + Value::from("2"), + Value::from("3"), + ]), ); vars.insert( - "list".to_string(), - SubstitutionValue::Multiple(vec![ - "1".to_string(), - "2".to_string(), - "3".to_string(), + "label".to_string(), + Value::from_serialize(&vec![ + serde_json::json!({"value": "bug", "name": "bug"}), + serde_json::json!({"value": "docs", "name": "documentation"}), ]), ); @@ -370,7 +216,7 @@ mod tests { #[test] fn substitutes_single_variable() { let vars = create_vars(); - let res = substitute("foo {{url}}\nbar\n", &vars).unwrap(); + let res = substitute("foo {{ url }}\nbar\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com\nbar\n".to_string())); } @@ -378,57 +224,48 @@ mod tests { #[test] fn substitutes_integer() { let vars = create_vars(); - let res = substitute("foo={{integer}}", &vars).unwrap(); + let res = substitute("foo={{ integer }}", &vars).unwrap(); - assert_eq!(res, Complete("foo=42\n".to_string())); + assert_eq!(res, Complete("foo=42".to_string())); } #[test] fn substitutes_placeholder_with_default_value() { let vars = create_vars(); - let res = substitute("foo: {{url | fallback.com}}\n", &vars).unwrap(); + let res = + substitute("foo: {{ url | default('fallback.com') }}\n", &vars) + .unwrap(); assert_eq!(res, Complete("foo: example.com\n".to_string())); } #[test] - fn substitutes_default_value() { + fn uses_default_when_value_missing() { let vars = create_vars(); - let res = substitute("foo: {{href | fallback.com }}\n", &vars).unwrap(); + let res = + substitute("foo: {{ href | default('fallback.com') }}\n", &vars) + .unwrap(); - assert_eq!( - res, - ValueMissing { - key: "href".to_string(), - fallback: Some("fallback.com".to_string()), - multiple: false, - } - ); + assert_eq!(res, Complete("foo: fallback.com\n".to_string())); } #[test] - fn substitutes_default_value_multiple() { + fn returns_value_missing_for_missing_variable() { let vars = create_vars(); - let res = substitute( - r#"foo: {{href | "fallback.com", "foobar.com" }}\n"#, - &vars, - ) - .unwrap(); + let res = substitute("foo: {{ href }}\n", &vars).unwrap(); assert_eq!( res, ValueMissing { key: "href".to_string(), - fallback: Some("\"fallback.com\", \"foobar.com\"".to_string()), - multiple: false, } ); } #[test] - fn substitutes_single_variable_with_speces() { + fn substitutes_single_variable_with_spaces() { let vars = create_vars(); - let res = substitute("foo {{ url }}\nbar\n", &vars).unwrap(); + let res = substitute("foo {{url}}\nbar\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com\nbar\n".to_string())); } @@ -436,7 +273,8 @@ mod tests { #[test] fn substitutes_one_variable_per_line() { let vars = create_vars(); - let res = substitute("foo {{url}}\nbar {{token}}\n", &vars).unwrap(); + let res = + substitute("foo {{ url }}\nbar {{ token }}\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com\nbar abc123\n".to_string())); } @@ -444,152 +282,69 @@ mod tests { #[test] fn substitutes_variable_on_the_same_line() { let vars = create_vars(); - let res = substitute("foo {{url}}, bar {{token}}\n", &vars).unwrap(); + let res = + substitute("foo {{ url }}, bar {{ token }}\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com, bar abc123\n".to_string())); } - #[test] - fn substitutes_nested_variable() { - let vars = create_vars(); - let res = substitute("# {{nested}}!\n", &vars).unwrap(); - - assert_eq!(res, Complete("# the answer is 42!\n".to_string())); - } - - #[test] - fn substitutes_only_characters_inside_quotes() { - let vars = create_vars(); - let res = substitute("foo: {{ \"integer\" }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: \"42\"\n".to_string())); - } - - #[test] - fn substitutes_only_characters_inside_list() { - let vars = create_vars(); - let res = substitute("foo: {{ [url] }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: [example.com]\n".to_string())); - } - - #[test] - fn substitutes_only_characters_inside_list_inside_quotes() { - let vars = create_vars(); - let res = substitute("foo: {{ [\"url\"] }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: [\"example.com\"]\n".to_string())); - } - - #[test] - fn substitutes_variable_on_the_same_line_in_list() { - let vars = create_vars(); - let res = substitute("foo: [{{ \"url\" }}, {{ \"integer\" }}]", &vars) - .unwrap(); - - assert_eq!( - res, - Complete("foo: [\"example.com\", \"42\"]\n".to_string()) - ); - } - - #[test] - fn substitutes_only_numbers_inside_quote() { - let vars = create_vars(); - let res = substitute("foo: {{ \"integer\" }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: \"42\"\n".to_string())); - } - #[test] fn substitutes_variable_with_underscore_and_number_in_name() { let vars = create_vars(); let res = substitute("foo: {{ api_url1 }}", &vars).unwrap(); - assert_eq!(res, Complete("foo: foo.com\n".to_string())); + assert_eq!(res, Complete("foo: foo.com".to_string())); } #[test] - fn fails_for_unmatched_open() { + fn substitutes_list_joined() { let vars = create_vars(); - let res = substitute("foo {{url\n", &vars); + let res = substitute("foo: {{ list | join('') }}", &vars).unwrap(); - assert!(res.is_err()); - } - - #[test] - fn fails_for_unmatched_close() { - let vars = create_vars(); - let res = substitute("foo url}} bar\n", &vars); - - assert!(res.is_err()); - } - - #[test] - fn substitutes_list() { - let vars = create_vars(); - let res = substitute("foo: {{ list[] }}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: 123\n".to_string())); + assert_eq!(res, Complete("foo: 123".to_string())); } #[test] fn substitutes_comma_separated_list() { let vars = create_vars(); - let res = substitute("foo: [ {{ list [, ] }} ]", &vars).unwrap(); - - assert_eq!(res, Complete("foo: [ 1, 2, 3 ]\n".to_string())); - } - - #[test] - fn substitutes_list_multi_char_separator() { - let vars = create_vars(); - let res = substitute("foo: {{ list [>>, <<]}}", &vars).unwrap(); - - assert_eq!(res, Complete("foo: 1>>, <<2>>, <<3\n".to_string())); - } - - #[test] - fn substitutes_list_custom_open_pair() { - let vars = create_vars(); - let res = substitute("foo: {{ list [:] ['] }}", &vars).unwrap(); + let res = + substitute("foo: [ {{ list | join(', ') }} ]", &vars).unwrap(); - assert_eq!(res, Complete("foo: '1':'2':'3'\n".to_string())); + assert_eq!(res, Complete("foo: [ 1, 2, 3 ]".to_string())); } #[test] - fn substitutes_list_custom_open_and_close_pair() { + fn substitutes_list_quoted_join() { let vars = create_vars(); - let res = - substitute("foo: {{ list [ - ] [<<][>>] }}", &vars).unwrap(); + let res = substitute( + r#"foo: ["{{ list | join('", "') }}"]"#, + &vars, + ) + .unwrap(); - assert_eq!(res, Complete("foo: <<1>> - <<2>> - <<3>>\n".to_string())); + assert_eq!( + res, + Complete(r#"foo: ["1", "2", "3"]"#.to_string()) + ); } #[test] - fn substitutes_list_default_value_multiple() { + fn substitutes_list_of_objects() { let vars = create_vars(); let res = substitute( - "foo: {{ missing_list [ - ] [<<][>>] | 9 8 7 }}", + r#"{% for l in label %}"{{ l.value }}"{% if not loop.last %}, {% endif %}{% endfor %}"#, &vars, ) .unwrap(); - assert_eq!( - res, - ValueMissing { - key: "missing_list".to_string(), - fallback: Some("9 8 7".to_string()), - multiple: true, - } - ); + assert_eq!(res, Complete(r#""bug", "docs""#.to_string())); } #[test] - fn substitutes_list_default_value_multiple_with_separator() { + fn returns_value_missing_when_var_missing_but_other_has_default() { let vars = create_vars(); let res = substitute( - "foo: [ {{ missing_list [ - ] [<<][>>] | \"9\", \"8\", \"7\" }} ]", + "{{ url | default('x') }} {{ missing }}", &vars, ) .unwrap(); @@ -597,25 +352,16 @@ mod tests { assert_eq!( res, ValueMissing { - key: "missing_list".to_string(), - fallback: Some("\"9\", \"8\", \"7\"".to_string()), - multiple: true, + key: "missing".to_string(), } ); } #[test] - fn substitutes_list_creates_object() { + fn fails_for_template_syntax_error() { let vars = create_vars(); - let res = - substitute("foo: {{ list [, ] [{ \"Id\": \"] [\" }] }}", &vars) - .unwrap(); + let res = substitute("{% if %}", &vars); - assert_eq!( - res, - Complete( - "foo: { \"Id\": \"1\" }, { \"Id\": \"2\" }, { \"Id\": \"3\" }\n".to_string() - ) - ); + assert!(res.is_err()); } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 777c568..4fbe4b3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -36,6 +36,7 @@ use hitman::{ SubstitutionValue, }, }; +use minijinja::Value as JinjaValue; use super::{ centered, @@ -46,9 +47,7 @@ use super::{ output::{HttpRequestInfo, RequestStatus}, progress::Progress, prompt::SimplePrompt, - select::{ - PromptSelectItem, RequestSelector, Select, SelectIntent, SelectItem, - }, + select::{RequestSelector, Select, SelectIntent, SelectItem}, Component, InteractiveComponent, PromptComponent, PromptIntent, }; @@ -80,7 +79,7 @@ pub enum AppState { PendingValue { file_path: String, key: String, - pending_vars: HashMap>, + pending_vars: HashMap, component: Box, }, @@ -106,12 +105,12 @@ pub enum Intent { PreviewRequest(Option), PrepareRequest { file_path: String, - vars: HashMap>, + vars: HashMap, }, AskForValue { key: String, file_path: String, - pending_vars: HashMap>, + pending_vars: HashMap, params: AskForValueParams, }, SendRequest { @@ -128,8 +127,8 @@ pub enum Intent { } pub enum AskForValueParams { - Prompt { fallback: Option }, - Select { values: Vec, multiple: bool }, + Prompt, + Select { values: Vec }, } impl App { @@ -321,7 +320,7 @@ impl App { fn try_request( &self, file_path: String, - mut vars: HashMap>, + mut vars: HashMap, ) -> Result> { // FIXME Call resolve_path and load_env once, and keep result in state @@ -333,16 +332,12 @@ impl App { resolved, prepared_request, }), - ValueMissing { - key, - fallback, - multiple, - } => { + ValueMissing { key } => { let scope = load_env(&self.target, &resolved, &[])?; match scope.lookup(&key)? { Replacement::Value(value) => { - vars.insert(key, SubstitutionValue::Single(value)); + vars.insert(key, JinjaValue::from(value)); Some(Intent::PrepareRequest { file_path, vars }) } Replacement::MultipleValuesFound { key, values } => { @@ -350,10 +345,7 @@ impl App { key, file_path, pending_vars: vars, - params: AskForValueParams::Select { - values, - multiple, - }, + params: AskForValueParams::Select { values }, }) } Replacement::ValueNotFound { key } => { @@ -361,7 +353,7 @@ impl App { key, file_path, pending_vars: vars, - params: AskForValueParams::Prompt { fallback }, + params: AskForValueParams::Prompt, }) } } @@ -444,26 +436,22 @@ fn create_prompt_component( params: AskForValueParams, ) -> Box { match params { - AskForValueParams::Select { values, multiple } => Box::new( + AskForValueParams::Select { values } => Box::new( Select::new( format!("Select substitution value for {{{{{key}}}}}"), key.into(), values, ) - .with_multiple(multiple), + .with_multiple(true), ), - AskForValueParams::Prompt { fallback } => { + AskForValueParams::Prompt => { if key.ends_with("_date") || key.ends_with("Date") { - Box::new( - DatePicker::new(format!("Select {{{{{key}}}}}")) - .with_fallback(fallback), - ) + Box::new(DatePicker::new(format!("Select {{{{{key}}}}}"))) } else { - Box::new( - SimplePrompt::new(format!("Enter value for {{{{{key}}}}}")) - .with_fallback(fallback), - ) + Box::new(SimplePrompt::new(format!( + "Enter value for {{{{{key}}}}}" + ))) } } } @@ -561,14 +549,13 @@ impl InteractiveComponent for App { PromptIntent::Abort => { return Some(Abort); } - PromptIntent::Accept(s) => match s { - SubstitutionValue::Single(s) => { - return Some(AcceptNewRequest(s)) - } - SubstitutionValue::Multiple(_) => { - unreachable!("Can never accept multiple requests") - } - }, + PromptIntent::Accept(v) => { + let s = v + .as_str() + .unwrap_or_default() + .to_string(); + return Some(AcceptNewRequest(s)); + } } } } @@ -606,7 +593,7 @@ impl InteractiveComponent for App { fn convert_prompt_intent( key: &str, file_path: &str, - pending_vars: &HashMap>, + pending_vars: &HashMap, intent: PromptIntent, ) -> Intent { match intent { @@ -791,15 +778,3 @@ impl SelectItem for Value { } } -impl PromptSelectItem for Value { - fn to_value(&self) -> String { - match self { - Self::Table(t) => match t.get("value") { - Some(Self::String(value)) => value.clone(), - Some(value) => value.to_string(), - _ => t.to_string(), - }, - other => other.to_string(), - } - } -} diff --git a/src/ui/datepicker.rs b/src/ui/datepicker.rs index 07e2524..f9690ac 100644 --- a/src/ui/datepicker.rs +++ b/src/ui/datepicker.rs @@ -1,5 +1,5 @@ use chrono::{Datelike, Days, Local, Months, NaiveDate, Weekday}; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Layout}, prelude::{Alignment::Center, Margin}, @@ -26,15 +26,6 @@ impl DatePicker { selected: Local::now().date_naive(), } } - - pub fn with_fallback(self, fallback: Option) -> Self { - Self { - selected: fallback - .and_then(|f| f.parse::().ok()) - .unwrap_or(self.selected), - ..self - } - } } impl Component for DatePicker { @@ -153,7 +144,7 @@ impl PromptComponent for DatePicker { self.selected.checked_add_days(Days::new(1)).unwrap(); } KeyMapping::Accept => { - return Some(PromptIntent::Accept(SubstitutionValue::Single( + return Some(PromptIntent::Accept(JinjaValue::from( format!("{}", self.selected), ))); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ea735d7..d7449ef 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,5 @@ use crossterm::event::Event; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, Frame, @@ -26,7 +26,7 @@ pub trait InteractiveComponent: Component { pub enum PromptIntent { Abort, - Accept(SubstitutionValue), + Accept(JinjaValue), } pub trait PromptComponent: Component { diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index fae8ee6..43f9a47 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -1,5 +1,5 @@ use crossterm::event::Event; -use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::Rect, style::Stylize, @@ -30,21 +30,12 @@ impl SimplePrompt { } } - pub fn with_fallback(self, value: Option) -> Self { - Self { - fallback: value, - ..self - } - } - - fn value(&self) -> SubstitutionValue { + fn value(&self) -> JinjaValue { let input_value = self.input.value().to_string(); if input_value.is_empty() { - SubstitutionValue::Single( - self.fallback.clone().unwrap_or(input_value), - ) + JinjaValue::from(self.fallback.clone().unwrap_or(input_value)) } else { - SubstitutionValue::Single(input_value) + JinjaValue::from(input_value) } } } diff --git a/src/ui/select.rs b/src/ui/select.rs index e922d25..e9d54b7 100644 --- a/src/ui/select.rs +++ b/src/ui/select.rs @@ -1,6 +1,7 @@ use crossterm::event::Event; use fuzzy_matcher::skim::SkimMatcherV2; use hitman::substitute::SubstitutionValue; +use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Style, Stylize}, @@ -92,10 +93,6 @@ pub trait SelectItem { fn text(&self) -> String; } -pub trait PromptSelectItem: SelectItem { - fn to_value(&self) -> String; -} - impl SelectItem for String { fn text(&self) -> String { self.clone() @@ -378,7 +375,16 @@ impl PromptComponent for Select { fn handle_prompt(&mut self, event: &Event) -> Option { self.handle_event(event).and_then(|intent| match intent { SelectIntent::Abort => Some(PromptIntent::Abort), - SelectIntent::Accept(item) => Some(PromptIntent::Accept(item)), + SelectIntent::Accept(item) => match item { + SubstitutionValue::Single(s) => { + Some(PromptIntent::Accept(JinjaValue::from(s))) + } + SubstitutionValue::Multiple(ss) => { + let items: Vec = + ss.into_iter().map(JinjaValue::from).collect(); + Some(PromptIntent::Accept(JinjaValue::from(items))) + } + }, SelectIntent::Change(_) => None, }) } @@ -390,14 +396,14 @@ impl PromptComponent for Select { SelectIntent::Abort => Some(PromptIntent::Abort), SelectIntent::Accept(item) => match item { SubstitutionValue::Single(v) => Some(PromptIntent::Accept( - SubstitutionValue::Single(v.to_value()), + JinjaValue::from_serialize(&v), )), SubstitutionValue::Multiple(vs) => { - Some(PromptIntent::Accept(SubstitutionValue::Multiple( - vs.into_iter() - .map(|v| v.to_value()) - .collect::>(), - ))) + let items: Vec = vs + .into_iter() + .map(|v| JinjaValue::from_serialize(&v)) + .collect(); + Some(PromptIntent::Accept(JinjaValue::from(items))) } }, SelectIntent::Change(_) => None, From a1efaf4753dccc6151513eeef26baf043f83a53d Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 18:41:11 +0100 Subject: [PATCH 02/15] chore: format code with nightly cargo fmt --- src/cli.rs | 2 +- src/env.rs | 6 ++++-- src/lib.rs | 1 - src/main.rs | 27 +++++++++++++++------------ src/prompt.rs | 1 - src/substitute.rs | 26 +++++++++----------------- src/ui/app.rs | 1 - src/ui/datepicker.rs | 7 ++++--- src/ui/select.rs | 6 +++--- 9 files changed, 36 insertions(+), 41 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index d69b131..926ee43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,7 +25,7 @@ pub struct Args { conflicts_with = "name", conflicts_with = "repeat", conflicts_with = "flurry", - conflicts_with = "watch", + conflicts_with = "watch" )] pub select: Option>, diff --git a/src/env.rs b/src/env.rs index 829e580..5a4965b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -51,7 +51,8 @@ impl CookieStore for HitmanCookieJar { } fn cookies(&self, _: &Url) -> Option { - let data_file = read_toml(&self.root_dir.join(DATA_FILE)).ok().flatten()?; + let data_file = + read_toml(&self.root_dir.join(DATA_FILE)).ok().flatten()?; match data_file.get(COOKIE_KEY)? { Value::Array(arr) => { @@ -213,7 +214,8 @@ fn merge(config: &mut TomlTable, other: TomlTable) { fn read_toml(file_path: &Path) -> Result> { match fs::read_to_string(file_path) { Ok(content) => { - let cfg = toml::from_str::(&content).with_context(|| format!("When reading {file_path:?}"))?; + let cfg = toml::from_str::(&content) + .with_context(|| format!("When reading {file_path:?}"))?; Ok(Some(cfg)) } diff --git a/src/lib.rs b/src/lib.rs index 066ec2f..8f79ee9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,4 +9,3 @@ pub mod substitute; pub mod util; pub mod prompt; - diff --git a/src/main.rs b/src/main.rs index 96fbb73..b8c9894 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,8 @@ use std::env::current_dir; use tokio::sync::mpsc; use hitman::env::{ - find_available_requests, get_target, load_env, - select_target, set_target, watch_list, + find_available_requests, get_target, load_env, select_target, set_target, + watch_list, }; use hitman::flurry::flurry_attack; use hitman::monitor::monitor; @@ -34,7 +34,8 @@ async fn main() -> Result<()> { set_interactive_mode(!(args.non_interactive || args.watch)); if let Some(arg) = args.select { - let root_dir = find_root_dir(¤t_dir()?)?.context("No hitman.toml found")?; + let root_dir = + find_root_dir(¤t_dir()?)?.context("No hitman.toml found")?; match arg { Some(target) => set_target(&root_dir, &target)?, @@ -49,7 +50,10 @@ async fn main() -> Result<()> { let file_path = cwd.join(file_path); let resolved = resolve_path(&file_path)?; - let target = args.target.clone().unwrap_or_else(|| get_target(&resolved.root_dir)); + let target = args + .target + .clone() + .unwrap_or_else(|| get_target(&resolved.root_dir)); if let Some(flurry_size) = args.flurry { let scope = load_env(&target, &resolved, &args.options)?; @@ -64,8 +68,7 @@ async fn main() -> Result<()> { let scope = load_env(&target, &resolved, &args.options)?; monitor(&resolved, delay_seconds, &scope).await } else { - let res = - run_once(&target, &resolved, &args.options).await; + let res = run_once(&target, &resolved, &args.options).await; if args.watch { watch_mode(&target, &resolved, &args.options).await @@ -90,10 +93,12 @@ async fn main() -> Result<()> { let file_path = &files[selected.index]; let resolved = resolve_path(file_path)?; - let target = args.target.clone().unwrap_or_else(|| get_target(&resolved.root_dir)); + let target = args + .target + .clone() + .unwrap_or_else(|| get_target(&resolved.root_dir)); - let result = - run_once(&target, &resolved, &args.options).await; + let result = run_once(&target, &resolved, &args.options).await; if !args.repeat { break result; @@ -154,9 +159,7 @@ async fn watch_mode( if let Some(event) = rx.recv().await { if let EventKind::Modify(_) = event.kind { watcher.unwatch_all()?; - if let Err(err) = - run_once(target, resolved, options).await - { + if let Err(err) = run_once(target, resolved, options).await { error!("# {err}"); } watcher.watch_all()?; diff --git a/src/prompt.rs b/src/prompt.rs index 4a8d2b7..2efa41a 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -216,7 +216,6 @@ fn values_to_list_options(values: &[Value]) -> Vec> { .collect() } - #[cfg(test)] mod tests { use super::*; diff --git a/src/substitute.rs b/src/substitute.rs index 66baed4..a1910d5 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -57,9 +57,10 @@ impl Object for TrackingContext { Some(v) => Some(v.clone()), None => { // Track the last missing key. Using "last" rather than - // "first" correctly handles {{ var | default('x') }} {{ other }}: - // the default filter handles `var` silently, then `other` - // causes the render to fail, and we report `other`. + // "first" correctly handles {{ var | default('x') }} {{ other + // }}: the default filter handles `var` + // silently, then `other` causes the render to + // fail, and we report `other`. *self.last_missing.lock().unwrap() = Some(key_str.to_string()); Some(Value::UNDEFINED) } @@ -316,16 +317,10 @@ mod tests { #[test] fn substitutes_list_quoted_join() { let vars = create_vars(); - let res = substitute( - r#"foo: ["{{ list | join('", "') }}"]"#, - &vars, - ) - .unwrap(); + let res = + substitute(r#"foo: ["{{ list | join('", "') }}"]"#, &vars).unwrap(); - assert_eq!( - res, - Complete(r#"foo: ["1", "2", "3"]"#.to_string()) - ); + assert_eq!(res, Complete(r#"foo: ["1", "2", "3"]"#.to_string())); } #[test] @@ -343,11 +338,8 @@ mod tests { #[test] fn returns_value_missing_when_var_missing_but_other_has_default() { let vars = create_vars(); - let res = substitute( - "{{ url | default('x') }} {{ missing }}", - &vars, - ) - .unwrap(); + let res = substitute("{{ url | default('x') }} {{ missing }}", &vars) + .unwrap(); assert_eq!( res, diff --git a/src/ui/app.rs b/src/ui/app.rs index 4fbe4b3..5f4d961 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -777,4 +777,3 @@ impl SelectItem for Value { } } } - diff --git a/src/ui/datepicker.rs b/src/ui/datepicker.rs index f9690ac..47370aa 100644 --- a/src/ui/datepicker.rs +++ b/src/ui/datepicker.rs @@ -144,9 +144,10 @@ impl PromptComponent for DatePicker { self.selected.checked_add_days(Days::new(1)).unwrap(); } KeyMapping::Accept => { - return Some(PromptIntent::Accept(JinjaValue::from( - format!("{}", self.selected), - ))); + return Some(PromptIntent::Accept(JinjaValue::from(format!( + "{}", + self.selected + )))); } KeyMapping::Abort => { return Some(PromptIntent::Abort); diff --git a/src/ui/select.rs b/src/ui/select.rs index e9d54b7..6c02647 100644 --- a/src/ui/select.rs +++ b/src/ui/select.rs @@ -395,9 +395,9 @@ impl PromptComponent for Select { self.handle_event(event).and_then(|intent| match intent { SelectIntent::Abort => Some(PromptIntent::Abort), SelectIntent::Accept(item) => match item { - SubstitutionValue::Single(v) => Some(PromptIntent::Accept( - JinjaValue::from_serialize(&v), - )), + SubstitutionValue::Single(v) => { + Some(PromptIntent::Accept(JinjaValue::from_serialize(&v))) + } SubstitutionValue::Multiple(vs) => { let items: Vec = vs .into_iter() From 9c746972919639038fe4f8f112714b28324841f1 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 18:51:38 +0100 Subject: [PATCH 03/15] fix: remove some clippy warnings --- src/substitute.rs | 2 +- src/ui/output.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/substitute.rs b/src/substitute.rs index a1910d5..786a2b9 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -197,7 +197,7 @@ mod tests { ); vars.insert( "label".to_string(), - Value::from_serialize(&vec![ + Value::from_serialize(vec![ serde_json::json!({"value": "bug", "name": "bug"}), serde_json::json!({"value": "docs", "name": "documentation"}), ]), diff --git a/src/ui/output.rs b/src/ui/output.rs index 876922c..310d602 100644 --- a/src/ui/output.rs +++ b/src/ui/output.rs @@ -58,7 +58,7 @@ pub enum Content { #[default] Empty, Preview(String), - Request(HttpRequestInfo), + Request(Box), } pub struct OutputView { @@ -91,7 +91,7 @@ impl OutputView { } self.scroll = (0, 0); - self.content = Content::Request(info); + self.content = Content::Request(Box::new(info)); } pub fn reset(&mut self) { @@ -131,7 +131,7 @@ impl OutputView { s } - fn make_lines(&self) -> Vec { + fn make_lines(&self) -> Vec> { let mut lines: Vec = Vec::new(); match &self.content { @@ -272,7 +272,7 @@ impl SyntaxHighlighter { } } - fn lines(&self) -> Option> { + fn lines(&self) -> Option>> { self.cache.as_ref().map(|lines| { lines .iter() From 9bf67b926ace2eee242dff38fe3fc1b45449849e Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 21:34:55 +0100 Subject: [PATCH 04/15] fix: simplify the example for lists --- example/create_issue.http | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/create_issue.http b/example/create_issue.http index 23fdb95..e963aeb 100644 --- a/example/create_issue.http +++ b/example/create_issue.http @@ -4,5 +4,5 @@ Accept: application/vnd.github+json { "title": "Found a bug", "body": "I'm having a problem with this.", - "labels": [{% for l in label %}"{{ l.value }}"{% if not loop.last %}, {% endif %}{% endfor %}], + "labels": ["{{ label | map(attribute="value") | join('", "') }}"], } From 36e2bf3e67d846787a5c21767f90239e48eb6555 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:00:56 +0100 Subject: [PATCH 05/15] fix: use cell instead of arc --- src/substitute.rs | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/substitute.rs b/src/substitute.rs index 786a2b9..a883371 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -1,4 +1,5 @@ -use std::sync::{Arc, Mutex}; +use std::cell::Cell; +use std::sync::Arc; use anyhow::Context; use httparse::Status; @@ -33,9 +34,12 @@ pub enum SubstitutionValue { Multiple(Vec), } +thread_local! { + static MISSING: Cell> = const { Cell::new(None) }; +} + struct TrackingContext { vars: HashMap, - last_missing: Arc>>, } impl std::fmt::Debug for TrackingContext { @@ -56,12 +60,7 @@ impl Object for TrackingContext { match self.vars.get(key_str) { Some(v) => Some(v.clone()), None => { - // Track the last missing key. Using "last" rather than - // "first" correctly handles {{ var | default('x') }} {{ other - // }}: the default filter handles `var` - // silently, then `other` causes the render to - // fail, and we report `other`. - *self.last_missing.lock().unwrap() = Some(key_str.to_string()); + MISSING.set(Some(key_str.to_string())); Some(Value::UNDEFINED) } } @@ -153,11 +152,8 @@ pub fn substitute( input: &str, vars: &HashMap, ) -> anyhow::Result> { - let last_missing = Arc::new(Mutex::new(None::)); - let ctx = TrackingContext { - vars: vars.clone(), - last_missing: last_missing.clone(), - }; + MISSING.set(None); + let ctx = TrackingContext { vars: vars.clone() }; let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); @@ -168,7 +164,7 @@ pub fn substitute( match env.render_str(input, ctx_val) { Ok(output) => Ok(Complete(output)), Err(e) => { - if let Some(key) = last_missing.lock().unwrap().take() { + if let Some(key) = MISSING.take() { return Ok(ValueMissing { key }); } Err(e.into()) From 9fb7d7c38fde9ccc953b36e684481008f6d8138e Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:09:32 +0100 Subject: [PATCH 06/15] fix: move substitute struct into ui code Only place where it is used, so belong more in there than in substitute.rs --- src/substitute.rs | 20 +------------------- src/ui/app.rs | 8 +++++++- src/ui/select.rs | 3 ++- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/substitute.rs b/src/substitute.rs index a883371..b7cd259 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -27,33 +27,15 @@ pub enum Substitution { pub use Substitution::{Complete, ValueMissing}; -// Used by UI layer to track whether a single or multiple values were selected -#[derive(Debug, Clone)] -pub enum SubstitutionValue { - Single(T), - Multiple(Vec), -} - thread_local! { static MISSING: Cell> = const { Cell::new(None) }; } +#[derive(Debug)] struct TrackingContext { vars: HashMap, } -impl std::fmt::Debug for TrackingContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "TrackingContext") - } -} - -impl std::fmt::Display for TrackingContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "TrackingContext") - } -} - impl Object for TrackingContext { fn get_value(self: &Arc, key: &Value) -> Option { let key_str = key.as_str()?; diff --git a/src/ui/app.rs b/src/ui/app.rs index 5f4d961..84b8fbf 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -33,7 +33,6 @@ use hitman::{ substitute::{ prepare_request, Substitution::{Complete, ValueMissing}, - SubstitutionValue, }, }; use minijinja::Value as JinjaValue; @@ -51,6 +50,13 @@ use super::{ Component, InteractiveComponent, PromptComponent, PromptIntent, }; +// Used by UI layer to track whether a single or multiple values were selected +#[derive(Debug, Clone)] +pub enum SubstitutionValue { + Single(T), + Multiple(Vec), +} + pub trait Screen { type B: Backend; diff --git a/src/ui/select.rs b/src/ui/select.rs index 6c02647..93f8829 100644 --- a/src/ui/select.rs +++ b/src/ui/select.rs @@ -1,6 +1,5 @@ use crossterm::event::Event; use fuzzy_matcher::skim::SkimMatcherV2; -use hitman::substitute::SubstitutionValue; use minijinja::Value as JinjaValue; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -15,6 +14,8 @@ use ratatui::{ }; use tui_input::{backend::crossterm::EventHandler, Input}; +use crate::ui::app::SubstitutionValue; + use super::{ keymap::{mapkey, KeyMapping}, Component, InteractiveComponent, PromptComponent, PromptIntent, From 323f36b5dd252835431bea949b2db4fe9e810be5 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:17:40 +0100 Subject: [PATCH 07/15] fix: remove dead code We are never setting fallback to anything other than `None`, so the code was essentially dead. Remove it. Jinja handles fallback itself --- src/ui/prompt.rs | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 43f9a47..3ca771a 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -17,7 +17,6 @@ use super::{ pub struct SimplePrompt { title: String, - fallback: Option, input: Input, } @@ -25,18 +24,12 @@ impl SimplePrompt { pub fn new(title: String) -> Self { Self { title, - fallback: None, input: Input::default(), } } fn value(&self) -> JinjaValue { - let input_value = self.input.value().to_string(); - if input_value.is_empty() { - JinjaValue::from(self.fallback.clone().unwrap_or(input_value)) - } else { - JinjaValue::from(input_value) - } + JinjaValue::from(self.input.value().to_string()) } } @@ -48,17 +41,10 @@ impl Component for SimplePrompt { let inner = block.inner(area); let input_value = self.input.value(); - let mut spans = Vec::new(); - spans.push(Span::from("> ")); - spans.push(Span::from(input_value)); + let spans = + vec![Span::from("> ").cyan(), Span::from(input_value).white()]; let cur = spans[0].width() as u16; - if input_value.is_empty() { - if let Some(value) = &self.fallback { - spans.push(Span::from(value).dark_gray()); - } - } - frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(Line::from(spans)).white().block(block), From 19cc9f975d0572aafbf2b9de6826b8365337854c Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:33:15 +0100 Subject: [PATCH 08/15] fix: add back comments --- src/substitute.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/substitute.rs b/src/substitute.rs index b7cd259..225a7cd 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -113,6 +113,8 @@ pub fn prepare_request( let mut headers = HeaderMap::new(); for header in req.headers { + // The parse_http crate is weird, it fills the array with empty headers + // if a partial request is parsed. if header.name.is_empty() { break; } From de690208c4d46fdd80f6b4e4ecb80e0043fe94fb Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:51:40 +0100 Subject: [PATCH 09/15] fix: add custom filter for select_one and select_multiple Need a way to define if we are selecting one or multiple values from a list. We can add custom filters to jinja! --- src/prompt.rs | 26 +++++++++++++++++++------- src/substitute.rs | 42 ++++++++++++++++++++++++++++++++++++++---- src/ui/app.rs | 13 ++++++++----- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/prompt.rs b/src/prompt.rs index 2efa41a..1f3aca0 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -47,7 +47,7 @@ pub fn get_interaction() -> Box { pub trait UserInteraction { fn prompt(&self, key: &str) -> Result; - fn select(&self, key: &str, values: &[Value]) -> Result; + fn select(&self, key: &str, values: &[Value]) -> Result; fn select_multiple( &self, key: &str, @@ -68,14 +68,18 @@ where loop { match prepare_request(resolved, &vars)? { Complete(req) => return Ok(req), - ValueMissing { key } => { + ValueMissing { key, multiple } => { let value = match scope.lookup(&key)? { Replacement::Value(value) => JinjaValue::from(value), Replacement::ValueNotFound { key } => { JinjaValue::from(interaction.prompt(&key)?) } Replacement::MultipleValuesFound { key, values } => { - interaction.select_multiple(&key, &values)? + if multiple { + interaction.select_multiple(&key, &values)? + } else { + interaction.select(&key, &values)? + } } }; @@ -106,7 +110,7 @@ impl UserInteraction for NoUserInteraction { bail!("Replacement not found: {key}"); } - fn select(&self, key: &str, values: &[toml::Value]) -> Result { + fn select(&self, key: &str, values: &[toml::Value]) -> Result { let suggestions = self.get_suggestions(key, values); bail!("Replacement not selected: {key}\nSuggestions:\n{suggestions}"); } @@ -128,7 +132,7 @@ impl UserInteraction for CliUserInteraction { prompt_user(key) } - fn select(&self, key: &str, values: &[toml::Value]) -> Result { + fn select(&self, key: &str, values: &[toml::Value]) -> Result { select_replacement(key, values) } @@ -166,7 +170,7 @@ fn prompt_for_date(key: &str) -> Result> { Ok(res.map(formatter)) } -fn select_replacement(key: &str, values: &[Value]) -> Result { +fn select_replacement(key: &str, values: &[Value]) -> Result { let list_options = values_to_list_options(values); let selected = Select::new(&format!("Select value for {key}"), list_options) @@ -174,7 +178,15 @@ fn select_replacement(key: &str, values: &[Value]) -> Result { .with_page_size(15) .prompt()?; - Ok(JinjaValue::from_serialize(&values[selected.index]).to_string()) + let value = &values[selected.index]; + let jinja_val = match value { + Value::Table(t) => match t.get("value") { + Some(v) => JinjaValue::from_serialize(v), + None => JinjaValue::from_serialize(value), + }, + _ => JinjaValue::from_serialize(value), + }; + Ok(jinja_val) } fn select_replacement_multiple( diff --git a/src/substitute.rs b/src/substitute.rs index 225a7cd..29db3c5 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -22,13 +22,14 @@ use crate::{ #[derive(Debug, PartialEq, Eq)] pub enum Substitution { Complete(T), - ValueMissing { key: String }, + ValueMissing { key: String, multiple: bool }, } pub use Substitution::{Complete, ValueMissing}; thread_local! { static MISSING: Cell> = const { Cell::new(None) }; + static MISSING_MULTIPLE: Cell = const { Cell::new(false) }; } #[derive(Debug)] @@ -56,7 +57,9 @@ pub fn prepare_request( let input = read_to_string(resolved.http_file())?; let buf = match substitute(&input, vars)? { Complete(buf) => buf, - ValueMissing { key } => return Ok(ValueMissing { key }), + ValueMissing { key, multiple } => { + return Ok(ValueMissing { key, multiple }) + } }; let mut headers_buf = [httparse::EMPTY_HEADER; 64]; @@ -88,7 +91,10 @@ pub fn prepare_request( for key in args { let Some(value) = vars.get(&key.name) else { - return Ok(ValueMissing { key: key.name }); + return Ok(ValueMissing { + key: key.name, + multiple: false, + }); }; map.insert(key.name, serde_json::to_value(value)?); @@ -137,11 +143,20 @@ pub fn substitute( vars: &HashMap, ) -> anyhow::Result> { MISSING.set(None); + MISSING_MULTIPLE.set(false); let ctx = TrackingContext { vars: vars.clone() }; let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); env.set_keep_trailing_newline(true); + env.add_filter("select_multiple", |v: Value| { + MISSING_MULTIPLE.set(true); + v + }); + env.add_filter("select_one", |v: Value| { + MISSING_MULTIPLE.set(false); + v + }); let ctx_val = Value::from_object(ctx); @@ -149,7 +164,10 @@ pub fn substitute( Ok(output) => Ok(Complete(output)), Err(e) => { if let Some(key) = MISSING.take() { - return Ok(ValueMissing { key }); + return Ok(ValueMissing { + key, + multiple: MISSING_MULTIPLE.take(), + }); } Err(e.into()) } @@ -239,6 +257,7 @@ mod tests { res, ValueMissing { key: "href".to_string(), + multiple: false, } ); } @@ -325,6 +344,21 @@ mod tests { res, ValueMissing { key: "missing".to_string(), + multiple: false, + } + ); + } + + #[test] + fn returns_multiple_true_when_select_multiple_filter_used() { + let vars = create_vars(); + let res = substitute("{{ missing | select_multiple }}", &vars).unwrap(); + + assert_eq!( + res, + ValueMissing { + key: "missing".to_string(), + multiple: true, } ); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 84b8fbf..4e72652 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -134,7 +134,7 @@ pub enum Intent { pub enum AskForValueParams { Prompt, - Select { values: Vec }, + Select { values: Vec, multiple: bool }, } impl App { @@ -338,7 +338,7 @@ impl App { resolved, prepared_request, }), - ValueMissing { key } => { + ValueMissing { key, multiple } => { let scope = load_env(&self.target, &resolved, &[])?; match scope.lookup(&key)? { @@ -351,7 +351,10 @@ impl App { key, file_path, pending_vars: vars, - params: AskForValueParams::Select { values }, + params: AskForValueParams::Select { + values, + multiple, + }, }) } Replacement::ValueNotFound { key } => { @@ -442,13 +445,13 @@ fn create_prompt_component( params: AskForValueParams, ) -> Box { match params { - AskForValueParams::Select { values } => Box::new( + AskForValueParams::Select { values, multiple } => Box::new( Select::new( format!("Select substitution value for {{{{{key}}}}}"), key.into(), values, ) - .with_multiple(true), + .with_multiple(multiple), ), AskForValueParams::Prompt => { From 132f73dc5602741fa3bc5b5c87d08544b31253c3 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:55:40 +0100 Subject: [PATCH 10/15] fix: make diff output smaller Avoid inlining method just for the sake of it. Make diff a bit smaller and easier to look at --- src/prompt.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/prompt.rs b/src/prompt.rs index 1f3aca0..8eead94 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -178,15 +178,9 @@ fn select_replacement(key: &str, values: &[Value]) -> Result { .with_page_size(15) .prompt()?; - let value = &values[selected.index]; - let jinja_val = match value { - Value::Table(t) => match t.get("value") { - Some(v) => JinjaValue::from_serialize(v), - None => JinjaValue::from_serialize(value), - }, - _ => JinjaValue::from_serialize(value), - }; - Ok(jinja_val) + Ok(JinjaValue::from(list_option_to_string( + key, values, &selected, + )?)) } fn select_replacement_multiple( @@ -228,6 +222,21 @@ fn values_to_list_options(values: &[Value]) -> Vec> { .collect() } +fn list_option_to_string( + key: &str, + values: &[Value], + selected: &ListOption, +) -> Result { + match &values[selected.index] { + Value::Table(t) => match t.get("value") { + Some(Value::String(value)) => Ok(value.clone()), + Some(value) => Ok(value.to_string()), + _ => bail!("Replacement not found: {key}"), + }, + other => Ok(other.to_string()), + } +} + #[cfg(test)] mod tests { use super::*; From 19b7fc2b9955ec0b39cc7310732cf304b7fb041e Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Tue, 17 Mar 2026 22:59:21 +0100 Subject: [PATCH 11/15] fix: rename thread local variable and default to select single item --- src/substitute.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/substitute.rs b/src/substitute.rs index 29db3c5..152b3c7 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -29,7 +29,7 @@ pub use Substitution::{Complete, ValueMissing}; thread_local! { static MISSING: Cell> = const { Cell::new(None) }; - static MISSING_MULTIPLE: Cell = const { Cell::new(false) }; + static MULTIPLE: Cell = const { Cell::new(false) }; } #[derive(Debug)] @@ -143,18 +143,18 @@ pub fn substitute( vars: &HashMap, ) -> anyhow::Result> { MISSING.set(None); - MISSING_MULTIPLE.set(false); + MULTIPLE.set(false); let ctx = TrackingContext { vars: vars.clone() }; let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); env.set_keep_trailing_newline(true); env.add_filter("select_multiple", |v: Value| { - MISSING_MULTIPLE.set(true); + MULTIPLE.set(true); v }); env.add_filter("select_one", |v: Value| { - MISSING_MULTIPLE.set(false); + MULTIPLE.set(false); v }); @@ -166,7 +166,7 @@ pub fn substitute( if let Some(key) = MISSING.take() { return Ok(ValueMissing { key, - multiple: MISSING_MULTIPLE.take(), + multiple: MULTIPLE.take(), }); } Err(e.into()) From 151474c362b0ff7b1e13bfd61c92a014bc283483 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Wed, 18 Mar 2026 16:48:13 +0100 Subject: [PATCH 12/15] fix: implement fallback again --- example/get_issues.http | 2 +- src/prompt.rs | 31 +++++++++++++++-------- src/substitute.rs | 56 ++++++++++++++++++++++++++++++++++++++--- src/ui/app.rs | 24 ++++++++++++------ src/ui/datepicker.rs | 9 +++++++ src/ui/prompt.rs | 27 +++++++++++++++++--- 6 files changed, 124 insertions(+), 25 deletions(-) diff --git a/example/get_issues.http b/example/get_issues.http index be440a0..0da22aa 100644 --- a/example/get_issues.http +++ b/example/get_issues.http @@ -1,2 +1,2 @@ -GET {{ base_url }}/repos/{{ reponame }}/issues?page={{ page | default(1) }}&per_page=10 HTTP/1.1 +GET {{ base_url }}/repos/{{ reponame }}/issues?page={{ page | fallback(1) }}&per_page=10 HTTP/1.1 Accept: application/vnd.github+json diff --git a/src/prompt.rs b/src/prompt.rs index 8eead94..4380e03 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -46,7 +46,7 @@ pub fn get_interaction() -> Box { } pub trait UserInteraction { - fn prompt(&self, key: &str) -> Result; + fn prompt(&self, key: &str, fallback: Option<&str>) -> Result; fn select(&self, key: &str, values: &[Value]) -> Result; fn select_multiple( &self, @@ -68,12 +68,16 @@ where loop { match prepare_request(resolved, &vars)? { Complete(req) => return Ok(req), - ValueMissing { key, multiple } => { + ValueMissing { + key, + fallback, + multiple, + } => { let value = match scope.lookup(&key)? { Replacement::Value(value) => JinjaValue::from(value), - Replacement::ValueNotFound { key } => { - JinjaValue::from(interaction.prompt(&key)?) - } + Replacement::ValueNotFound { key } => JinjaValue::from( + interaction.prompt(&key, fallback.as_deref())?, + ), Replacement::MultipleValuesFound { key, values } => { if multiple { interaction.select_multiple(&key, &values)? @@ -106,7 +110,10 @@ impl NoUserInteraction { } impl UserInteraction for NoUserInteraction { - fn prompt(&self, key: &str) -> Result { + fn prompt(&self, key: &str, fallback: Option<&str>) -> Result { + if let Some(val) = fallback.map(ToString::to_string) { + return Ok(val); + } bail!("Replacement not found: {key}"); } @@ -128,8 +135,8 @@ impl UserInteraction for NoUserInteraction { pub struct CliUserInteraction; impl UserInteraction for CliUserInteraction { - fn prompt(&self, key: &str) -> Result { - prompt_user(key) + fn prompt(&self, key: &str, fallback: Option<&str>) -> Result { + prompt_user(key, fallback) } fn select(&self, key: &str, values: &[toml::Value]) -> Result { @@ -145,14 +152,18 @@ impl UserInteraction for CliUserInteraction { } } -fn prompt_user(key: &str) -> Result { +fn prompt_user(key: &str, fallback: Option<&str>) -> Result { + let fb = fallback.unwrap_or(""); + if key.ends_with("_date") || key.ends_with("Date") { if let Some(date) = prompt_for_date(key)? { return Ok(date); } } - let input = Text::new(&format!("Enter value for {key}")).prompt()?; + let input = Text::new(&format!("Enter value for {key}")) + .with_default(fb) + .prompt()?; Ok(input) } diff --git a/src/substitute.rs b/src/substitute.rs index 152b3c7..4f679fd 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -22,7 +22,11 @@ use crate::{ #[derive(Debug, PartialEq, Eq)] pub enum Substitution { Complete(T), - ValueMissing { key: String, multiple: bool }, + ValueMissing { + key: String, + multiple: bool, + fallback: Option, + }, } pub use Substitution::{Complete, ValueMissing}; @@ -30,6 +34,7 @@ pub use Substitution::{Complete, ValueMissing}; thread_local! { static MISSING: Cell> = const { Cell::new(None) }; static MULTIPLE: Cell = const { Cell::new(false) }; + static FALLBACK: Cell> = const { Cell::new(None) }; } #[derive(Debug)] @@ -57,8 +62,16 @@ pub fn prepare_request( let input = read_to_string(resolved.http_file())?; let buf = match substitute(&input, vars)? { Complete(buf) => buf, - ValueMissing { key, multiple } => { - return Ok(ValueMissing { key, multiple }) + ValueMissing { + key, + multiple, + fallback, + } => { + return Ok(ValueMissing { + key, + multiple, + fallback, + }) } }; @@ -94,6 +107,7 @@ pub fn prepare_request( return Ok(ValueMissing { key: key.name, multiple: false, + fallback: None, }); }; @@ -144,6 +158,7 @@ pub fn substitute( ) -> anyhow::Result> { MISSING.set(None); MULTIPLE.set(false); + FALLBACK.set(None); let ctx = TrackingContext { vars: vars.clone() }; let mut env = Environment::new(); @@ -157,6 +172,12 @@ pub fn substitute( MULTIPLE.set(false); v }); + env.add_filter("fallback", |v: Value, fallback: String| { + if v.is_undefined() { + FALLBACK.set(Some(fallback)); + } + v + }); let ctx_val = Value::from_object(ctx); @@ -167,6 +188,7 @@ pub fn substitute( return Ok(ValueMissing { key, multiple: MULTIPLE.take(), + fallback: FALLBACK.take(), }); } Err(e.into()) @@ -258,6 +280,7 @@ mod tests { ValueMissing { key: "href".to_string(), multiple: false, + fallback: None, } ); } @@ -345,6 +368,7 @@ mod tests { ValueMissing { key: "missing".to_string(), multiple: false, + fallback: None, } ); } @@ -359,10 +383,36 @@ mod tests { ValueMissing { key: "missing".to_string(), multiple: true, + fallback: None, + } + ); + } + + #[test] + fn returns_fallback_when_fallback_filter_used() { + let vars = create_vars(); + let res = + substitute("{{ href | fallback('fallback.com') }}", &vars).unwrap(); + + assert_eq!( + res, + ValueMissing { + key: "href".to_string(), + multiple: false, + fallback: Some("fallback.com".to_string()), } ); } + #[test] + fn fallback_filter_is_noop_when_value_present() { + let vars = create_vars(); + let res = + substitute("{{ url | fallback('fallback.com') }}", &vars).unwrap(); + + assert_eq!(res, Complete("example.com".to_string())); + } + #[test] fn fails_for_template_syntax_error() { let vars = create_vars(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 4e72652..641fdb3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -133,7 +133,7 @@ pub enum Intent { } pub enum AskForValueParams { - Prompt, + Prompt { fallback: Option }, Select { values: Vec, multiple: bool }, } @@ -338,7 +338,11 @@ impl App { resolved, prepared_request, }), - ValueMissing { key, multiple } => { + ValueMissing { + key, + multiple, + fallback, + } => { let scope = load_env(&self.target, &resolved, &[])?; match scope.lookup(&key)? { @@ -362,7 +366,7 @@ impl App { key, file_path, pending_vars: vars, - params: AskForValueParams::Prompt, + params: AskForValueParams::Prompt { fallback }, }) } } @@ -454,13 +458,17 @@ fn create_prompt_component( .with_multiple(multiple), ), - AskForValueParams::Prompt => { + AskForValueParams::Prompt { fallback } => { if key.ends_with("_date") || key.ends_with("Date") { - Box::new(DatePicker::new(format!("Select {{{{{key}}}}}"))) + Box::new( + DatePicker::new(format!("Select {{{{{key}}}}}")) + .with_fallback(fallback), + ) } else { - Box::new(SimplePrompt::new(format!( - "Enter value for {{{{{key}}}}}" - ))) + Box::new( + SimplePrompt::new(format!("Enter value for {{{{{key}}}}}")) + .with_fallback(fallback), + ) } } } diff --git a/src/ui/datepicker.rs b/src/ui/datepicker.rs index 47370aa..ea6ed4d 100644 --- a/src/ui/datepicker.rs +++ b/src/ui/datepicker.rs @@ -26,6 +26,15 @@ impl DatePicker { selected: Local::now().date_naive(), } } + + pub fn with_fallback(self, fallback: Option) -> Self { + Self { + selected: fallback + .and_then(|f| f.parse::().ok()) + .unwrap_or(self.selected), + ..self + } + } } impl Component for DatePicker { diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 3ca771a..a8fdb8f 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -17,6 +17,7 @@ use super::{ pub struct SimplePrompt { title: String, + fallback: Option, input: Input, } @@ -24,12 +25,25 @@ impl SimplePrompt { pub fn new(title: String) -> Self { Self { title, + fallback: None, input: Input::default(), } } + pub fn with_fallback(self, value: Option) -> Self { + Self { + fallback: value, + ..self + } + } + fn value(&self) -> JinjaValue { - JinjaValue::from(self.input.value().to_string()) + let input_value = self.input.value().to_string(); + if input_value.is_empty() { + JinjaValue::from(self.fallback.clone().unwrap_or(input_value)) + } else { + JinjaValue::from(input_value) + } } } @@ -41,10 +55,17 @@ impl Component for SimplePrompt { let inner = block.inner(area); let input_value = self.input.value(); - let spans = - vec![Span::from("> ").cyan(), Span::from(input_value).white()]; + let mut spans = Vec::new(); + spans.push(Span::from("> ")); + spans.push(Span::from(input_value)); let cur = spans[0].width() as u16; + if input_value.is_empty() { + if let Some(value) = &self.fallback { + spans.push(Span::from(value).dark_gray()); + } + } + frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(Line::from(spans)).white().block(block), From 7f3dec94388a4fa5ab4a473ca73545451000df77 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Wed, 18 Mar 2026 17:05:41 +0100 Subject: [PATCH 13/15] fix: make diff smaller --- example/create_issue.http | 2 +- example/get_issues.http | 2 +- example/get_repos.http | 2 +- src/substitute.rs | 37 +++++++++++++++++++++---------------- src/ui/app.rs | 2 +- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/example/create_issue.http b/example/create_issue.http index e963aeb..70e896a 100644 --- a/example/create_issue.http +++ b/example/create_issue.http @@ -1,4 +1,4 @@ -POST {{ base_url }}/repos/{{ owner }}/{{ repo }}/issues HTTP/1.1 +POST {{base_url}}/repos/{{owner}}/{{repo}}/issues HTTP/1.1 Accept: application/vnd.github+json { diff --git a/example/get_issues.http b/example/get_issues.http index 0da22aa..631bb2d 100644 --- a/example/get_issues.http +++ b/example/get_issues.http @@ -1,2 +1,2 @@ -GET {{ base_url }}/repos/{{ reponame }}/issues?page={{ page | fallback(1) }}&per_page=10 HTTP/1.1 +GET {{base_url}}/repos/{{reponame}}/issues?page={{page | fallback(1)}}&per_page=10 HTTP/1.1 Accept: application/vnd.github+json diff --git a/example/get_repos.http b/example/get_repos.http index c4cde8a..8e8aac5 100644 --- a/example/get_repos.http +++ b/example/get_repos.http @@ -1,2 +1,2 @@ -GET {{ base_url }}/users/{{ username }}/repos HTTP/1.1 +GET {{base_url}}/users/{{username}}/repos HTTP/1.1 Accept: application/vnd.github+json diff --git a/src/substitute.rs b/src/substitute.rs index 4f679fd..dcc49b4 100644 --- a/src/substitute.rs +++ b/src/substitute.rs @@ -24,8 +24,8 @@ pub enum Substitution { Complete(T), ValueMissing { key: String, - multiple: bool, fallback: Option, + multiple: bool, }, } @@ -64,13 +64,13 @@ pub fn prepare_request( Complete(buf) => buf, ValueMissing { key, - multiple, fallback, + multiple, } => { return Ok(ValueMissing { key, - multiple, fallback, + multiple, }) } }; @@ -106,8 +106,8 @@ pub fn prepare_request( let Some(value) = vars.get(&key.name) else { return Ok(ValueMissing { key: key.name, - multiple: false, fallback: None, + multiple: false, }); }; @@ -237,7 +237,7 @@ mod tests { #[test] fn substitutes_single_variable() { let vars = create_vars(); - let res = substitute("foo {{ url }}\nbar\n", &vars).unwrap(); + let res = substitute("foo {{url}}\nbar\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com\nbar\n".to_string())); } @@ -245,7 +245,7 @@ mod tests { #[test] fn substitutes_integer() { let vars = create_vars(); - let res = substitute("foo={{ integer }}", &vars).unwrap(); + let res = substitute("foo={{integer}}", &vars).unwrap(); assert_eq!(res, Complete("foo=42".to_string())); } @@ -254,20 +254,27 @@ mod tests { fn substitutes_placeholder_with_default_value() { let vars = create_vars(); let res = - substitute("foo: {{ url | default('fallback.com') }}\n", &vars) + substitute("foo: {{ url | fallback('fallback.com') }}\n", &vars) .unwrap(); assert_eq!(res, Complete("foo: example.com\n".to_string())); } #[test] - fn uses_default_when_value_missing() { + fn substitutes_default_value() { let vars = create_vars(); let res = - substitute("foo: {{ href | default('fallback.com') }}\n", &vars) + substitute("foo: {{ href | fallback('fallback.com') }}\n", &vars) .unwrap(); - assert_eq!(res, Complete("foo: fallback.com\n".to_string())); + assert_eq!( + res, + ValueMissing { + key: "href".to_string(), + fallback: Some("fallback.com".to_string()), + multiple: false, + } + ); } #[test] @@ -279,8 +286,8 @@ mod tests { res, ValueMissing { key: "href".to_string(), - multiple: false, fallback: None, + multiple: false, } ); } @@ -288,7 +295,7 @@ mod tests { #[test] fn substitutes_single_variable_with_spaces() { let vars = create_vars(); - let res = substitute("foo {{url}}\nbar\n", &vars).unwrap(); + let res = substitute("foo {{ url }}\nbar\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com\nbar\n".to_string())); } @@ -296,8 +303,7 @@ mod tests { #[test] fn substitutes_one_variable_per_line() { let vars = create_vars(); - let res = - substitute("foo {{ url }}\nbar {{ token }}\n", &vars).unwrap(); + let res = substitute("foo {{url}}\nbar {{token}}\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com\nbar abc123\n".to_string())); } @@ -305,8 +311,7 @@ mod tests { #[test] fn substitutes_variable_on_the_same_line() { let vars = create_vars(); - let res = - substitute("foo {{ url }}, bar {{ token }}\n", &vars).unwrap(); + let res = substitute("foo {{url}}, bar {{token}}\n", &vars).unwrap(); assert_eq!(res, Complete("foo example.com, bar abc123\n".to_string())); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 641fdb3..8a2f887 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -340,8 +340,8 @@ impl App { }), ValueMissing { key, - multiple, fallback, + multiple, } => { let scope = load_env(&self.target, &resolved, &[])?; From 531589370663360c6c90d83075cf84ab344b2451 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Wed, 18 Mar 2026 17:28:54 +0100 Subject: [PATCH 14/15] fix: use select_multiple for create_issue example --- example/create_issue.http | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/create_issue.http b/example/create_issue.http index 70e896a..90d3247 100644 --- a/example/create_issue.http +++ b/example/create_issue.http @@ -4,5 +4,5 @@ Accept: application/vnd.github+json { "title": "Found a bug", "body": "I'm having a problem with this.", - "labels": ["{{ label | map(attribute="value") | join('", "') }}"], + "labels": ["{{ label | select_multiple | map(attribute="value") | join('", "') }}"], } From de9825efb868fc7b68d7d67259fc9b12ea5739f3 Mon Sep 17 00:00:00 2001 From: Sebastian Lyng Johansen Date: Wed, 18 Mar 2026 17:36:41 +0100 Subject: [PATCH 15/15] fix: avoid using map when selecting multiple --- example/create_issue.http | 2 +- src/prompt.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/example/create_issue.http b/example/create_issue.http index 90d3247..fb3b2e2 100644 --- a/example/create_issue.http +++ b/example/create_issue.http @@ -4,5 +4,5 @@ Accept: application/vnd.github+json { "title": "Found a bug", "body": "I'm having a problem with this.", - "labels": ["{{ label | select_multiple | map(attribute="value") | join('", "') }}"], + "labels": ["{{ label | select_multiple | join('", "') }}"], } diff --git a/src/prompt.rs b/src/prompt.rs index 4380e03..4855c2f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -207,8 +207,10 @@ fn select_replacement_multiple( let items: Vec = selected .iter() - .map(|item| JinjaValue::from_serialize(&values[item.index])) - .collect(); + .map(|item| { + list_option_to_string(key, values, item).map(JinjaValue::from) + }) + .collect::>>()?; Ok(JinjaValue::from(items)) }