use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread;
use base64::{Engine, engine::general_purpose::STANDARD};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::collection::Collection;
use crate::environment::{EnvUpdate, Environment, ValueSource, substitute};
use crate::http::ApiResponse;
use crate::hurl::{
EntryOutcome, FormField, HurlEntry, KvRow, RunOutput, RunStatus, collection_to_hurl,
expand_base64_form_fields, run_hurl, run_hurl_streaming, stage_out_of_scope_form_files,
};
#[derive(Clone)]
pub struct AppVars {
pub base_url: String,
}
impl Default for AppVars {
fn default() -> Self {
Self {
base_url: "http://127.0.0.1:8080".to_string(),
}
}
}
pub fn collection_vars(
env: Option<&Environment>,
captures: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut vars = HashMap::new();
if let Some(env) = env {
for v in &env.vars {
vars.insert(v.key.clone(), v.value.clone());
}
}
for (k, v) in captures {
vars.insert(k.clone(), v.clone());
}
vars
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SubstKind {
Literal,
Loaded,
Pending,
Failed,
Undefined,
}
pub struct SubstInfo {
pub shown: Option<String>,
pub kind: SubstKind,
}
pub fn subst_map(col: &Collection, env: Option<&Environment>) -> HashMap<String, SubstInfo> {
let mut out: HashMap<String, SubstInfo> = HashMap::new();
for e in &col.entries {
for (name, _) in &e.captures {
out.insert(
name.clone(),
SubstInfo {
shown: None,
kind: SubstKind::Failed,
},
);
}
}
if let Some(env) = env {
for v in &env.vars {
let info = if v.loading {
SubstInfo {
shown: None,
kind: SubstKind::Pending,
}
} else if v.resolved {
match v.source {
ValueSource::Literal => SubstInfo {
shown: Some(v.value.clone()),
kind: SubstKind::Literal,
},
_ => SubstInfo {
shown: Some(v.display_value()),
kind: SubstKind::Loaded,
},
}
} else {
SubstInfo {
shown: None,
kind: SubstKind::Failed,
}
};
out.insert(v.key.clone(), info);
}
}
for (k, val) in &col.captures {
out.insert(
k.clone(),
SubstInfo {
shown: Some(val.clone()),
kind: SubstKind::Loaded,
},
);
}
out
}
pub fn subst_display(text: &str, map: &HashMap<String, SubstInfo>) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let Some(close_rel) = rest[open + 2..].find("}}") else {
break;
};
let close = open + 2 + close_rel;
let end = close + 2;
let inner = rest[open + 2..close].trim();
out.push_str(&rest[..open]);
match map.get(inner).and_then(|i| i.shown.as_ref()) {
Some(val) => out.push_str(val),
None => out.push_str(&rest[open..end]),
}
rest = &rest[end..];
}
out.push_str(rest);
out
}
#[derive(Clone)]
struct KvValue {
value: String,
enabled: bool,
desc: String,
}
impl serde::Serialize for KvValue {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if self.enabled && self.desc.is_empty() {
s.serialize_str(&self.value)
} else if self.desc.is_empty() {
(&self.value, self.enabled).serialize(s)
} else {
(&self.value, self.enabled, &self.desc).serialize(s)
}
}
}
impl<'de> serde::Deserialize<'de> for KvValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(match Value::deserialize(d)? {
Value::Array(arr) => KvValue {
value: arr.first().map(value_as_text).unwrap_or_default(),
enabled: arr.get(1).and_then(Value::as_bool).unwrap_or(true),
desc: arr.get(2).map(value_as_text).unwrap_or_default(),
},
other => KvValue {
value: value_as_text(&other),
enabled: true,
desc: String::new(),
},
})
}
}
#[derive(Clone, Default)]
struct TextValue(String);
impl serde::Serialize for TextValue {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for TextValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(TextValue(value_as_text(&Value::deserialize(d)?)))
}
}
fn value_as_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[derive(Serialize, Default, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum FormKind {
#[default]
Text,
File,
#[serde(rename = "base64file")]
Base64File,
}
impl<'de> serde::Deserialize<'de> for FormKind {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
Ok(match v.as_str() {
Some("file") => FormKind::File,
Some("base64file") => FormKind::Base64File,
_ => FormKind::Text,
})
}
}
#[derive(Serialize, Deserialize)]
struct FormFieldJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
base64_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
#[serde(default)]
key: TextValue,
#[serde(rename = "type", default)]
kind: FormKind,
#[serde(default)]
value: TextValue,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
enabled: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
desc: String,
}
fn default_true() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
impl From<&FormField> for FormFieldJson {
fn from(f: &FormField) -> Self {
Self {
base64_prefix: f.base64_prefix.clone(),
content_type: f.content_type.clone(),
key: TextValue(f.key.clone()),
kind: match f.kind {
crate::hurl::FormFieldKind::File => FormKind::File,
crate::hurl::FormFieldKind::Base64File => FormKind::Base64File,
crate::hurl::FormFieldKind::Text => FormKind::Text,
},
value: TextValue(f.value.clone()),
enabled: f.enabled,
desc: f.desc.clone(),
}
}
}
impl From<FormFieldJson> for FormField {
fn from(f: FormFieldJson) -> Self {
Self {
key: f.key.0,
value: f.value.0,
kind: match f.kind {
FormKind::File => crate::hurl::FormFieldKind::File,
FormKind::Base64File => crate::hurl::FormFieldKind::Base64File,
FormKind::Text => crate::hurl::FormFieldKind::Text,
},
content_type: f.content_type,
base64_prefix: f.base64_prefix,
enabled: f.enabled,
desc: f.desc,
}
}
}
#[derive(Serialize, Deserialize, Default)]
struct BasicAuthJson {
#[serde(default)]
pass: TextValue,
#[serde(default)]
user: TextValue,
}
#[derive(Serialize, Deserialize)]
struct RequestJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
basic_auth: Option<BasicAuthJson>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
cookies: BTreeMap<String, KvValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
form_fields: Vec<FormFieldJson>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<String, KvValue>,
method: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query_params: BTreeMap<String, KvValue>,
url: String,
}
fn rows_to_map(rows: &[KvRow]) -> BTreeMap<String, KvValue> {
rows.iter()
.map(|r| {
(
r.key.clone(),
KvValue {
value: r.value.clone(),
enabled: r.enabled,
desc: r.desc.clone(),
},
)
})
.collect()
}
fn map_to_rows(map: BTreeMap<String, KvValue>) -> Vec<KvRow> {
map.into_iter()
.map(|(key, kv)| KvRow {
key,
value: kv.value,
enabled: kv.enabled,
desc: kv.desc,
})
.collect()
}
pub fn build_request_json(entry: &HurlEntry) -> String {
let dto = RequestJson {
basic_auth: entry.basic_auth.as_ref().map(|(user, pass)| BasicAuthJson {
pass: TextValue(pass.clone()),
user: TextValue(user.clone()),
}),
body: entry.body.as_deref().map(|raw| {
serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}),
cookies: rows_to_map(&entry.cookies),
form_fields: entry.form_fields.iter().map(FormFieldJson::from).collect(),
headers: rows_to_map(&entry.headers),
method: entry.method.clone(),
query_params: rows_to_map(&entry.queries),
url: entry.url.clone(),
};
serde_json::to_string_pretty(&dto).unwrap_or_else(|_| "{}".into())
}
pub fn apply_request_json(base: &HurlEntry, text: &str) -> Result<HurlEntry, String> {
let dto: RequestJson = serde_json::from_str(text).map_err(|e| e.to_string())?;
let body = match dto.body {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(s),
Some(v) => Some(serde_json::to_string_pretty(&v).unwrap_or_default()),
};
let mut entry = base.clone();
entry.method = dto.method;
entry.url = dto.url;
entry.basic_auth = dto.basic_auth.map(|ba| (ba.user.0, ba.pass.0));
entry.headers = map_to_rows(dto.headers);
entry.cookies = map_to_rows(dto.cookies);
entry.queries = map_to_rows(dto.query_params);
entry.form_fields = dto.form_fields.into_iter().map(FormField::from).collect();
entry.body = body;
Ok(entry)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum RequestView {
Json,
#[default]
Hurl,
}
pub struct ResolvedRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub cookies: Vec<(String, String)>,
pub form_fields: Vec<FormField>,
pub body: Option<String>,
}
pub fn resolve_entry(entry: &HurlEntry, vars: &HashMap<String, String>) -> ResolvedRequest {
let method = entry.method.clone();
let url = substitute(&entry.url, vars);
let mut headers: Vec<(String, String)> = entry
.headers
.iter()
.filter(|r| r.enabled)
.map(|r| (r.key.clone(), substitute(&r.value, vars)))
.collect();
if let Some((user, pass)) = &entry.basic_auth {
let cred = STANDARD.encode(format!(
"{}:{}",
substitute(user, vars),
substitute(pass, vars)
));
headers.push(("Authorization".to_string(), format!("Basic {cred}")));
}
let cookies: Vec<(String, String)> = entry
.cookies
.iter()
.filter(|r| r.enabled)
.map(|r| (substitute(&r.key, vars), substitute(&r.value, vars)))
.collect();
let form_fields: Vec<FormField> = entry
.form_fields
.iter()
.filter(|f| f.enabled)
.map(|f| FormField {
key: substitute(&f.key, vars),
value: substitute(&f.value, vars),
kind: f.kind,
content_type: f.content_type.as_deref().map(|ct| substitute(ct, vars)),
base64_prefix: f.base64_prefix.as_deref().map(|p| substitute(p, vars)),
enabled: f.enabled,
desc: String::new(),
})
.collect();
let body = entry.body.as_deref().map(|b| substitute(b, vars));
ResolvedRequest {
method,
url,
headers,
cookies,
form_fields,
body,
}
}
pub struct CaptureUpdate {
pub col_id: u64,
pub entry_idx: usize,
pub ok: bool,
pub values: HashMap<String, String>,
pub response: ApiResponse,
}
pub struct BatchRunUpdate {
pub col_id: u64,
pub results: Vec<Option<bool>>,
pub captures: HashMap<String, String>,
pub responses: Vec<Option<ApiResponse>>,
}
fn to_run_entry(base: &HurlEntry, resolved: ResolvedRequest) -> HurlEntry {
let is_multipart = resolved
.form_fields
.iter()
.any(|form| form.kind.is_multipart());
HurlEntry {
title: String::new(),
method: resolved.method,
url: resolved.url,
headers: resolved
.headers
.iter()
.map(|(k, v)| KvRow::new(k.clone(), v.clone()))
.collect(),
basic_auth: None, form_fields: resolved.form_fields,
is_multipart,
queries: base.queries.clone(),
cookies: resolved
.cookies
.iter()
.map(|(k, v)| KvRow::new(k.clone(), v.clone()))
.collect(),
options: base.options.clone(),
body: resolved.body,
expected_status: base.expected_status,
response_version: base.response_version.clone(),
response_headers: base.response_headers.clone(),
response_body: base.response_body.clone(),
captures: base.captures.clone(),
asserts: base.asserts.clone(),
reports: base.reports.clone(),
comments: Vec::new(),
user_added: base.user_added,
modified: base.modified,
last_run: base.last_run,
last_response: None,
}
}
pub fn effective_vars<'a>(
base: &HurlEntry,
vars: &'a HashMap<String, String>,
) -> Cow<'a, HashMap<String, String>> {
let defaults = base.variable_defaults();
if defaults.is_empty() {
return Cow::Borrowed(vars);
}
let mut merged = vars.clone();
for (name, value) in defaults {
if merged.contains_key(&name) {
continue;
}
let value = substitute(&value, &merged);
merged.insert(name, value);
}
Cow::Owned(merged)
}
fn strip_variable_options(entry: &mut HurlEntry) {
entry
.options
.retain(|r| !(r.enabled && r.key.trim().eq_ignore_ascii_case("variable")));
}
pub fn strip_bound_variable_options(
entries: &mut [HurlEntry],
vars: &HashMap<String, String>,
) -> bool {
let mut stripped = false;
for entry in entries {
let bound: Vec<String> = entry
.variable_defaults()
.into_iter()
.filter(|(name, _)| vars.contains_key(name))
.map(|(name, _)| name)
.collect();
if bound.is_empty() {
continue;
}
entry.options.retain(|r| {
let is_bound_default = r.enabled
&& r.key.trim().eq_ignore_ascii_case("variable")
&& r.value
.split_once('=')
.is_some_and(|(name, _)| bound.iter().any(|b| b == name.trim()));
!is_bound_default
});
stripped = true;
}
stripped
}
pub fn run_resolved_entry(
base: &HurlEntry,
vars: &HashMap<String, String>,
file_root: Option<&std::path::Path>,
extra_captures: &[(String, String)],
) -> RunOutput {
let vars = effective_vars(base, vars);
let resolved = resolve_entry(base, &vars);
let mut run_entry = to_run_entry(base, resolved);
run_entry.captures.extend(extra_captures.iter().cloned());
strip_variable_options(&mut run_entry);
let mut entries = [run_entry];
if let Err(e) = expand_base64_form_fields(&mut entries, file_root) {
return RunOutput {
entries: vec![],
error: Some(format!("Base64 file error: {e}")),
};
}
let staged_dir = stage_out_of_scope_form_files(&mut entries, file_root).unwrap_or_default();
let mut run_entry = entries.into_iter().next().unwrap();
run_entry.ensure_run_content_length();
let run_root = staged_dir.as_deref().or(file_root);
let content = run_entry.to_hurl();
let out = run_hurl(&content, &vars, run_root);
if let Some(dir) = &staged_dir {
let _ = std::fs::remove_dir_all(dir);
}
out
}
const BODY_FORM_CONFLICT_ERROR: &str = "Can't send: a request can't have both a Body and Form/Multipart fields (Hurl sends the body and silently drops the fields) — remove one.";
pub fn run_collection(
col: &Collection,
env: Option<&Environment>,
state: Arc<Mutex<ApiResponse>>,
) -> Option<Receiver<CaptureUpdate>> {
let base = col.entries.get(col.selected_entry)?;
if base.body_form_conflict() {
let mut r = state.lock().unwrap();
r.loading = false;
r.error = BODY_FORM_CONFLICT_ERROR.to_string();
return None;
}
let base = base.clone();
let vars = collection_vars(env, &col.captures);
let col_id = col.id;
let entry_idx = col.selected_entry;
let file_root = col
.path
.as_ref()
.and_then(|p| p.parent().map(std::path::PathBuf::from));
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let out = run_resolved_entry(&base, &vars, file_root.as_deref(), &[]);
let mut r = state.lock().unwrap();
r.loading = false;
match out.entries.into_iter().next() {
Some(eo) => {
r.status = eo.status;
r.status_text = eo.status_text;
r.body = Arc::from(eo.body);
r.headers = eo.headers;
r.assert_results = eo.asserts;
r.duration_ms = Some(eo.duration_ms);
r.error = eo.error.or(out.error).unwrap_or_default();
let values: HashMap<String, String> = eo.captures.into_iter().collect();
let _ = tx.send(CaptureUpdate {
col_id,
entry_idx,
ok: eo.ok,
values,
response: r.clone(),
});
}
None => {
r.error = out.error.unwrap_or_else(|| "no response".to_string());
}
}
});
Some(rx)
}
fn entry_response(eo: &EntryOutcome) -> ApiResponse {
ApiResponse {
status: eo.status,
status_text: eo.status_text.clone(),
body: Arc::from(eo.body.as_str()),
loading: false,
error: eo.error.clone().unwrap_or_default(),
headers: eo.headers.clone(),
assert_results: eo.asserts.clone(),
duration_ms: Some(eo.duration_ms),
}
}
pub fn run_all_entries(
col: &Collection,
env: Option<&Environment>,
state: Arc<Mutex<ApiResponse>>,
batch: bool,
) -> Option<Receiver<BatchRunUpdate>> {
if col.entries.is_empty() {
return None;
}
if let Some(bad) = col.entries.iter().find(|e| e.body_form_conflict()) {
let mut r = state.lock().unwrap();
r.loading = false;
r.error = format!("{} ({})", BODY_FORM_CONFLICT_ERROR, bad.title);
return None;
}
let vars = collection_vars(env, &col.captures);
let col_id = col.id;
let file_root = col
.path
.as_ref()
.and_then(|p| p.parent().map(std::path::PathBuf::from));
let total = col.entries.len();
let mut run_entries = col.entries.clone();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
if let Err(e) = expand_base64_form_fields(&mut run_entries, file_root.as_deref()) {
let mut r = state.lock().unwrap();
r.loading = false;
r.error = format!("Base64 file error: {e}");
return;
}
let staged_dir = stage_out_of_scope_form_files(&mut run_entries, file_root.as_deref())
.unwrap_or_default();
let run_root = staged_dir.as_deref().or(file_root.as_deref());
for e in &mut run_entries {
e.ensure_run_content_length();
}
strip_bound_variable_options(&mut run_entries, &vars);
let content = collection_to_hurl(&run_entries);
let mut results: Vec<Option<bool>> = vec![None; total];
let mut captures: HashMap<String, String> = HashMap::new();
let mut responses: Vec<Option<ApiResponse>> = vec![None; total];
let out = if batch {
run_hurl(&content, &vars, run_root)
} else {
let mut idx = 0usize;
run_hurl_streaming(&content, &vars, run_root, |eo| {
if idx < total {
results[idx] = Some(eo.ok);
captures.extend(eo.captures.iter().cloned());
responses[idx] = Some(entry_response(eo));
}
idx += 1;
let _ = tx.send(BatchRunUpdate {
col_id,
results: results.clone(),
captures: captures.clone(),
responses: responses.clone(),
});
})
};
if let Some(dir) = &staged_dir {
let _ = std::fs::remove_dir_all(dir);
}
let mut r = state.lock().unwrap();
r.loading = false;
match out.entries.last() {
Some(last) => {
r.status = last.status;
r.status_text = last.status_text.clone();
r.body = Arc::from(last.body.as_str());
r.headers = last.headers.clone();
r.assert_results = last.asserts.clone();
r.error = last
.error
.clone()
.or_else(|| out.error.clone())
.unwrap_or_default();
}
None => {
r.error = out
.error
.clone()
.unwrap_or_else(|| "no response".to_string());
}
}
if batch {
for (i, eo) in out.entries.iter().enumerate().take(total) {
results[i] = Some(eo.ok);
captures.extend(eo.captures.iter().cloned());
responses[i] = Some(entry_response(eo));
}
let _ = tx.send(BatchRunUpdate {
col_id,
results,
captures,
responses,
});
}
});
Some(rx)
}
pub fn drain_capture_updates(
pending: &mut Vec<Receiver<CaptureUpdate>>,
collections: &mut [Collection],
) -> bool {
if pending.is_empty() {
return false;
}
let mut still = Vec::new();
for rx in std::mem::take(pending) {
let mut disconnected = false;
loop {
match rx.try_recv() {
Ok(update) => {
for col in collections.iter_mut() {
if col.id == update.col_id {
for (k, v) in &update.values {
col.captures.insert(k.clone(), v.clone());
}
col.invalidate_request_json();
if let Some(entry) = col.entries.get_mut(update.entry_idx) {
entry.last_response = Some(update.response.clone());
entry.last_run = if update.ok {
RunStatus::Passed
} else {
RunStatus::Failed
};
}
}
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
if !disconnected {
still.push(rx);
}
}
*pending = still;
!pending.is_empty()
}
pub fn entry_referenced_keys(entry: &HurlEntry) -> std::collections::HashSet<String> {
let mut keys = std::collections::HashSet::new();
let mut add = |text: &str| keys.extend(crate::environment::referenced_keys(text));
add(&entry.url);
for r in entry.headers.iter().chain(&entry.queries) {
add(&r.key);
add(&r.value);
}
for f in &entry.form_fields {
add(&f.key);
add(&f.value);
}
for r in &entry.cookies {
add(&r.key);
add(&r.value);
}
if let Some((u, p)) = &entry.basic_auth {
add(u);
add(p);
}
if let Some(body) = &entry.body {
add(body);
}
keys
}
pub fn pending_request_keys(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let Some(env) = env else { return Vec::new() };
let Some(entry) = col.entries.get(col.selected_entry) else {
return Vec::new();
};
let referenced = entry_referenced_keys(entry);
let mut blocking: Vec<String> = env
.vars
.iter()
.filter(|v| v.is_pending() && referenced.contains(&v.key))
.map(|v| v.key.clone())
.collect();
blocking.sort();
blocking.dedup();
blocking
}
pub fn pending_request_keys_all(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let Some(env) = env else { return Vec::new() };
let mut referenced = std::collections::HashSet::new();
for entry in &col.entries {
referenced.extend(entry_referenced_keys(entry));
}
let mut blocking: Vec<String> = env
.vars
.iter()
.filter(|v| v.is_pending() && referenced.contains(&v.key))
.map(|v| v.key.clone())
.collect();
blocking.sort();
blocking.dedup();
blocking
}
fn defined_keys(col: &Collection, env: Option<&Environment>) -> std::collections::HashSet<String> {
let mut defined: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(env) = env {
defined.extend(env.vars.iter().map(|v| v.key.clone()));
}
for entry in &col.entries {
defined.extend(entry.captures.iter().map(|(name, _)| name.clone()));
}
defined.extend(col.captures.keys().cloned());
defined
}
pub fn undefined_request_keys(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let Some(entry) = col.entries.get(col.selected_entry) else {
return Vec::new();
};
let defined = defined_keys(col, env);
let mut out: Vec<String> = entry_referenced_keys(entry)
.into_iter()
.filter(|k| !defined.contains(k))
.collect();
out.sort();
out
}
pub fn undefined_request_keys_all(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let defined = defined_keys(col, env);
let mut out: Vec<String> = col
.entries
.iter()
.flat_map(entry_referenced_keys)
.filter(|k| !defined.contains(k))
.collect();
out.sort();
out.dedup();
out
}
pub fn body_form_conflicts(col: &Collection) -> Vec<String> {
col.entries
.get(col.selected_entry)
.filter(|e| e.body_form_conflict())
.map(|e| vec![request_label(e)])
.unwrap_or_default()
}
pub fn body_form_conflicts_all(col: &Collection) -> Vec<String> {
col.entries
.iter()
.filter(|e| e.body_form_conflict())
.map(request_label)
.collect()
}
fn request_label(e: &crate::hurl::HurlEntry) -> String {
if e.title.trim().is_empty() {
format!("{} {}", e.method, e.url)
} else {
e.title.clone()
}
}
pub fn drain_env_updates(
pending: &mut Vec<Receiver<EnvUpdate>>,
global_envs: &mut [Environment],
collections: &mut [Collection],
) -> bool {
if pending.is_empty() {
return false;
}
let mut still = Vec::new();
for rx in std::mem::take(pending) {
let mut disconnected = false;
loop {
match rx.try_recv() {
Ok(update) => {
for env in global_envs.iter_mut() {
if env.id == update.env_id {
env.apply_update(&update);
for col in collections.iter_mut() {
col.invalidate_request_json();
}
}
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
if !disconnected {
still.push(rx);
}
}
*pending = still;
!pending.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collection::Collection;
use crate::environment::{EnvVar, Environment, ValueSource};
use crate::hurl::{FormField, FormFieldKind, HurlEntry};
#[test]
fn request_json_is_alphabetically_ordered_and_round_trips() {
let entry = HurlEntry {
method: "POST".into(),
url: "http://example.com/api".into(),
headers: vec![
KvRow::toggled("X-Zed", "z", true),
KvRow::toggled("Authorization", "Bearer t", true),
],
cookies: vec![KvRow::toggled("session", "abc", true)],
queries: vec![KvRow::toggled("page", "2", true)],
basic_auth: Some(("alice".into(), "secret".into())),
form_fields: vec![
FormField {
key: "name".into(),
value: "widget".into(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
},
FormField {
key: "file".into(),
value: "./a.bin".into(),
kind: FormFieldKind::File,
content_type: Some("application/octet-stream".into()),
base64_prefix: None,
enabled: true,
desc: String::new(),
},
],
body: Some(r#"{"a":1}"#.into()),
..Default::default()
};
let json = build_request_json(&entry);
let expected = r#"{
"basic_auth": {
"pass": "secret",
"user": "alice"
},
"body": {
"a": 1
},
"cookies": {
"session": "abc"
},
"form_fields": [
{
"key": "name",
"type": "text",
"value": "widget"
},
{
"content_type": "application/octet-stream",
"key": "file",
"type": "file",
"value": "./a.bin"
}
],
"headers": {
"Authorization": "Bearer t",
"X-Zed": "z"
},
"method": "POST",
"query_params": {
"page": "2"
},
"url": "http://example.com/api"
}"#;
assert_eq!(json, expected);
let back = apply_request_json(&HurlEntry::default(), &json).unwrap();
assert_eq!(back.method, "POST");
assert_eq!(back.url, "http://example.com/api");
assert_eq!(back.basic_auth, Some(("alice".into(), "secret".into())));
assert_eq!(
back.headers,
vec![
("Authorization".to_string(), "Bearer t".to_string(), true),
("X-Zed".to_string(), "z".to_string(), true),
]
);
assert_eq!(back.form_fields.len(), 2);
assert_eq!(back.form_fields[1].kind, FormFieldKind::File);
assert_eq!(back.body.as_deref(), Some("{\n \"a\": 1\n}"));
}
#[test]
fn apply_request_json_tolerates_non_string_scalars_and_unknown_form_type() {
let base = HurlEntry::default();
let text = r#"{
"method": "GET",
"url": "http://x",
"headers": { "X-Count": 5 },
"form_fields": [ { "key": "k", "value": "v", "type": "weird" } ]
}"#;
let entry = apply_request_json(&base, text).unwrap();
assert_eq!(
entry.headers,
vec![("X-Count".to_string(), "5".to_string(), true)]
);
assert_eq!(entry.form_fields[0].kind, FormFieldKind::Text);
}
#[test]
fn apply_request_json_rejects_input_without_method_or_url() {
let base = HurlEntry::default();
assert!(apply_request_json(&base, "[]").is_err());
assert!(apply_request_json(&base, r#"{"url":"http://x"}"#).is_err());
}
fn me_entry() -> HurlEntry {
HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/me".into(),
headers: vec![KvRow::new("Authorization", "Bearer {{ API_TOKEN }}")],
..Default::default()
}
}
fn env_token(value: &str, resolved: bool) -> Environment {
Environment {
id: 0,
name: "e".into(),
vars: vec![EnvVar {
key: "API_TOKEN".into(),
value: value.into(),
source: ValueSource::OnePassword,
resolved,
loading: false,
original_value: value.into(),
modified: false,
user_added: false,
raw: String::new(),
}],
path: None,
git_origin: None,
}
}
fn auth_header(col: &Collection, env: Option<&Environment>) -> String {
let vars = collection_vars(env, &col.captures);
let headers = resolve_entry(&col.entries[col.selected_entry], &vars).headers;
headers
.into_iter()
.find(|(k, _)| k == "Authorization")
.unwrap()
.1
}
#[test]
fn reloading_environment_refreshes_the_sent_request() {
let col = Collection::new("c".into(), vec![me_entry()]);
let env = env_token("{{ op://Eng/demo-api/token }}", false);
assert!(
auth_header(&col, Some(&env)).contains("op://"),
"unresolved ref is sent while missing"
);
let env = env_token("real-secret", true);
assert_eq!(auth_header(&col, Some(&env)), "Bearer real-secret");
}
fn secret_var(key: &str, resolved: bool, loading: bool) -> EnvVar {
EnvVar {
key: key.into(),
value: "{{ op://x }}".into(),
source: ValueSource::OnePassword,
resolved,
loading,
original_value: "{{ op://x }}".into(),
modified: false,
user_added: false,
raw: String::new(),
}
}
fn env_with(vars: Vec<EnvVar>) -> Environment {
Environment {
id: 1,
name: "e".into(),
vars,
path: None,
git_origin: None,
}
}
#[test]
fn pending_secret_blocks_the_referencing_request() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ API_TOKEN }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("API_TOKEN", false, true)]);
assert_eq!(
pending_request_keys(&col, Some(&env)),
vec!["API_TOKEN".to_string()]
);
}
#[test]
fn resolved_secret_does_not_block() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ API_TOKEN }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("API_TOKEN", true, false)]);
assert!(pending_request_keys(&col, Some(&env)).is_empty());
}
#[test]
fn unreferenced_pending_secret_does_not_block() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/plain".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("OTHER", false, true)]);
assert!(
pending_request_keys(&col, Some(&env)).is_empty(),
"request doesn't use the loading secret"
);
}
#[test]
fn pending_request_keys_all_checks_every_entry_not_just_the_selected_one() {
let first = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/plain".into(),
..Default::default()
};
let second = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ API_TOKEN }}".into(),
..Default::default()
};
let mut col = Collection::new("c".into(), vec![first, second]);
col.selected_entry = 0; let env = env_with(vec![secret_var("API_TOKEN", false, true)]);
assert!(
pending_request_keys(&col, Some(&env)).is_empty(),
"the selected entry alone doesn't reference it"
);
assert_eq!(
pending_request_keys_all(&col, Some(&env)),
vec!["API_TOKEN".to_string()],
"Run All must check every entry, not just the selected one"
);
}
fn plain_var(key: &str, value: &str) -> EnvVar {
EnvVar {
key: key.into(),
value: value.into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: value.into(),
modified: false,
user_added: false,
raw: String::new(),
}
}
#[test]
fn a_variable_no_one_defines_is_reported() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ tokn }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![plain_var("BASE_URL", "http://x")]);
assert_eq!(
undefined_request_keys(&col, Some(&env)),
vec!["tokn".to_string()],
"the typo is named; the variable that exists is not"
);
}
#[test]
fn a_capture_name_counts_as_defined_before_it_has_a_value() {
let login = HurlEntry {
method: "POST".into(),
url: "http://x/login".into(),
captures: vec![("token".into(), "jsonpath \"$.token\"".into())],
..Default::default()
};
let use_it = HurlEntry {
method: "GET".into(),
url: "http://x/me?t={{ token }}".into(),
..Default::default()
};
let mut col = Collection::new("c".into(), vec![login, use_it]);
col.selected_entry = 1;
assert!(
undefined_request_keys(&col, None).is_empty(),
"a captured name is defined even before the capture has run"
);
}
#[test]
fn a_pending_secret_is_defined_not_undefined() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ API_TOKEN }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("API_TOKEN", false, true)]);
assert!(undefined_request_keys(&col, Some(&env)).is_empty());
}
#[test]
fn with_no_environment_active_every_referenced_variable_is_undefined() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/x".into(),
headers: vec![KvRow::toggled(
"Authorization",
"Bearer {{ API_KEY }}",
true,
)],
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
assert_eq!(
undefined_request_keys(&col, None),
vec!["API_KEY".to_string(), "BASE_URL".to_string()],
"sorted, and headers are scanned as well as the URL"
);
}
#[test]
fn undefined_request_keys_all_checks_every_entry_and_dedupes() {
let first = HurlEntry {
method: "GET".into(),
url: "{{ nope }}/a".into(),
..Default::default()
};
let second = HurlEntry {
method: "GET".into(),
url: "{{ nope }}/b".into(),
..Default::default()
};
let mut col = Collection::new("c".into(), vec![first, second]);
col.selected_entry = 0;
assert_eq!(
undefined_request_keys_all(&col, None),
vec!["nope".to_string()],
"reported once, not once per entry that uses it"
);
}
#[test]
fn collection_vars_includes_captures_overriding_env() {
let env = env_with(vec![EnvVar {
key: "access_token".into(),
value: "from-env".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "from-env".into(),
modified: false,
user_added: false,
raw: String::new(),
}]);
let mut captures = HashMap::new();
captures.insert("access_token".to_string(), "from-capture".to_string());
let vars = collection_vars(Some(&env), &captures);
assert_eq!(
vars.get("access_token").unwrap(),
"from-capture",
"a fresh capture wins over env"
);
}
#[test]
fn base_url_is_not_substituted_from_app_vars() {
let env = env_with(vec![EnvVar {
key: "API_TOKEN".into(),
value: "t".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "t".into(),
modified: false,
user_added: false,
raw: String::new(),
}]);
let vars = collection_vars(Some(&env), &HashMap::new());
assert!(
!vars.contains_key("BASE_URL"),
"BASE_URL is not injected from the app"
);
assert_eq!(
substitute("{{ BASE_URL }}/me", &vars),
"{{ BASE_URL }}/me",
"an unresolved {{ BASE_URL }} is left intact so the user notices"
);
}
#[test]
fn drain_capture_updates_routes_to_the_matching_collection() {
let c1 = Collection::new("c1".into(), vec![]);
let mut c2 = Collection::new("c2".into(), vec![HurlEntry::default()]);
c2.request_json_for = Some(0);
let target = c2.id;
let (tx, rx) = mpsc::channel();
let mut values = HashMap::new();
values.insert("access_token".to_string(), "tok".to_string());
let response = ApiResponse {
status: 200,
body: "ok".into(),
..Default::default()
};
tx.send(CaptureUpdate {
col_id: target,
entry_idx: 0,
ok: true,
values,
response,
})
.unwrap();
drop(tx);
let mut pending = vec![rx];
let mut cols = [c1, c2];
for _ in 0..3 {
drain_capture_updates(&mut pending, &mut cols);
}
assert_eq!(
cols[1].captures.get("access_token").unwrap(),
"tok",
"matching collection updated"
);
assert!(cols[0].captures.is_empty(), "other collection untouched");
assert_eq!(cols[1].request_json_for, None, "target preview invalidated");
assert_eq!(
cols[1].entries[0].last_response.as_ref().map(|r| r.status),
Some(200),
"the entry remembers its own last response"
);
}
#[test]
fn run_all_entries_returns_a_receiver_for_a_non_empty_collection() {
let e1 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1/one".into(),
..Default::default()
};
let e2 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1/two".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![e1, e2]);
let state = Arc::new(Mutex::new(ApiResponse::default()));
assert!(
run_all_entries(&col, None, state, false).is_some(),
"a non-empty collection must start a streaming run"
);
}
#[test]
fn run_all_entries_rejects_a_body_form_conflict_naming_the_offending_entry() {
let ok_entry = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1/ok".into(),
..Default::default()
};
let bad_entry = HurlEntry {
title: "Bad One".into(),
method: "POST".into(),
url: "http://192.0.2.1/bad".into(),
body: Some("{}".into()),
form_fields: vec![FormField {
key: "f".into(),
value: "v".into(),
enabled: true,
..Default::default()
}],
..Default::default()
};
let col = Collection::new("c".into(), vec![ok_entry, bad_entry]);
let state = Arc::new(Mutex::new(ApiResponse::default()));
let rx = run_all_entries(&col, None, state.clone(), false);
assert!(rx.is_none(), "must not start a run that can never be built");
let r = state.lock().unwrap();
assert!(!r.loading);
assert!(
r.error.contains("Body") && r.error.contains("Form") && r.error.contains("Bad One")
);
}
#[test]
fn subst_map_classifies_variables_by_status() {
use crate::environment::SECRET_MASK;
let env = env_with(vec![
EnvVar {
key: "HOST".into(),
value: "h".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "h".into(),
modified: false,
user_added: false,
raw: String::new(),
},
EnvVar {
key: "PORT".into(),
value: "8080".into(),
source: ValueSource::ProcessEnv,
resolved: true,
loading: false,
original_value: "8080".into(),
modified: false,
user_added: false,
raw: String::new(),
},
secret_var("TOK", false, true), secret_var("BAD", false, false), EnvVar {
key: "SECRET".into(),
value: "real".into(),
source: ValueSource::OnePassword,
resolved: true,
loading: false,
original_value: "real".into(),
modified: false,
user_added: false,
raw: String::new(),
},
]);
let mut col = Collection::new(
"c".into(),
vec![HurlEntry {
captures: vec![("token".into(), "jsonpath \"$.t\"".into())],
..Default::default()
}],
);
col.captures.insert("post_id".into(), "42".into());
let m = subst_map(&col, Some(&env));
assert!(matches!(m["HOST"].kind, SubstKind::Literal));
assert_eq!(
m["HOST"].shown.as_deref(),
Some("h"),
"literal is substituted"
);
assert!(matches!(m["PORT"].kind, SubstKind::Loaded));
assert_eq!(
m["PORT"].shown.as_deref(),
Some("8080"),
"env-var value is substituted"
);
assert!(
matches!(m["TOK"].kind, SubstKind::Pending) && m["TOK"].shown.is_none(),
"loading secret is pending, kept"
);
assert!(
matches!(m["BAD"].kind, SubstKind::Failed) && m["BAD"].shown.is_none(),
"failed secret is red, kept"
);
assert!(matches!(m["SECRET"].kind, SubstKind::Loaded));
assert_eq!(
m["SECRET"].shown.as_deref(),
Some(SECRET_MASK),
"a resolved secret is masked, not revealed"
);
assert!(
matches!(m["token"].kind, SubstKind::Failed) && m["token"].shown.is_none(),
"uninitialised capture is red, kept"
);
assert!(matches!(m["post_id"].kind, SubstKind::Loaded));
assert_eq!(
m["post_id"].shown.as_deref(),
Some("42"),
"an initialised capture is substituted"
);
}
#[test]
fn subst_display_substitutes_known_and_keeps_unavailable() {
let env = env_with(vec![
EnvVar {
key: "HOST".into(),
value: "example.test".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "example.test".into(),
modified: false,
user_added: false,
raw: String::new(),
},
secret_var("TOK", false, true), ]);
let col = Collection::new("c".into(), vec![]);
let m = subst_map(&col, Some(&env));
assert_eq!(
subst_display("{{ HOST }}/{{ TOK }}/{{ NOPE }}", &m),
"example.test/{{ TOK }}/{{ NOPE }}",
"known values are substituted; pending and unknown placeholders are kept",
);
}
#[test]
fn a_declared_parameter_supplies_its_default_when_nobody_binds_it() {
let entry = param_entry("FILE", "./samples/invoice.pdf");
let vars = HashMap::new();
let effective = effective_vars(&entry, &vars);
assert_eq!(
effective.get("FILE"),
Some(&"./samples/invoice.pdf".to_string()),
);
assert_eq!(
resolve_entry(&entry, &effective).form_fields[0].value,
"./samples/invoice.pdf",
"the default reaches the multipart file field as a real path",
);
}
#[test]
fn a_caller_binding_beats_the_declared_default() {
let entry = param_entry("FILE", "./samples/invoice.pdf");
let vars = HashMap::from([("FILE".to_string(), "./inbox/real.pdf".to_string())]);
let effective = effective_vars(&entry, &vars);
assert_eq!(effective.get("FILE"), Some(&"./inbox/real.pdf".to_string()));
}
#[test]
fn the_run_entry_never_carries_a_variable_option_to_hurl() {
let entry = param_entry("FILE", "./samples/invoice.pdf");
let vars = HashMap::from([("FILE".to_string(), "./inbox/real.pdf".to_string())]);
let mut run_entry = to_run_entry(&entry, resolve_entry(&entry, &vars));
strip_variable_options(&mut run_entry);
assert!(
run_entry.options.iter().all(|r| r.key != "variable"),
"a variable: row would override the caller inside Hurl",
);
assert!(
run_entry.options.iter().any(|r| r.key == "retry"),
"behavioural options are untouched",
);
}
#[test]
fn a_whole_file_run_strips_only_the_defaults_the_caller_bound() {
let mut entries = vec![param_entry("FILE", "./samples/invoice.pdf")];
entries[0]
.options
.push(KvRow::new("variable", "MODE=draft"));
let vars = HashMap::from([("FILE".to_string(), "./inbox/real.pdf".to_string())]);
assert!(strip_bound_variable_options(&mut entries, &vars));
let rows: Vec<&str> = entries[0]
.options
.iter()
.map(|r| r.value.as_str())
.collect();
assert_eq!(
rows,
vec!["3", "MODE=draft"],
"the bound FILE default is gone; the unbound MODE default survives",
);
}
#[test]
fn a_whole_file_run_reports_when_it_changed_nothing() {
let mut entries = vec![param_entry("FILE", "./samples/invoice.pdf")];
assert!(!strip_bound_variable_options(&mut entries, &HashMap::new()));
assert_eq!(entries[0].options.len(), 2);
}
#[test]
fn a_default_may_reference_another_variable() {
let mut entry = param_entry("SAMPLES", "{{ROOT}}/samples");
entry
.options
.push(KvRow::new("variable", "DOC={{SAMPLES}}/invoice.pdf"));
let vars = HashMap::from([("ROOT".to_string(), "/srv".to_string())]);
let effective = effective_vars(&entry, &vars);
assert_eq!(effective.get("SAMPLES"), Some(&"/srv/samples".to_string()));
assert_eq!(
effective.get("DOC"),
Some(&"/srv/samples/invoice.pdf".to_string()),
);
}
#[test]
fn malformed_and_disabled_parameter_rows_are_ignored() {
let mut entry = param_entry("FILE", "./samples/invoice.pdf");
entry.options = vec![
KvRow::new("variable", "no-equals-sign"),
KvRow::new("variable", "=novalue"),
KvRow::new("variable", "SPACED NAME=x"),
KvRow::toggled("variable", "OFF=x", false),
KvRow::new("VARIABLE", "SHOUTED=y"),
];
assert_eq!(
entry.variable_defaults(),
vec![("SHOUTED".to_string(), "y".to_string())],
"only the well-formed enabled row counts, and the option name is \
matched case-insensitively",
);
}
#[test]
fn a_request_without_parameters_borrows_the_callers_variables() {
let entry = me_entry();
let vars = HashMap::from([("TOKEN".to_string(), "abc".to_string())]);
assert!(matches!(
effective_vars(&entry, &vars),
std::borrow::Cow::Borrowed(_)
));
}
fn param_entry(name: &str, default: &str) -> HurlEntry {
HurlEntry {
title: "upload_document".into(),
method: "POST".into(),
url: "https://example.test/documents".into(),
options: vec![
KvRow::new("retry", "3"),
KvRow::new("variable", format!("{name}={default}")),
],
is_multipart: true,
form_fields: vec![FormField {
key: "file".into(),
value: format!("{{{{{name}}}}}"),
kind: FormFieldKind::File,
enabled: true,
..Default::default()
}],
..Default::default()
}
}
}