use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hurl_core::ast::{
Body, Bytes, Capture, Entry, KeyValue, MultipartParam, SectionValue, StatusValue,
};
use hurl_core::parser::parse_hurl_file;
use hurl_core::types::ToSource;
use super::entry::{BASE64_FILE_CT_MARKER, FormField, FormFieldKind, HurlEntry, RunStatus};
pub fn parse_hurl(content: &str) -> Vec<HurlEntry> {
let Ok(file) = parse_hurl_file(content) else {
return Vec::new();
};
let lines: Vec<&str> = content.lines().collect();
let method_lines: Vec<usize> = file
.entries
.iter()
.map(|e| first_method_line(&lines, e.request.source_info.start.line))
.collect();
file.entries
.iter()
.enumerate()
.map(|(i, e)| {
let end = method_lines.get(i + 1).copied().unwrap_or(lines.len() + 1);
map_entry(e, &lines, method_lines[i], end)
})
.collect()
}
fn first_method_line(lines: &[&str], start_line: usize) -> usize {
(start_line.saturating_sub(1)..lines.len())
.find(|&i| {
let l = lines[i].trim();
!l.is_empty() && !l.starts_with('#')
})
.map(|i| i + 1)
.unwrap_or(lines.len() + 1)
}
pub fn parse_hurl_error(content: &str) -> Option<String> {
use hurl_core::error::DisplaySourceError;
use hurl_core::parser::ParseErrorKind;
let err = parse_hurl_file(content).err()?;
let line = err.pos.line;
let reason = match &err.kind {
ParseErrorKind::RequestSectionName { name }
if matches!(name.as_str(), "Captures" | "Asserts") =>
{
format!(
"line {line}: [{name}] is a response section — add an 'HTTP' status line \
above it (use 'HTTP *' to accept any status)"
)
}
ParseErrorKind::RequestSectionName { name } => {
format!("line {line}: [{name}] is not a valid request section")
}
ParseErrorKind::Method { .. } => {
format!("line {line}: expected an HTTP method (e.g. GET, POST)")
}
ParseErrorKind::Version => {
format!("line {line}: a response line must be 'HTTP <status>' (e.g. 'HTTP 200')")
}
ParseErrorKind::Status => format!("line {line}: invalid status code"),
ParseErrorKind::UrlInvalidStart | ParseErrorKind::UrlIllegalCharacter(_) => {
format!("line {line}: invalid URL")
}
_ => format!("line {line}: {}", err.description().to_lowercase()),
};
Some(reason)
}
fn map_entry(e: &Entry, lines: &[&str], scan_start: usize, scan_end: usize) -> HurlEntry {
let req = &e.request;
let mut basic_auth = None;
let mut form_fields = Vec::new();
let mut query_params = Vec::new();
let mut cookies = Vec::new();
for section in &req.sections {
let rows_start = section.source_info.start.line + 1;
match §ion.value {
SectionValue::BasicAuth(Some(kv)) => basic_auth = Some(kv_pair(kv)),
SectionValue::FormParams(kvs, _) => {
form_fields = form_fields_from_section(kvs, None, lines, rows_start);
}
SectionValue::MultipartFormData(parts, _) => {
form_fields = form_fields_from_section(&[], Some(parts), lines, rows_start);
}
SectionValue::QueryParams(..) => query_params = scan_kv_rows(lines, rows_start),
SectionValue::Cookies(_) => cookies = scan_kv_rows(lines, rows_start),
_ => {}
}
}
let mut expected_status = None;
let mut captures = Vec::new();
let mut asserts = Vec::new();
if let Some(resp) = &e.response {
if let StatusValue::Specific(n) = resp.status.value {
expected_status = Some(n as u16);
}
for section in &resp.sections {
match §ion.value {
SectionValue::Captures(caps) => {
captures = caps.iter().filter_map(|c| capture_pair(c, lines)).collect();
}
SectionValue::Asserts(asrts) => {
asserts = asrts
.iter()
.filter_map(|a| source_line(a.query.source_info.start.line, lines))
.collect();
}
_ => {}
}
}
}
let is_multipart = form_fields
.iter()
.any(|f| f.enabled && f.kind.is_multipart());
HurlEntry {
title: title_from_span(req.source_info.start.line, lines),
method: req.method.to_string(),
url: req.url.to_source().to_string(),
headers: scan_kv_rows(lines, req.url.source_info.start.line + 1),
basic_auth,
form_fields,
is_multipart,
queries: query_params,
cookies,
body: req.body.as_ref().and_then(body_source),
expected_status,
captures,
asserts,
reports: reports_from_span(lines, scan_start, scan_end),
user_added: false,
modified: false,
last_run: RunStatus::default(),
last_response: None,
}
}
fn kv_pair(kv: &KeyValue) -> (String, String) {
(
kv.key.to_source().to_string(),
kv.value.to_source().to_string(),
)
}
fn scan_kv_rows(lines: &[&str], start: usize) -> Vec<(String, String, bool)> {
let mut rows = Vec::new();
let mut i = start.saturating_sub(1);
while let Some(&line) = lines.get(i) {
match parse_kv_row(line) {
Some(row) => rows.push(row),
None => break,
}
i += 1;
}
rows
}
fn parse_kv_row(line: &str) -> Option<(String, String, bool)> {
let (enabled, rest) = uncomment(line);
let (key, value) = split_kv(rest)?;
Some((key.to_string(), value.to_string(), enabled))
}
fn uncomment(line: &str) -> (bool, &str) {
let trimmed = line.trim();
match trimmed.strip_prefix('#') {
Some(rest) => (false, rest.trim_start()),
None => (true, trimmed),
}
}
fn split_kv(text: &str) -> Option<(&str, &str)> {
let colon = text.find(':')?;
let key = text[..colon].trim();
let mut chars = key.chars();
let starts_ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric());
let token_ok = key
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if !starts_ok || !token_ok {
return None;
}
Some((key, text[colon + 1..].trim()))
}
fn form_fields_from_section(
kvs: &[KeyValue],
parts: Option<&[MultipartParam]>,
lines: &[&str],
rows_start: usize,
) -> Vec<FormField> {
let mut rows: Vec<(usize, FormField)> = Vec::new();
if let Some(parts) = parts {
for p in parts {
rows.push((multipart_param_line(p), multipart_field(p)));
}
} else {
for kv in kvs {
rows.push((
kv.key.source_info.start.line,
FormField {
key: kv.key.to_source().to_string(),
value: kv.value.to_source().to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
));
}
}
rows.extend(scan_disabled_form_rows(lines, rows_start));
rows.sort_by_key(|(line, _)| *line);
rows.into_iter().map(|(_, f)| f).collect()
}
fn multipart_param_line(p: &MultipartParam) -> usize {
match p {
MultipartParam::Param(kv) => kv.key.source_info.start.line,
MultipartParam::FilenameParam(fp) => fp.key.source_info.start.line,
}
}
fn scan_disabled_form_rows(lines: &[&str], start: usize) -> Vec<(usize, FormField)> {
let mut out = Vec::new();
let mut i = start.saturating_sub(1);
while let Some(&line) = lines.get(i) {
let (enabled, rest) = uncomment(line);
match parse_form_field_line(rest, true) {
Some(mut field) if !enabled => {
field.enabled = false;
out.push((i + 1, field));
}
Some(_) => {}
None => break,
}
i += 1;
}
out
}
fn parse_form_field_line(body: &str, multipart: bool) -> Option<FormField> {
let (key, value) = split_kv(body)?;
if multipart && let Some(spec) = value.strip_prefix("file,") {
return Some(parse_file_form_value(key, spec));
}
Some(FormField {
key: key.to_string(),
value: value.to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
})
}
fn parse_file_form_value(key: &str, spec: &str) -> FormField {
let (escaped_path, ct) = split_unescaped_semicolon(spec);
let path = unescape_form_file_path(escaped_path);
let ct = ct.trim();
if let Some(encoded) = ct.strip_prefix(BASE64_FILE_CT_MARKER) {
let prefix = URL_SAFE_NO_PAD
.decode(encoded)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
return FormField {
key: key.to_string(),
value: path,
kind: FormFieldKind::Base64File,
content_type: None,
base64_prefix: Some(prefix),
enabled: true,
};
}
FormField {
key: key.to_string(),
value: path,
kind: FormFieldKind::File,
content_type: (!ct.is_empty()).then(|| ct.to_string()),
base64_prefix: None,
enabled: true,
}
}
fn split_unescaped_semicolon(spec: &str) -> (&str, &str) {
let bytes = spec.as_bytes();
let mut escaped = false;
for (i, &b) in bytes.iter().enumerate() {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b';' {
return (&spec[..i], &spec[i + 1..]);
}
}
(spec, "")
}
fn unescape_form_file_path(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some(other) => out.push(other),
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
fn multipart_field(p: &MultipartParam) -> FormField {
match p {
MultipartParam::Param(kv) => FormField {
key: kv.key.to_source().to_string(),
value: kv.value.to_source().to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
MultipartParam::FilenameParam(fp) => {
let content_type = fp
.value
.content_type
.as_ref()
.map(|t| t.to_source().to_string());
if let Some(encoded) = content_type
.as_deref()
.and_then(|ct| ct.strip_prefix(BASE64_FILE_CT_MARKER))
{
let prefix = URL_SAFE_NO_PAD
.decode(encoded)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
return FormField {
key: fp.key.to_source().to_string(),
value: fp.value.filename.to_string(),
kind: FormFieldKind::Base64File,
content_type: None,
base64_prefix: Some(prefix),
enabled: true,
};
}
FormField {
key: fp.key.to_source().to_string(),
value: fp.value.filename.to_string(),
kind: FormFieldKind::File,
content_type,
base64_prefix: None,
enabled: true,
}
}
}
}
fn body_source(b: &Body) -> Option<String> {
let s = match &b.value {
Bytes::Json(v) => v.to_source().to_string(),
Bytes::Xml(x) => x.clone(),
Bytes::OnelineString(t) => t.to_source().to_string(),
Bytes::MultilineString(m) => m.to_source().to_string(),
Bytes::Hex(h) => h.to_string(),
Bytes::Base64(_) | Bytes::File(_) => return None,
};
let s = s.trim().to_string();
(!s.is_empty()).then_some(s)
}
fn source_line(line: usize, lines: &[&str]) -> Option<String> {
let idx = line.checked_sub(1)?;
lines
.get(idx)
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
}
fn capture_pair(c: &Capture, lines: &[&str]) -> Option<(String, String)> {
let line = source_line(c.query.source_info.start.line, lines)?;
let (name, expr) = line.split_once(':')?;
Some((name.trim().to_string(), expr.trim().to_string()))
}
fn reports_from_span(lines: &[&str], start: usize, end: usize) -> Vec<(String, String)> {
let from = start.saturating_sub(1);
let to = end.saturating_sub(1).min(lines.len());
let mut reports = Vec::new();
let mut i = from;
while i < to {
if is_reports_marker(lines[i]) {
i += 1;
break;
}
i += 1;
}
while i < to {
match parse_report_row(lines[i]) {
Some(row) => reports.push(row),
None => break,
}
i += 1;
}
reports
}
fn is_reports_marker(line: &str) -> bool {
line.trim_start()
.strip_prefix('#')
.map(str::trim)
.is_some_and(|rest| rest.eq_ignore_ascii_case("[Reports]"))
}
fn parse_report_row(line: &str) -> Option<(String, String)> {
let rest = line.trim_start().strip_prefix('#')?.trim_start();
if rest.starts_with('[') {
return None;
}
let (name, query) = rest.split_once(':')?;
let name = name.trim();
let query = query.trim();
if name.is_empty()
|| query.is_empty()
|| !name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return None;
}
Some((name.to_string(), query.to_string()))
}
fn title_from_span(start_line: usize, lines: &[&str]) -> String {
let method = (start_line.saturating_sub(1)..lines.len())
.find(|&i| {
let l = lines[i].trim();
!l.is_empty() && !l.starts_with('#')
})
.unwrap_or(lines.len());
let block_start = lines[..method]
.iter()
.rposition(|l| !l.trim().starts_with('#'))
.map_or(0, |i| i + 1);
lines[block_start..method]
.iter()
.map(|l| {
l.trim_start_matches('#')
.chars()
.filter(|c| !matches!(c, '-' | '='))
.collect::<String>()
.trim()
.to_string()
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::super::entry::collection_to_hurl;
use super::*;
#[test]
fn parse_error_explains_captures_needing_a_response_line() {
let content =
"# Get token\nPOST http://h/oauth2\n[Captures]\naccess_token: jsonpath \"$.token\"\n";
assert!(parse_hurl(content).is_empty(), "this really is unparseable");
let why = parse_hurl_error(content).expect("a reason is produced");
assert!(
why.contains("Captures"),
"names the offending section: {why}"
);
assert!(
why.contains("HTTP"),
"points at the missing response line: {why}"
);
assert!(why.contains("line 3"), "cites the line: {why}");
}
#[test]
fn parse_error_is_none_for_valid_hurl() {
let content = "GET http://h/x\nHTTP 200\n[Captures]\ntok: jsonpath \"$.t\"\n";
assert_eq!(parse_hurl(content).len(), 1);
assert!(parse_hurl_error(content).is_none());
}
#[test]
fn body_terminates_at_http_so_later_entries_parse() {
let content = "# First\nPOST http://x/a\nContent-Type: application/json\n{\n \"k\": \"v\"\n}\nHTTP 200\n\n# Second\nGET http://x/b\nAccept: application/json\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 2, "the body must not swallow the second entry");
assert_eq!(e[0].body.as_deref(), Some("{\n \"k\": \"v\"\n}"));
assert_eq!(e[1].method, "GET");
assert!(e[1].body.is_none());
}
#[test]
fn hurl_round_trips_through_serialize_and_parse() {
let original = vec![
HurlEntry::from_fields(
"Create post",
"POST",
"{{ BASE_URL }}/posts",
vec![("Content-Type".into(), "application/json".into(), true)],
"{\n \"title\": \"hi\"\n}",
),
HurlEntry::from_fields(
"Health",
"GET",
"{{ BASE_URL }}/health",
vec![("Accept".into(), "application/json".into(), true)],
"",
),
];
let text = collection_to_hurl(&original);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), original.len());
for (a, b) in original.iter().zip(&reparsed) {
assert_eq!(a.title, b.title);
assert_eq!(a.method, b.method);
assert_eq!(a.url, b.url);
assert_eq!(a.headers, b.headers);
assert_eq!(a.body, b.body);
}
}
#[test]
fn sections_and_captures_round_trip() {
let src = "# Auth\nGET {{ BASE_URL }}/users/1\n[BasicAuth]\n{{ USER }}: {{ PASS }}\nHTTP 200\n[Captures]\ntoken: jsonpath \"$.token\"\n";
let parsed = parse_hurl(src);
assert_eq!(parsed.len(), 1);
let reparsed = parse_hurl(&collection_to_hurl(&parsed));
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].basic_auth, parsed[0].basic_auth);
assert_eq!(reparsed[0].expected_status, parsed[0].expected_status);
assert_eq!(reparsed[0].captures, parsed[0].captures);
}
#[test]
fn asserts_and_captures_without_explicit_status_still_round_trip() {
let mut entry =
HurlEntry::from_fields("Health", "GET", "{{ BASE_URL }}/health", vec![], "");
entry.asserts = vec!["jsonpath \"$.status\" == \"ok\"".to_string()];
entry.captures = vec![("id".to_string(), "jsonpath \"$.id\"".to_string())];
assert!(entry.expected_status.is_none());
let text = entry.to_hurl();
assert!(
text.contains("HTTP *"),
"wildcard status line expected:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].asserts, entry.asserts);
assert_eq!(reparsed[0].captures, entry.captures);
assert!(reparsed[0].expected_status.is_none());
}
#[test]
fn asserts_are_parsed_and_round_trip() {
let src = "# Health\nGET {{ BASE_URL }}/health\nHTTP 200\n[Asserts]\njsonpath \"$.status\" == \"ok\"\njsonpath \"$.count\" >= 1\n[Captures]\nid: jsonpath \"$.id\"\n";
let parsed = parse_hurl(src);
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].asserts,
vec![
"jsonpath \"$.status\" == \"ok\"".to_string(),
"jsonpath \"$.count\" >= 1".to_string(),
],
);
let reparsed = parse_hurl(&collection_to_hurl(&parsed));
assert_eq!(reparsed[0].asserts, parsed[0].asserts);
assert_eq!(reparsed[0].captures, parsed[0].captures);
}
#[test]
fn cookies_round_trip() {
let mut entry = HurlEntry::from_fields("Login", "GET", "{{ BASE_URL }}/me", vec![], "");
entry.cookies = vec![
("session".to_string(), "abc123".to_string(), true),
("theme".to_string(), "dark".to_string(), true),
];
let text = entry.to_hurl();
assert!(
text.contains("[Cookies]"),
"expected a Cookies section:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].cookies, entry.cookies);
}
#[test]
fn reports_block_round_trips_through_serialize_and_parse() {
let mut entry = HurlEntry::from_fields("Process", "POST", "{{ URL }}/process", vec![], "");
entry.reports = vec![
("status".to_string(), "jsonpath \"$.status\"".to_string()),
(
"overall".to_string(),
"jsonpath \"$.overall_result\"".to_string(),
),
];
let text = entry.to_hurl();
assert!(
text.contains("# [Reports]"),
"expected a commented Reports marker:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].reports, entry.reports);
}
#[test]
fn reports_block_is_ignored_by_hurl_core_so_the_file_still_parses() {
let mut entry = HurlEntry::from_fields("Process", "POST", "http://h/process", vec![], "");
entry.captures = vec![("token".to_string(), "jsonpath \"$.token\"".to_string())];
entry.reports = vec![("status".to_string(), "jsonpath \"$.status\"".to_string())];
let text = entry.to_hurl();
assert!(
parse_hurl_file(&text).is_ok(),
"hurl_core should still parse a file with a # [Reports] block:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed[0].captures, entry.captures);
assert_eq!(reparsed[0].reports, entry.reports);
}
#[test]
fn reports_block_scan_does_not_bleed_across_entries() {
let mut a = HurlEntry::from_fields("First", "GET", "http://h/a", vec![], "");
a.reports = vec![("s".to_string(), "jsonpath \"$.s\"".to_string())];
let b = HurlEntry::from_fields("Second", "GET", "http://h/b", vec![], "");
let doc = collection_to_hurl(&[a.clone(), b.clone()]);
let parsed = parse_hurl(&doc);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].reports, a.reports);
assert!(
parsed[1].reports.is_empty(),
"second entry must not inherit the first's Reports block"
);
}
#[test]
fn text_only_form_fields_round_trip_as_form_section() {
let mut entry = HurlEntry::from_fields("Login", "POST", "{{ BASE_URL }}/login", vec![], "");
entry.form_fields = vec![
FormField {
key: "user".to_string(),
value: "bob".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
FormField {
key: "pass".to_string(),
value: "secret".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
];
let text = entry.to_hurl();
assert!(
text.contains("[Form]"),
"all-Text fields should serialize as [Form]:\n{text}"
);
assert!(!text.contains("[Multipart]"));
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].form_fields, entry.form_fields);
}
#[test]
fn file_form_fields_round_trip_as_multipart_section() {
let mut entry =
HurlEntry::from_fields("Upload", "POST", "{{ BASE_URL }}/upload", vec![], "");
entry.form_fields = vec![
FormField {
key: "field1".to_string(),
value: "value1".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
FormField {
key: "field2".to_string(),
value: "example.txt".to_string(),
kind: FormFieldKind::File,
content_type: None,
base64_prefix: None,
enabled: true,
},
FormField {
key: "field3".to_string(),
value: "example.zip".to_string(),
kind: FormFieldKind::File,
content_type: Some("application/zip".to_string()),
base64_prefix: None,
enabled: true,
},
];
let text = entry.to_hurl();
assert!(
text.contains("[Multipart]"),
"a File field should switch to [Multipart]:\n{text}"
);
assert!(!text.contains("[Form]\n"));
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].form_fields, entry.form_fields);
}
#[test]
fn file_form_field_path_with_spaces_round_trips_as_a_real_path() {
let mut entry =
HurlEntry::from_fields("Upload", "POST", "{{ BASE_URL }}/upload", vec![], "");
entry.form_fields = vec![FormField {
key: "doc".to_string(),
value: "/tmp/my report final.pdf".to_string(),
kind: FormFieldKind::File,
content_type: None,
base64_prefix: None,
enabled: true,
}];
let text = entry.to_hurl();
assert!(
text.contains(r"file,/tmp/my\ report\ final.pdf;"),
"the emitted Hurl escapes spaces in the path:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(
reparsed[0].form_fields, entry.form_fields,
"and it parses back to the same unescaped real path"
);
}
#[test]
fn loading_a_multipart_file_with_escaped_spaces_yields_a_real_path() {
let src = "POST http://x/upload\n[Multipart]\ndoc: file,my\\ report.pdf;\n";
let parsed = parse_hurl(src);
assert_eq!(parsed[0].form_fields.len(), 1);
assert_eq!(
parsed[0].form_fields[0].value, "my report.pdf",
"the stored path is the decoded real path, not the escaped source"
);
}
#[test]
fn base64_file_field_round_trips_with_its_prefix() {
let mut entry =
HurlEntry::from_fields("Upload", "POST", "{{ BASE_URL }}/upload", vec![], "");
entry.form_fields = vec![FormField {
key: "avatar".to_string(),
value: "/tmp/pic.png".to_string(),
kind: FormFieldKind::Base64File,
content_type: None,
base64_prefix: Some("data:image/png;base64,".to_string()),
enabled: true,
}];
let text = entry.to_hurl();
assert!(
text.contains("[Multipart]"),
"a Base64File field serializes under [Multipart]:\n{text}"
);
assert!(
text.contains("x-paperboy-base64;"),
"the emitted Hurl carries the PaperBoy marker:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(
reparsed[0].form_fields, entry.form_fields,
"the Base64File kind and its prefix survive the round trip"
);
}
#[test]
fn base64_file_field_with_empty_prefix_round_trips() {
let mut entry =
HurlEntry::from_fields("Upload", "POST", "{{ BASE_URL }}/upload", vec![], "");
entry.form_fields = vec![FormField {
key: "blob".to_string(),
value: "/tmp/data.bin".to_string(),
kind: FormFieldKind::Base64File,
content_type: None,
base64_prefix: Some(String::new()),
enabled: true,
}];
let reparsed = parse_hurl(&entry.to_hurl());
assert_eq!(reparsed[0].form_fields, entry.form_fields);
}
#[test]
fn disabled_header_round_trips_as_a_comment() {
let mut entry = HurlEntry::from_fields("Get", "GET", "http://x/y", vec![], "");
entry.headers = vec![
("Accept".to_string(), "application/json".to_string(), true),
("X-Off".to_string(), "no".to_string(), false),
];
let text = entry.to_hurl();
assert!(
text.contains("\n# X-Off: no\n"),
"the disabled header is written as a comment:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0].headers, entry.headers);
}
#[test]
fn disabled_cookie_and_query_rows_round_trip() {
let mut entry = HurlEntry::from_fields("Get", "GET", "http://x/y", vec![], "");
entry.cookies = vec![
("session".to_string(), "abc".to_string(), true),
("stale".to_string(), "1".to_string(), false),
];
entry.queries = vec![
("page".to_string(), "2".to_string(), false),
("q".to_string(), "hi".to_string(), true),
];
let text = entry.to_hurl();
assert!(
text.contains("# stale: 1"),
"disabled cookie commented:\n{text}"
);
assert!(
text.contains("# page: 2"),
"disabled query commented:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed[0].cookies, entry.cookies);
assert_eq!(reparsed[0].queries, entry.queries);
}
#[test]
fn a_hand_written_commented_request_line_parses_as_disabled() {
let src = "GET http://x/y\nAccept: text/plain\n# X-Debug: 1\n# just a note\n[Query]\npage: 2\n# limit: 10\n";
let parsed = parse_hurl(src);
assert_eq!(
parsed[0].headers,
vec![
("Accept".to_string(), "text/plain".to_string(), true),
("X-Debug".to_string(), "1".to_string(), false),
],
"the commented header line is a disabled entry; the prose note is ignored"
);
assert_eq!(
parsed[0].queries,
vec![
("page".to_string(), "2".to_string(), true),
("limit".to_string(), "10".to_string(), false),
]
);
}
#[test]
fn disabled_rows_keep_their_position_relative_to_enabled_ones() {
let mut entry = HurlEntry::from_fields("Get", "GET", "http://x/y", vec![], "");
entry.headers = vec![
("A".to_string(), "1".to_string(), false),
("B".to_string(), "2".to_string(), true),
("C".to_string(), "3".to_string(), false),
];
let reparsed = parse_hurl(&entry.to_hurl());
assert_eq!(reparsed[0].headers, entry.headers, "order is preserved");
}
#[test]
fn disabled_text_form_field_round_trips_as_a_comment() {
let mut entry = HurlEntry::from_fields("Post", "POST", "http://x/y", vec![], "");
entry.form_fields = vec![
FormField {
key: "on".to_string(),
value: "yes".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
FormField {
key: "off".to_string(),
value: "no".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: false,
},
];
let text = entry.to_hurl();
assert!(text.contains("[Form]"), "text-only stays [Form]:\n{text}");
assert!(
text.contains("# off: no"),
"disabled field commented:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed[0].form_fields, entry.form_fields);
}
#[test]
fn a_disabled_file_field_does_not_flip_a_form_section_to_multipart() {
let mut entry = HurlEntry::from_fields("Post", "POST", "http://x/y", vec![], "");
entry.form_fields = vec![
FormField {
key: "name".to_string(),
value: "bob".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
},
FormField {
key: "doc".to_string(),
value: "/tmp/a b.pdf".to_string(),
kind: FormFieldKind::File,
content_type: Some("application/pdf".to_string()),
base64_prefix: None,
enabled: false,
},
];
let text = entry.to_hurl();
assert!(text.contains("[Form]"), "stays [Form]:\n{text}");
assert!(!text.contains("[Multipart]"));
let reparsed = parse_hurl(&text);
assert_eq!(reparsed[0].form_fields, entry.form_fields);
}
#[test]
fn a_disabled_multipart_file_field_round_trips() {
let mut entry = HurlEntry::from_fields("Post", "POST", "http://x/y", vec![], "");
entry.form_fields = vec![
FormField {
key: "upload".to_string(),
value: "/tmp/on.bin".to_string(),
kind: FormFieldKind::File,
content_type: None,
base64_prefix: None,
enabled: true,
},
FormField {
key: "avatar".to_string(),
value: "/tmp/off.png".to_string(),
kind: FormFieldKind::Base64File,
content_type: None,
base64_prefix: Some("data:image/png;base64,".to_string()),
enabled: false,
},
];
let text = entry.to_hurl();
assert!(
text.contains("[Multipart]"),
"an enabled File stays [Multipart]:\n{text}"
);
let reparsed = parse_hurl(&text);
assert_eq!(reparsed[0].form_fields, entry.form_fields);
}
}