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::{
FormField, HurlEntry, collection_to_hurl, run_hurl, 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,
}
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, 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,
}
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(if v.as_str() == Some("file") {
FormKind::File
} else {
FormKind::Text
})
}
}
#[derive(Serialize, Deserialize)]
struct FormFieldJson {
#[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,
}
impl From<&FormField> for FormFieldJson {
fn from(f: &FormField) -> Self {
Self {
content_type: f.content_type.clone(),
key: TextValue(f.key.clone()),
kind: match f.kind {
crate::hurl::FormFieldKind::File => FormKind::File,
crate::hurl::FormFieldKind::Text => FormKind::Text,
},
value: TextValue(f.value.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::Text => crate::hurl::FormFieldKind::Text,
},
content_type: f.content_type,
}
}
}
#[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, TextValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
form_fields: Vec<FormFieldJson>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<String, TextValue>,
method: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query_params: BTreeMap<String, TextValue>,
url: String,
}
fn pairs_to_map(pairs: &[(String, String)]) -> BTreeMap<String, TextValue> {
pairs
.iter()
.map(|(k, v)| (k.clone(), TextValue(v.clone())))
.collect()
}
fn map_to_pairs(map: BTreeMap<String, TextValue>) -> Vec<(String, String)> {
map.into_iter().map(|(k, v)| (k, v.0)).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: pairs_to_map(&entry.cookies),
form_fields: entry.form_fields.iter().map(FormFieldJson::from).collect(),
headers: pairs_to_map(&entry.headers),
method: entry.method.clone(),
query_params: pairs_to_map(&entry.query_params),
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_pairs(dto.headers);
entry.cookies = map_to_pairs(dto.cookies);
entry.query_params = map_to_pairs(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 {
#[default]
Json,
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_request(col: &Collection, env: Option<&Environment>) -> Option<ResolvedRequest> {
let entry = col.entries.get(col.selected_entry)?;
let vars = collection_vars(env, &col.captures);
let method = entry.method.clone();
let url = substitute(&entry.url, &vars);
let mut headers: Vec<(String, String)> = entry
.headers
.iter()
.map(|(k, v)| (k.clone(), substitute(v, &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()
.map(|(k, v)| (substitute(k, &vars), substitute(v, &vars)))
.collect();
let form_fields: Vec<FormField> = entry
.form_fields
.iter()
.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)),
})
.collect();
let body = entry.body.as_deref().map(|b| substitute(b, &vars));
Some(ResolvedRequest {
method,
url,
headers,
cookies,
form_fields,
body,
})
}
pub struct CaptureUpdate {
pub col_id: u64,
pub entry_idx: usize,
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 run_content(col: &Collection, env: Option<&Environment>) -> Option<HurlEntry> {
let base = col.entries.get(col.selected_entry)?;
let resolved = resolve_request(col, env)?;
Some(HurlEntry {
title: String::new(),
method: resolved.method,
url: resolved.url,
headers: resolved.headers,
basic_auth: None, form_fields: resolved.form_fields,
query_params: base.query_params.clone(),
cookies: resolved.cookies,
body: resolved.body,
expected_status: base.expected_status,
captures: base.captures.clone(),
asserts: base.asserts.clone(),
user_added: base.user_added,
modified: base.modified,
last_run: base.last_run,
last_response: None,
})
}
const BODY_FORM_CONFLICT_ERROR: &str = "Can't send: a request can't have both a Body and Form/Multipart fields (Hurl doesn't support combining them) — remove one.";
pub fn run_collection(
col: &Collection,
env: Option<&Environment>,
state: Arc<Mutex<ApiResponse>>,
) -> Option<Receiver<CaptureUpdate>> {
if let Some(entry) = col.entries.get(col.selected_entry)
&& entry.body.is_some()
&& !entry.form_fields.is_empty()
{
let mut r = state.lock().unwrap();
r.loading = false;
r.error = BODY_FORM_CONFLICT_ERROR.to_string();
return None;
}
let mut run_entry = run_content(col, env)?;
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 mut entries = [run_entry.clone()];
let staged_dir =
stage_out_of_scope_form_files(&mut entries, file_root.as_deref()).unwrap_or_default();
if staged_dir.is_some() {
run_entry = entries[0].clone();
}
let run_root = staged_dir.as_deref().or(file_root.as_deref());
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);
}
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.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,
values,
response: r.clone(),
});
}
None => {
r.error = out.error.unwrap_or_else(|| "no response".to_string());
}
}
});
Some(rx)
}
pub fn run_all_entries(
col: &Collection,
env: Option<&Environment>,
state: Arc<Mutex<ApiResponse>>,
) -> Option<Receiver<BatchRunUpdate>> {
if col.entries.is_empty() {
return None;
}
if let Some(bad) = col
.entries
.iter()
.find(|e| e.body.is_some() && !e.form_fields.is_empty())
{
let mut r = state.lock().unwrap();
r.loading = false;
r.error = format!("{} ({})", BODY_FORM_CONFLICT_ERROR, bad.title);
return None;
}
let content = collection_to_hurl(&col.entries);
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 || {
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());
let content = if staged_dir.is_some() {
collection_to_hurl(&run_entries)
} else {
content
};
let out = run_hurl(&content, &vars, run_root);
if let Some(dir) = &staged_dir {
let _ = std::fs::remove_dir_all(dir);
}
let mut r = state.lock().unwrap();
r.loading = false;
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];
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());
}
}
for (i, eo) in out.entries.iter().enumerate().take(total) {
results[i] = Some(eo.ok);
captures.extend(eo.captures.iter().cloned());
responses[i] = Some(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(),
});
}
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());
}
}
}
}
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 (k, v) in &entry.headers {
add(k);
add(v);
}
for (k, v) in &entry.query_params {
add(k);
add(v);
}
for f in &entry.form_fields {
add(&f.key);
add(&f.value);
}
for (k, v) in &entry.cookies {
add(k);
add(v);
}
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
}
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![
("X-Zed".into(), "z".into()),
("Authorization".into(), "Bearer t".into()),
],
cookies: vec![("session".into(), "abc".into())],
query_params: vec![("page".into(), "2".into())],
basic_auth: Some(("alice".into(), "secret".into())),
form_fields: vec![
FormField {
key: "name".into(),
value: "widget".into(),
kind: FormFieldKind::Text,
content_type: None,
},
FormField {
key: "file".into(),
value: "./a.bin".into(),
kind: FormFieldKind::File,
content_type: Some("application/octet-stream".into()),
},
],
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()),
("X-Zed".to_string(), "z".to_string()),
]
);
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())]
);
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![("Authorization".into(), "Bearer {{ API_TOKEN }}".into())],
..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 headers = resolve_request(col, env).unwrap().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"
);
}
#[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,
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).is_some(),
"a non-empty collection must start a batch 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(),
..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());
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",
);
}
}