use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use super::json_comments;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum FormFieldKind {
#[default]
Text,
File,
Base64File,
}
impl FormFieldKind {
pub fn is_multipart(&self) -> bool {
matches!(self, FormFieldKind::Base64File | FormFieldKind::File)
}
}
pub(crate) const BASE64_FILE_CT_MARKER: &str = "x-paperboy-base64;";
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct FormField {
pub key: String,
pub value: String,
#[serde(default, deserialize_with = "crate::persistence::lenient")]
pub kind: FormFieldKind,
pub content_type: Option<String>,
#[serde(default)]
pub base64_prefix: Option<String>,
pub enabled: bool,
#[serde(default)]
pub desc: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize)]
pub struct KvRow {
pub key: String,
pub value: String,
pub enabled: bool,
pub desc: String,
}
impl KvRow {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
enabled: true,
desc: String::new(),
}
}
pub fn toggled(key: impl Into<String>, value: impl Into<String>, enabled: bool) -> Self {
Self {
key: key.into(),
value: value.into(),
enabled,
desc: String::new(),
}
}
}
impl From<(String, String, bool)> for KvRow {
fn from((key, value, enabled): (String, String, bool)) -> Self {
Self {
key,
value,
enabled,
desc: String::new(),
}
}
}
#[cfg(test)]
impl PartialEq<(String, String, bool)> for KvRow {
fn eq(&self, (key, value, enabled): &(String, String, bool)) -> bool {
self.key == *key && self.value == *value && self.enabled == *enabled && self.desc.is_empty()
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum KvRowRepr {
Full {
key: String,
value: String,
#[serde(default = "enabled_by_default")]
enabled: bool,
#[serde(default)]
desc: String,
},
Legacy(String, String, bool),
}
fn enabled_by_default() -> bool {
true
}
impl<'de> Deserialize<'de> for KvRow {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(match KvRowRepr::deserialize(d)? {
KvRowRepr::Full {
key,
value,
enabled,
desc,
} => KvRow {
key,
value,
enabled,
desc,
},
KvRowRepr::Legacy(key, value, enabled) => KvRow {
key,
value,
enabled,
desc: String::new(),
},
})
}
}
pub(crate) const DESC_MARKER: &str = "# @desc ";
pub(crate) fn key_char_allowed(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '[' | ']' | '$')
}
pub(crate) fn key_start_allowed(c: char) -> bool {
key_char_allowed(c) && c != '['
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyProblem {
Empty,
LeadingBracket,
Char(char),
}
pub fn key_problem(key: &str) -> Option<KeyProblem> {
let key = key.trim();
let Some(first) = key.chars().next() else {
return Some(KeyProblem::Empty);
};
if first == '[' {
return Some(KeyProblem::LeadingBracket);
}
let mut rest = key;
let mut at_start = true;
while let Some(c) = rest.chars().next() {
if let Some(after) = rest.strip_prefix("{{") {
let Some(end) = after.find("}}") else {
return Some(KeyProblem::Char('{'));
};
rest = &after[end + 2..];
at_start = false;
continue;
}
let ok = if at_start {
key_start_allowed(c)
} else {
key_char_allowed(c)
};
if !ok {
return Some(KeyProblem::Char(c));
}
rest = &rest[c.len_utf8()..];
at_start = false;
}
None
}
pub fn value_problem(value: &str) -> Option<char> {
value
.chars()
.find(|c| matches!(c, '\n' | '\r' | '\t' | '\\'))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlaceholderProblem {
Truncated { written: String, read: String },
Unparsable { written: String },
}
fn hurl_name_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
fn hurl_template_stopper(c: char) -> bool {
matches!(c, '\\' | '\u{8}' | '\n' | '\u{c}' | '\r' | '\t' | '#')
}
pub fn placeholder_problem(inner: &str) -> Option<PlaceholderProblem> {
let written = format!("{{{{{inner}}}}}");
let body = inner.trim_matches(' ');
if body.chars().any(hurl_template_stopper) {
return Some(PlaceholderProblem::Unparsable { written });
}
let name: String = body.chars().take_while(|c| hurl_name_char(*c)).collect();
if name.is_empty() {
return Some(PlaceholderProblem::Unparsable { written });
}
if name.len() == body.len() {
return None;
}
Some(PlaceholderProblem::Truncated {
written,
read: name,
})
}
pub fn placeholder_problems(text: &str) -> Vec<PlaceholderProblem> {
let mut out = Vec::new();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let after = &rest[open + 2..];
let Some(close) = after.find("}}") else {
break;
};
if let Some(p) = placeholder_problem(&after[..close]) {
out.push(p);
}
rest = &after[close + 2..];
}
out
}
fn escape_form_file_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for c in path.chars() {
match c {
' ' | '#' | ';' | '\\' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
_ => out.push(c),
}
}
out
}
fn push_line(out: &mut String, line: &str, enabled: bool) {
if !enabled {
out.push_str("# ");
out.push_str(&line.replace(['\n', '\r'], " "));
out.push('\n');
return;
}
out.push_str(line);
out.push('\n');
}
fn push_kv_line(out: &mut String, row: &KvRow) {
push_desc(out, &row.desc);
let writable = key_problem(&row.key).is_none() && value_problem(&row.value).is_none();
push_line(
out,
&format!("{}: {}", row.key, row.value),
row.enabled && writable,
);
}
fn push_desc(out: &mut String, desc: &str) {
for line in desc.lines() {
out.push_str(DESC_MARKER);
out.push_str(line);
out.push('\n');
}
}
fn form_field_line(f: &FormField) -> String {
match f.kind {
FormFieldKind::Text => format!("{}: {}", f.key, f.value),
FormFieldKind::File => {
let path = escape_form_file_path(&f.value);
match f.content_type.as_deref().map(str::trim) {
Some(ct) if !ct.is_empty() => format!("{}: file,{}; {}", f.key, path, ct),
_ => format!("{}: file,{};", f.key, path),
}
}
FormFieldKind::Base64File => {
let path = escape_form_file_path(&f.value);
let encoded_prefix =
URL_SAFE_NO_PAD.encode(f.base64_prefix.as_deref().unwrap_or("").as_bytes());
format!(
"{}: file,{}; {}{}",
f.key, path, BASE64_FILE_CT_MARKER, encoded_prefix
)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunStatus {
#[default]
NotRun,
Running,
Passed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CommentAnchor {
Lead,
#[default]
Headers,
BasicAuth,
Cookies,
Query,
Form,
Options,
Body,
Response,
ResponseHeaders,
Asserts,
Captures,
ResponseBody,
Trailing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryComment {
#[serde(default, deserialize_with = "crate::persistence::lenient")]
pub anchor: CommentAnchor,
pub text: String,
}
pub(crate) fn single_line(s: &str) -> String {
if s.contains(['\n', '\r']) {
s.replace(['\n', '\r'], " ").trim().to_string()
} else {
s.trim().to_string()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HurlEntry {
pub title: String,
pub method: String,
pub url: String,
pub headers: Vec<KvRow>,
pub basic_auth: Option<(String, String)>,
#[serde(default)]
pub form_fields: Vec<FormField>,
#[serde(default)]
pub is_multipart: bool,
pub queries: Vec<KvRow>,
#[serde(default)]
pub cookies: Vec<KvRow>,
#[serde(default)]
pub options: Vec<KvRow>,
#[serde(rename = "body")]
pub body_src: Option<String>,
pub expected_status: Option<u16>,
#[serde(default)]
pub response_version: Option<String>,
#[serde(default)]
pub response_headers: Vec<KvRow>,
#[serde(default)]
pub response_body: Option<String>,
pub captures: Vec<(String, String)>,
#[serde(default)]
pub asserts: Vec<String>,
#[serde(default)]
pub reports: Vec<(String, String)>,
#[serde(default)]
pub generators: Vec<(String, String)>,
#[serde(default)]
pub comments: Vec<EntryComment>,
#[serde(default)]
pub unparsed: Option<String>,
#[serde(default)]
pub user_added: bool,
#[serde(default)]
pub modified: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
#[serde(skip)]
pub uid: u64,
#[serde(skip)]
pub last_run: RunStatus,
#[serde(skip)]
pub last_response: Option<crate::http::ApiResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParamNameError {
Invalid,
Conflict(String),
}
pub fn check_parameter_name(
name: &str,
value: &str,
declared: &[(String, String)],
) -> Option<ParamNameError> {
if !is_variable_name(name.trim()) {
return Some(ParamNameError::Invalid);
}
let name = name.trim();
match declared.iter().find(|(n, _)| n == name) {
Some((_, existing)) if existing != value.trim() => {
Some(ParamNameError::Conflict(existing.clone()))
}
_ => None,
}
}
pub fn suggest_parameter_name(value: &str, declared: &[(String, String)]) -> String {
let value = value.trim();
if let Some((name, _)) = declared.iter().find(|(_, v)| v == value) {
return name.clone();
}
let taken: Vec<&String> = declared.iter().map(|(n, _)| n).collect();
let core = match value.rsplit(['/', '\\']).next() {
Some(last) if value.contains('/') || value.contains('\\') => {
last.split('.').next().unwrap_or(last)
}
_ => value,
};
let mut base = String::new();
for ch in core.chars() {
if ch.is_alphanumeric() {
base.extend(ch.to_uppercase());
} else if !base.ends_with('_') {
base.push('_');
}
}
let base = base.trim_matches('_');
let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) {
"VALUE"
} else {
base
};
let base: String = base.chars().take(24).collect();
let base = base.trim_end_matches('_').to_string();
if !taken.iter().any(|t| **t == base) {
return base;
}
(2..)
.map(|n| format!("{base}_{n}"))
.find(|c| !taken.iter().any(|t| **t == *c))
.expect("an unused suffix always exists")
}
pub fn is_variable_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(hurl_name_char)
}
pub(crate) fn parse_body_marker(line: &str) -> Option<usize> {
let rest = line.trim_start().strip_prefix('#')?.trim_start();
if !rest.get(..6)?.eq_ignore_ascii_case("[body]") {
return None;
}
rest[6..].trim().parse::<usize>().ok()
}
pub(crate) fn decode_body_line(line: &str) -> &str {
let rest = line.trim_start().strip_prefix('#').unwrap_or("");
rest.strip_prefix(' ').unwrap_or(rest)
}
pub(crate) fn parse_gen_marker(line: &str) -> Option<usize> {
let rest = line.trim_start().strip_prefix('#')?.trim_start();
if !rest.get(..5)?.eq_ignore_ascii_case("[gen]") {
return None;
}
rest[5..].trim().parse::<usize>().ok()
}
pub(crate) fn parse_gen_row(line: &str) -> Option<(String, String)> {
let rest = line.trim_start().strip_prefix('#')?.trim_start();
let (name, expr) = rest.split_once('=')?;
let (name, expr) = (name.trim(), expr.trim());
if !gen_row_persistable(name, expr) {
return None;
}
Some((name.to_string(), expr.to_string()))
}
pub(crate) fn gen_row_persistable(name: &str, expr: &str) -> bool {
let name = name.trim();
let expr = expr.trim();
!name.is_empty() && !expr.is_empty() && !name.contains('=')
}
fn encode_body_block(src: &str) -> String {
let lines: Vec<&str> = src.split('\n').collect();
let mut out = format!("# [Body] {}\n", lines.len());
for l in lines {
let l = l.strip_suffix('\r').unwrap_or(l);
if l.is_empty() {
out.push_str("#\n");
} else {
out.push_str(&format!("# {l}\n"));
}
}
out
}
impl HurlEntry {
pub fn set_baseline(&mut self) {
self.baseline = Some(self.to_hurl());
}
pub fn mark_edited(&mut self) {
self.modified = match &self.baseline {
Some(disk) => &self.to_hurl() != disk,
None => true,
};
}
pub fn unreadable(text: &str) -> Self {
let title = text
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(|l| l.trim_start_matches('#').trim())
.filter(|l| !l.is_empty())
.unwrap_or_default()
.chars()
.take(80)
.collect();
Self {
title,
unparsed: Some(text.to_string()),
..Default::default()
}
}
pub fn is_unreadable(&self) -> bool {
self.unparsed.is_some()
}
pub fn body_wire(&self) -> Option<Cow<'_, str>> {
self.body_src.as_deref().map(json_comments::wire_body)
}
pub fn stale_body_notes(&self) -> Option<(Vec<usize>, String)> {
let at_body: Vec<usize> = self
.comments
.iter()
.enumerate()
.filter(|(_, c)| c.anchor == CommentAnchor::Body)
.map(|(i, _)| i)
.collect();
for (pos, &idx) in at_body.iter().enumerate() {
let Some(n) = parse_body_marker(&self.comments[idx].text) else {
continue;
};
if n == 0 {
continue;
}
let Some(end) = pos.checked_add(1).and_then(|s| s.checked_add(n)) else {
continue;
};
let Some(lines) = at_body.get(pos + 1..end) else {
continue;
};
if lines.len() != n {
continue;
}
let text = lines
.iter()
.map(|&i| decode_body_line(&self.comments[i].text))
.collect::<Vec<_>>()
.join("\n");
let mut claimed = vec![idx];
claimed.extend_from_slice(lines);
return Some((claimed, text));
}
None
}
pub fn can_adopt_body_notes(&self) -> bool {
self.adopted_notes()
.is_some_and(|e| e.survives_being_written())
}
fn adopted_notes(&self) -> Option<Self> {
let (at, text) = self.stale_body_notes()?;
let mut next = self.clone();
next.body_src = Some(text);
next.drop_comments(&at);
Some(next)
}
fn survives_being_written(&self) -> bool {
let back = crate::hurl::parser::parse_hurl(&self.to_hurl());
back.len() == 1 && back[0].body_wire() == self.body_wire()
}
pub fn adopt_body_notes(&mut self) -> bool {
let Some(next) = self.adopted_notes() else {
return false;
};
if !next.survives_being_written() {
return false;
}
*self = next;
true
}
pub fn discard_body_notes(&mut self) -> bool {
let Some((at, _)) = self.stale_body_notes() else {
return false;
};
self.drop_comments(&at);
true
}
fn drop_comments(&mut self, at: &[usize]) {
let mut i = 0usize;
self.comments.retain(|_| {
let keep = !at.contains(&i);
i += 1;
keep
});
}
pub fn from_fields(
name: &str,
method: &str,
url: &str,
headers: Vec<KvRow>,
body: &str,
) -> Self {
let headers = headers
.into_iter()
.filter(|r| !r.key.trim().is_empty())
.map(|r| KvRow {
key: r.key.trim().to_string(),
value: r.value.trim().to_string(),
enabled: r.enabled,
desc: r.desc,
})
.collect();
let body = if body.trim().is_empty() {
None
} else {
Some(body.to_string())
};
Self {
title: single_line(name),
method: method.to_string(),
url: url.trim().to_string(),
headers,
body_src: body,
..Default::default()
}
}
pub fn variable_defaults(&self) -> Vec<(String, String)> {
self.options
.iter()
.filter_map(|r| Self::variable_default(r.enabled, &r.key, &r.value))
.collect()
}
pub fn variable_default(enabled: bool, key: &str, value: &str) -> Option<(String, String)> {
if !enabled || !key.trim().eq_ignore_ascii_case("variable") {
return None;
}
let (name, value) = value.split_once('=')?;
let name = name.trim();
is_variable_name(name).then(|| (name.to_string(), value.trim().to_string()))
}
pub fn declares_variable(&self, name: &str) -> bool {
self.variable_defaults().iter().any(|(n, _)| n == name)
}
pub fn ensure_run_content_length(&mut self) {
let carries_body = matches!(
self.method.to_ascii_uppercase().as_str(),
"POST" | "PUT" | "PATCH" | "DELETE"
);
let has_forms = !self.form_fields.is_empty();
let has_body = self
.body_wire()
.as_deref()
.is_some_and(|b| !b.trim().is_empty())
|| !self.form_fields.is_empty();
let has_content_length = self
.headers
.iter()
.any(|r| r.key.eq_ignore_ascii_case("content-length"));
if carries_body && !has_body && !has_content_length && !has_forms {
self.headers.push(KvRow::new("Content-Length", "0"));
}
}
pub fn body_form_conflict(&self) -> bool {
self.body_src.is_some() && self.form_fields.iter().any(|f| f.enabled)
}
pub fn first_empty_file_field(&self) -> Option<&str> {
self.form_fields
.iter()
.find(|f| f.enabled && f.kind.is_multipart() && f.value.trim().is_empty())
.map(|f| f.key.as_str())
}
pub fn to_hurl(&self) -> String {
if let Some(raw) = &self.unparsed {
let mut out = raw.trim_end_matches(['\n', '\r']).to_string();
out.push('\n');
return out;
}
use CommentAnchor::*;
let mut out = String::new();
let push_comments = |out: &mut String, anchor: CommentAnchor| {
for c in self.comments.iter().filter(|c| c.anchor == anchor) {
out.push_str(&c.text);
out.push('\n');
}
};
push_comments(&mut out, Lead);
if self.comments.iter().any(|c| c.anchor == Lead) {
out.push('\n');
}
if !self.title.trim().is_empty() {
out.push_str("# ");
out.push_str(&single_line(&self.title));
out.push('\n');
}
let method = if self.method.is_empty() {
"GET"
} else {
self.method.as_str()
};
out.push_str(&format!("{method} {}\n", self.url));
push_comments(&mut out, Headers);
for row in &self.headers {
push_kv_line(&mut out, row);
}
push_comments(&mut out, BasicAuth);
if let Some((user, pass)) = &self.basic_auth {
out.push_str(&format!("[BasicAuth]\n{user}: {pass}\n"));
}
push_comments(&mut out, Cookies);
if !self.cookies.is_empty() {
out.push_str("[Cookies]\n");
for row in &self.cookies {
push_kv_line(&mut out, row);
}
}
push_comments(&mut out, Query);
if !self.queries.is_empty() {
out.push_str("[Query]\n");
for row in &self.queries {
push_kv_line(&mut out, row);
}
}
push_comments(&mut out, Form);
if !self.form_fields.is_empty() {
let multipart = self
.form_fields
.iter()
.any(|f| f.enabled && f.kind.is_multipart())
|| self.is_multipart;
out.push_str(if multipart {
"[Multipart]\n"
} else {
"[Form]\n"
});
for f in &self.form_fields {
push_desc(&mut out, &f.desc);
let writable = key_problem(&f.key).is_none()
&& (f.kind != FormFieldKind::Text || value_problem(&f.value).is_none());
push_line(&mut out, &form_field_line(f), f.enabled && writable);
}
}
push_comments(&mut out, Options);
if !self.options.is_empty() {
out.push_str("[Options]\n");
for row in &self.options {
push_kv_line(&mut out, row);
}
}
push_comments(&mut out, Body);
if let Some(body) = self.body_wire() {
if let Some(src) = self.body_src.as_deref().filter(|s| *s != body.as_ref()) {
out.push_str(&encode_body_block(src));
}
out.push_str(&body);
if !body.ends_with('\n') {
out.push('\n');
}
}
push_comments(&mut out, Response);
let has_response_comments = self.comments.iter().any(|c| {
matches!(
c.anchor,
Response | ResponseHeaders | Asserts | Captures | ResponseBody
)
});
let version = self.response_version.as_deref().unwrap_or("HTTP");
let has_response_area = self.expected_status.is_some()
|| self.response_version.is_some()
|| !self.response_headers.is_empty()
|| self.response_body.is_some()
|| !self.asserts.is_empty()
|| !self.captures.is_empty()
|| !self.reports.is_empty()
|| has_response_comments;
if let Some(status) = self.expected_status {
out.push_str(&format!("{version} {status}\n"));
} else if has_response_area {
out.push_str(&format!("{version} *\n"));
}
push_comments(&mut out, ResponseHeaders);
for row in &self.response_headers {
push_kv_line(&mut out, row);
}
push_comments(&mut out, Asserts);
if !self.asserts.is_empty() {
out.push_str("[Asserts]\n");
for a in &self.asserts {
out.push_str(a);
out.push('\n');
}
}
push_comments(&mut out, Captures);
if !self.captures.is_empty() {
out.push_str("[Captures]\n");
for (name, expr) in &self.captures {
out.push_str(&format!("{name}: {expr}\n"));
}
}
push_comments(&mut out, ResponseBody);
if let Some(body) = &self.response_body {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
if !self.reports.is_empty() {
out.push_str("# [Reports]\n");
for (name, query) in &self.reports {
out.push_str(&format!("# {name}: {query}\n"));
}
}
let writable: Vec<&(String, String)> = self
.generators
.iter()
.filter(|(name, expr)| gen_row_persistable(name, expr))
.collect();
if !writable.is_empty() {
out.push_str(&format!("# [Gen] {}\n", writable.len()));
for (name, expr) in writable {
out.push_str(&format!("# {name} = {expr}\n"));
}
}
push_comments(&mut out, Trailing);
out
}
}
pub fn collection_to_hurl(entries: &[HurlEntry]) -> String {
entries
.iter()
.map(HurlEntry::to_hurl)
.collect::<Vec<_>>()
.join("\n")
}
pub fn status_eq_code(expr: &str) -> Option<u16> {
let rest = expr.trim().strip_prefix("status")?;
let rest = rest.trim_start().strip_prefix("==")?;
rest.trim().parse::<u16>().ok()
}
pub const METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"];
pub fn method_rgb(method: &str) -> Option<(u8, u8, u8)> {
Some(match method {
"GET" => (97, 175, 239),
"POST" => (73, 204, 144),
"PUT" => (252, 161, 48),
"DELETE" => (248, 81, 73),
"PATCH" => (80, 227, 194),
"ANY" => (252, 161, 48),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(method: &str) -> HurlEntry {
HurlEntry {
method: method.to_string(),
url: "http://x/y".to_string(),
..Default::default()
}
}
#[test]
fn bodyless_post_gets_an_explicit_content_length_zero() {
let mut e = entry("POST");
e.ensure_run_content_length();
assert!(
e.headers
.iter()
.any(|r| r.key == "Content-Length" && r.value == "0")
);
}
#[test]
fn content_length_added_for_all_body_carrying_methods() {
for m in ["POST", "PUT", "PATCH", "DELETE", "post", "Put"] {
let mut e = entry(m);
e.ensure_run_content_length();
assert!(
e.headers.iter().any(|r| r.key == "Content-Length"),
"expected Content-Length for {m}"
);
}
}
#[test]
fn get_and_head_never_get_a_content_length() {
for m in ["GET", "HEAD"] {
let mut e = entry(m);
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|r| r.key.eq_ignore_ascii_case("content-length")),
"did not expect Content-Length for {m}"
);
}
}
#[test]
fn content_length_skipped_when_a_body_is_present() {
let mut e = entry("POST");
e.body_src = Some("{\"a\":1}".to_string());
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|r| r.key.eq_ignore_ascii_case("content-length"))
);
}
#[test]
fn even_a_whitespace_body_conflicts_with_form_fields() {
let mut e = HurlEntry {
form_fields: vec![FormField {
key: "grant_type".into(),
enabled: true,
..Default::default()
}],
..Default::default()
};
assert!(!e.body_form_conflict(), "form fields alone are fine");
e.body_src = Some(" ".into());
assert!(e.body_form_conflict(), "a space is still a body");
}
#[test]
fn a_disabled_form_field_does_not_conflict_with_a_body() {
let e = HurlEntry {
body_src: Some("{}".into()),
form_fields: vec![FormField {
key: "grant_type".into(),
enabled: false,
..Default::default()
}],
..Default::default()
};
assert!(!e.body_form_conflict());
}
#[test]
fn content_length_skipped_when_form_fields_are_present() {
let mut e = entry("POST");
e.form_fields = vec![FormField {
key: "a".to_string(),
value: "b".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
}];
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|r| r.key.eq_ignore_ascii_case("content-length"))
);
}
#[test]
fn a_user_set_content_length_is_not_duplicated() {
let mut e = entry("POST");
e.headers.push(KvRow::toggled("content-length", "5", true));
e.ensure_run_content_length();
let count = e
.headers
.iter()
.filter(|r| r.key.eq_ignore_ascii_case("content-length"))
.count();
assert_eq!(count, 1);
}
#[test]
fn a_row_saved_before_descriptions_existed_still_loads() {
let legacy: KvRow = serde_json::from_str(r#"["X-Trace","on",false]"#)
.expect("the legacy three-element form must still deserialise");
assert_eq!(legacy.key, "X-Trace");
assert_eq!(legacy.value, "on");
assert!(!legacy.enabled);
assert_eq!(legacy.desc, "", "with no note, of course");
}
#[test]
fn a_described_row_round_trips_through_the_saved_state_format() {
let row = KvRow {
key: "X-Trace".into(),
value: "on".into(),
enabled: true,
desc: "staging only".into(),
};
let back: KvRow = serde_json::from_str(&serde_json::to_string(&row).unwrap()).unwrap();
assert_eq!(back.desc, "staging only");
assert_eq!(back.key, "X-Trace");
}
#[test]
fn a_bracket_key_never_breaks_the_collection_file() {
for row_of in [
(|r| HurlEntry {
headers: vec![r],
..Default::default()
}) as fn(KvRow) -> HurlEntry,
|r| HurlEntry {
cookies: vec![r],
..Default::default()
},
|r| HurlEntry {
queries: vec![r],
..Default::default()
},
|r| HurlEntry {
options: vec![r],
..Default::default()
},
] {
let mut e = row_of(KvRow::new("[Body]", "value"));
e.method = "POST".into();
e.url = "http://h/a".into();
let text = e.to_hurl();
assert!(
text.contains("# [Body]: value"),
"the row must be commented, got:\n{text}"
);
assert_eq!(
crate::hurl::parse_hurl_error(&text),
None,
"must still parse:\n{text}"
);
assert_eq!(
crate::hurl::parse_hurl(&text).len(),
1,
"the entry must survive:\n{text}"
);
}
}
#[test]
fn a_bracket_form_field_key_never_breaks_the_collection_file() {
let e = HurlEntry {
method: "POST".into(),
url: "http://h/a".into(),
form_fields: vec![FormField {
key: "[Body]".into(),
value: "v".into(),
enabled: true,
..Default::default()
}],
..Default::default()
};
let text = e.to_hurl();
assert_eq!(crate::hurl::parse_hurl_error(&text), None, "\n{text}");
assert_eq!(crate::hurl::parse_hurl(&text).len(), 1, "\n{text}");
}
#[test]
fn a_bracket_key_does_not_lose_the_other_requests_in_the_file() {
let bad = HurlEntry {
method: "POST".into(),
url: "http://h/a".into(),
headers: vec![KvRow::new("[Body]", "value")],
..Default::default()
};
let good = HurlEntry {
method: "GET".into(),
url: "http://h/b".into(),
..Default::default()
};
let text = crate::hurl::collection_to_hurl(&[bad, good]);
assert_eq!(crate::hurl::parse_hurl(&text).len(), 2, "\n{text}");
}
#[test]
fn a_bracket_inside_a_key_is_still_a_normal_row() {
assert_eq!(key_problem("filter[name]"), None);
assert_eq!(key_problem("[Body]"), Some(KeyProblem::LeadingBracket));
assert_eq!(key_problem(" [Options]"), Some(KeyProblem::LeadingBracket));
let e = HurlEntry {
method: "GET".into(),
url: "http://h/a".into(),
queries: vec![KvRow::new("filter[name]", "x")],
..Default::default()
};
let text = e.to_hurl();
assert!(text.contains("\nfilter[name]: x"), "\n{text}");
assert_eq!(crate::hurl::parse_hurl_error(&text), None, "\n{text}");
let back = crate::hurl::parse_hurl(&text);
assert_eq!(
back[0].queries,
vec![("filter[name]".into(), "x".into(), true)]
);
}
#[test]
fn permissive_key_shapes_round_trip() {
for key in [
"filter[name]",
"{{VAR}}",
"X-Ké",
"-X-A",
"_X_A",
"$X",
"X.A",
"X-A]B",
"X-{{Tenant}}-Id",
] {
assert_eq!(key_problem(key), None, "{key} should be writable");
let e = HurlEntry {
method: "GET".into(),
url: "http://h/a".into(),
headers: vec![KvRow::new(key, "v")],
..Default::default()
};
let text = e.to_hurl();
assert_eq!(crate::hurl::parse_hurl_error(&text), None, "{key}\n{text}");
let back = crate::hurl::parse_hurl(&text);
assert_eq!(
back.first().map(|e| e.headers.clone()).unwrap_or_default(),
vec![(key.to_string(), "v".to_string(), true)],
"{key} must survive a save/load round trip:\n{text}"
);
}
}
#[test]
fn unwritable_key_shapes_are_refused() {
for key in [
"X-A:B", "X-A#B", "X\"A", "X\\A", "X;A", "X,A", "X A", "X\tA", "X\nY", "X/A", "X@A",
"X(A", "A}B", "A{B", "{V}", "X-{{V",
] {
assert!(
matches!(key_problem(key), Some(KeyProblem::Char(_))),
"{key:?} must be refused"
);
}
assert_eq!(key_problem(""), Some(KeyProblem::Empty));
assert_eq!(key_problem(" "), Some(KeyProblem::Empty));
}
#[test]
fn only_line_breaking_characters_are_refused_in_a_value() {
for value in [
"plain",
"a: b",
"a # b",
"[bracketed]",
"{{VAR}}",
"\"quoted\"",
"a;b,c",
"",
] {
assert_eq!(value_problem(value), None, "{value:?} should be writable");
}
assert_eq!(value_problem("a\nb"), Some('\n'));
assert_eq!(value_problem("a\tb"), Some('\t'));
assert_eq!(value_problem("a\\b"), Some('\\'));
}
#[test]
fn every_writable_row_survives_a_round_trip() {
let keys = [
"X-A",
"filter[name]",
"{{V}}",
"X-Ké",
"$X",
"_A",
"-A",
"A.B",
"A{{V}}B",
"9",
];
let values = ["v", "a: b", "a # b", "{{V}}", "[x]", "a;b", "\"q\""];
for key in keys {
for value in values {
assert_eq!(key_problem(key), None, "{key:?}");
assert_eq!(value_problem(value), None, "{value:?}");
let e = HurlEntry {
method: "GET".into(),
url: "http://h/a".into(),
headers: vec![KvRow::new(key, value)],
..Default::default()
};
let text = e.to_hurl();
assert_eq!(
crate::hurl::parse_hurl_error(&text),
None,
"{key:?} / {value:?}\n{text}"
);
let back = crate::hurl::parse_hurl(&text);
assert_eq!(
back.first().map(|e| e.headers.clone()).unwrap_or_default(),
vec![(key.to_string(), value.to_string(), true)],
"{key:?} / {value:?} must round-trip:\n{text}"
);
}
}
}
#[test]
fn a_disabled_row_with_a_multiline_value_keeps_the_file_parseable() {
let e = HurlEntry {
method: "GET".into(),
url: "http://h/a".into(),
headers: vec![KvRow {
key: "X-A".into(),
value: "one\nGET http://evil/".into(),
enabled: false,
desc: String::new(),
}],
..Default::default()
};
let text = e.to_hurl();
assert_eq!(crate::hurl::parse_hurl_error(&text), None, "\n{text}");
assert_eq!(
crate::hurl::parse_hurl(&text).len(),
1,
"the value must not become a second entry:\n{text}"
);
}
#[test]
fn a_commented_body_is_written_out_as_strict_json() {
let mut e = HurlEntry {
title: "t".into(),
method: "POST".into(),
url: "http://h/a".into(),
..Default::default()
};
e.body_src = Some("{\n // who\n \"id\": 1 // the caller\n}".into());
assert_eq!(
e.body_wire().as_deref(),
Some("{\n \"id\": 1\n}"),
"the wire body has no commentary in it"
);
let text = e.to_hurl();
let hurl_reads: String = text
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
assert!(
!hurl_reads.contains("// who") && !hurl_reads.contains("// the caller"),
"\n{hurl_reads}"
);
assert!(hurl_reads.contains("\"id\": 1"), "\n{text}");
assert!(text.contains("# // who"), "the note is kept:\n{text}");
}
#[test]
fn a_body_that_is_not_json_is_written_out_untouched() {
let mut e = HurlEntry::default();
e.body_src = Some("query { user // not a comment\n}".into());
assert_eq!(e.body_wire().as_deref(), e.body_src.as_deref());
}
#[test]
fn renaming_the_field_did_not_rename_it_on_disk() {
let mut e = HurlEntry::default();
e.body_src = Some("{}".into());
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("\"body\":\"{}\""), "{json}");
let back: HurlEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.body_src.as_deref(), Some("{}"));
}
#[test]
fn a_name_with_a_newline_cannot_invent_a_second_request() {
let e = HurlEntry::from_fields(
"line one\nGET http://evil.example.net",
"POST",
"https://x/y",
vec![],
"",
);
let text = e.to_hurl();
let back = crate::hurl::parse_hurl(&text);
assert_eq!(back.len(), 1, "one request in, one request out:\n{text}");
assert_eq!(back[0].url, "https://x/y");
assert_eq!(back[0].method, "POST");
assert_eq!(
back[0].title, "line one GET http://evil.example.net",
"the name is kept, just flattened onto its one line"
);
}
#[test]
fn a_pasted_multi_line_title_still_serializes_to_one_comment() {
let mut e = HurlEntry::from_fields("ok", "GET", "https://x/y", vec![], "");
e.title = "first\r\nDELETE https://x/z".to_string();
let back = crate::hurl::parse_hurl(&e.to_hurl());
assert_eq!(back.len(), 1);
assert_eq!(back[0].method, "GET");
}
}
#[cfg(test)]
mod placeholder_tests {
use super::*;
fn hurl_reads(inner: &str) -> Option<String> {
let text = format!("GET http://h/{{{{{inner}}}}}\n");
let file = hurl_core::parser::parse_hurl_file(&text).ok()?;
let url = file.entries[0].request.url.to_string();
let start = url.find("{{")?;
let rest = &url[start + 2..];
let end = rest.find("}}")?;
Some(rest[..end].to_string())
}
#[test]
fn the_truncation_rule_matches_what_hurl_actually_does() {
for inner in [
"TOKEN", " TOKEN ", "api_key", "api-key", "x1", "Ké",
"newUuid", "newDate",
] {
assert_eq!(
placeholder_problem(inner),
None,
"{inner:?} should be clean"
);
assert_eq!(
hurl_reads(inner).as_deref(),
Some(inner.trim_matches([' ', '\t'])),
"hurl disagrees about {inner:?}"
);
}
for (inner, read) in [
("api.key", "api"),
(" api.key ", "api"),
("gen.uuid", "gen"),
("a b", "a"),
("hmac(k, m)", "hmac"),
("TOKEN!", "TOKEN"),
] {
assert_eq!(
placeholder_problem(inner),
Some(PlaceholderProblem::Truncated {
written: format!("{{{{{inner}}}}}"),
read: read.to_string(),
}),
"{inner:?} should be truncated"
);
assert_eq!(
hurl_reads(inner).as_deref(),
Some(read),
"hurl disagrees about {inner:?}"
);
}
for inner in ["$guid", "", " ", ".x", "!", "\tTOKEN\t", "TOKEN\t", "a#b"] {
assert_eq!(
placeholder_problem(inner),
Some(PlaceholderProblem::Unparsable {
written: format!("{{{{{inner}}}}}"),
}),
"{inner:?} should be unparsable"
);
assert_eq!(hurl_reads(inner), None, "hurl disagrees about {inner:?}");
}
}
#[test]
fn a_parameter_name_hurl_would_truncate_is_not_offered() {
for good in ["TOKEN", "api_key", "api-key", "x1", "Ké"] {
assert_eq!(check_parameter_name(good, "v", &[]), None, "{good:?}");
assert!(is_variable_name(good), "{good:?}");
}
for bad in ["api.key", "$guid", "a b", "", "a{b"] {
assert_eq!(
check_parameter_name(bad, "v", &[]),
Some(ParamNameError::Invalid),
"{bad:?}"
);
assert!(!is_variable_name(bad), "{bad:?}");
}
}
#[test]
fn every_placeholder_in_a_line_is_checked_in_order() {
let found = placeholder_problems("{{ok}}/{{a.b}}?t={{ok2}}&u={{c.d}}");
assert_eq!(
found,
vec![
PlaceholderProblem::Truncated {
written: "{{a.b}}".into(),
read: "a".into()
},
PlaceholderProblem::Truncated {
written: "{{c.d}}".into(),
read: "c".into()
},
]
);
assert!(placeholder_problems("nothing templated here").is_empty());
assert!(placeholder_problems("{{ unterminated").is_empty());
}
}