use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hurl_core::ast::{
Body, Bytes, Capture, Entry, KeyValue, MultipartParam, SectionValue, StatusValue, VersionValue,
};
use hurl_core::parser::parse_hurl_file;
use hurl_core::types::ToSource;
use super::entry::{
BASE64_FILE_CT_MARKER, CommentAnchor, EntryComment, FormField, FormFieldKind, HurlEntry, KvRow,
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, i == 0)
})
.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,
is_first: bool,
) -> HurlEntry {
let req = &e.request;
let mut anchors: Vec<usize> = req
.sections
.iter()
.map(|s| s.source_info.start.line)
.collect();
if let Some(b) = &req.body {
anchors.push(body_start_line(b));
}
if let Some(resp) = &e.response {
anchors.push(resp.status.source_info.start.line);
}
let mut landmarks: Vec<(usize, CommentAnchor)> = Vec::new();
for section in &req.sections {
let anchor = match §ion.value {
SectionValue::BasicAuth(_) => CommentAnchor::BasicAuth,
SectionValue::Cookies(_) => CommentAnchor::Cookies,
SectionValue::QueryParams(..) => CommentAnchor::Query,
SectionValue::FormParams(..) | SectionValue::MultipartFormData(..) => {
CommentAnchor::Form
}
SectionValue::Options(_) => CommentAnchor::Options,
_ => continue,
};
landmarks.push((section.source_info.start.line, anchor));
}
if let Some(b) = &req.body {
landmarks.push((body_start_line(b), CommentAnchor::Body));
}
if let Some(resp) = &e.response {
let status_line = resp.status.source_info.start.line;
landmarks.push((status_line, CommentAnchor::Response));
if !resp.headers.is_empty()
|| scan_kv_rows(lines, status_line + 1, first_response_anchor(resp))
.iter()
.any(|r| !r.enabled)
{
landmarks.push((status_line + 1, CommentAnchor::ResponseHeaders));
}
for section in &resp.sections {
let anchor = match §ion.value {
SectionValue::Asserts(_) => CommentAnchor::Asserts,
SectionValue::Captures(_) => CommentAnchor::Captures,
_ => continue,
};
landmarks.push((section.source_info.start.line, anchor));
}
if let Some(b) = &resp.body {
landmarks.push((body_start_line(b), CommentAnchor::ResponseBody));
}
}
landmarks.sort_by_key(|(line, _)| *line);
let mut body_ranges: Vec<(usize, usize)> = Vec::new();
if let Some(b) = &req.body {
body_ranges.push(body_line_span(b));
}
if let Some(b) = e.response.as_ref().and_then(|r| r.body.as_ref()) {
body_ranges.push(body_line_span(b));
}
let mut basic_auth = None;
let mut form_fields = Vec::new();
let mut query_params = Vec::new();
let mut cookies = Vec::new();
let mut options = Vec::new();
for section in &req.sections {
let rows_start = section.source_info.start.line + 1;
let rows_end = first_anchor_after(&anchors, section.source_info.start.line);
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, rows_end);
}
SectionValue::MultipartFormData(parts, _) => {
form_fields =
form_fields_from_section(&[], Some(parts), lines, rows_start, rows_end);
}
SectionValue::QueryParams(..) => {
query_params = scan_kv_rows(lines, rows_start, rows_end)
}
SectionValue::Cookies(_) => cookies = scan_kv_rows(lines, rows_start, rows_end),
SectionValue::Options(_) => options = scan_kv_rows(lines, rows_start, rows_end),
_ => {}
}
}
let mut expected_status = None;
let mut captures = Vec::new();
let mut asserts = Vec::new();
let mut response_version = None;
let mut response_headers = Vec::new();
let mut response_body = None;
if let Some(resp) = &e.response {
if let StatusValue::Specific(n) = resp.status.value {
expected_status = Some(n as u16);
}
response_version = match resp.version.value {
VersionValue::VersionAny => None,
v => Some(v.to_string()),
};
response_headers = scan_kv_rows(
lines,
resp.status.source_info.start.line + 1,
first_response_anchor(resp),
);
response_body = resp.body.as_ref().and_then(body_source);
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,
first_anchor_after(&anchors, req.url.source_info.start.line),
),
basic_auth,
form_fields,
is_multipart,
queries: query_params,
cookies,
options,
body: req.body.as_ref().and_then(body_source),
expected_status,
response_version,
response_headers,
response_body,
captures,
asserts,
reports: reports_from_span(lines, scan_start, scan_end),
comments: scan_comments(
lines,
&landmarks,
&body_ranges,
scan_start,
scan_end,
is_first,
),
user_added: false,
modified: false,
last_run: RunStatus::default(),
last_response: None,
}
}
fn first_response_anchor(resp: &hurl_core::ast::Response) -> Option<usize> {
resp.sections
.iter()
.map(|s| s.source_info.start.line)
.chain(resp.body.as_ref().map(body_start_line))
.min()
}
fn kv_pair(kv: &KeyValue) -> (String, String) {
(
kv.key.to_source().to_string(),
kv.value.to_source().to_string(),
)
}
fn first_anchor_after(anchors: &[usize], after: usize) -> Option<usize> {
anchors.iter().copied().filter(|&a| a > after).min()
}
fn body_start_line(b: &Body) -> usize {
b.space0.source_info.start.line
}
fn body_line_span(b: &Body) -> (usize, usize) {
let start = body_start_line(b);
let end = b.line_terminator0.newline.source_info.start.line.max(start);
(start, end + 1)
}
fn scan_comments(
lines: &[&str],
landmarks: &[(usize, CommentAnchor)],
body_ranges: &[(usize, usize)],
method_line: usize,
scan_end: usize,
is_first: bool,
) -> Vec<EntryComment> {
#[derive(Clone, Copy)]
enum RowKind {
Kv,
Form,
}
let first_landmark = landmarks.first().map(|(l, _)| *l);
let mut regions: Vec<(usize, usize, RowKind)> = vec![(
method_line + 1,
first_landmark.unwrap_or(scan_end),
RowKind::Kv,
)];
for (k, (line, anchor)) in landmarks.iter().enumerate() {
let (kind, rows_start) = match anchor {
CommentAnchor::Cookies | CommentAnchor::Query | CommentAnchor::Options => {
(RowKind::Kv, line + 1)
}
CommentAnchor::Form => (RowKind::Form, line + 1),
CommentAnchor::ResponseHeaders => (RowKind::Kv, *line),
_ => continue,
};
let end = landmarks.get(k + 1).map(|(l, _)| *l).unwrap_or(scan_end);
regions.push((rows_start, end, kind));
}
let in_body = |line_no: usize| {
body_ranges
.iter()
.any(|&(s, e)| line_no >= s && line_no < e)
};
let is_disabled_row = |line_no: usize| {
let Some(&line) = lines.get(line_no.wrapping_sub(1)) else {
return false;
};
regions.iter().any(|&(s, e, kind)| {
line_no >= s
&& line_no < e
&& (desc_line(line).is_some()
|| match kind {
RowKind::Kv => parse_kv_row(line).is_some(),
RowKind::Form => parse_form_field_line(uncomment(line).1, true).is_some(),
})
})
};
let is_structural = |line_no: usize| {
if in_body(line_no) {
return true;
}
let Some(&line) = lines.get(line_no.wrapping_sub(1)) else {
return false;
};
let t = line.trim();
if t.is_empty() {
return false;
}
if !t.starts_with('#') {
return true;
}
is_disabled_row(line_no)
};
let anchor_of_line = |l2: usize| match first_landmark {
Some(fl) if l2 >= fl => landmarks
.iter()
.rev()
.find(|(l, _)| *l <= l2)
.map_or(CommentAnchor::Headers, |(_, a)| *a),
_ => CommentAnchor::Headers,
};
let anchor_for_comment = |line_no: usize| {
(line_no + 1..scan_end)
.find(|&l2| is_structural(l2))
.map_or(CommentAnchor::Trailing, anchor_of_line)
};
let reports_block = {
let to = scan_end.min(lines.len() + 1);
let marker =
(method_line..to).find(|&i| lines.get(i - 1).is_some_and(|l| is_reports_marker(l)));
marker.map_or(0..0, |m| {
let mut j = m + 1;
while j < to && lines.get(j - 1).and_then(|l| parse_report_row(l)).is_some() {
j += 1;
}
m..j
})
};
let next_title = if scan_end <= lines.len() {
let mut top = scan_end;
let mut idx = scan_end - 1;
while idx >= method_line
&& lines
.get(idx - 1)
.is_some_and(|l| l.trim().starts_with('#'))
{
top = idx;
idx -= 1;
}
top..scan_end
} else {
0..0
};
let mut out = Vec::new();
if is_first {
let mut title_top = method_line;
let mut idx = method_line.wrapping_sub(1);
while idx >= 1
&& lines
.get(idx - 1)
.is_some_and(|l| l.trim().starts_with('#'))
{
title_top = idx;
idx -= 1;
}
for ln in 1..title_top {
if let Some(t) = lines
.get(ln - 1)
.map(|l| l.trim())
.filter(|t| t.starts_with('#'))
{
out.push(EntryComment {
anchor: CommentAnchor::Lead,
text: t.to_string(),
});
}
}
}
for line_no in method_line..scan_end.min(lines.len() + 1) {
let Some(t) = lines.get(line_no - 1).map(|l| l.trim()) else {
break;
};
if !t.starts_with('#')
|| in_body(line_no)
|| is_disabled_row(line_no)
|| reports_block.contains(&line_no)
|| next_title.contains(&line_no)
{
continue;
}
out.push(EntryComment {
anchor: anchor_for_comment(line_no),
text: t.to_string(),
});
}
out
}
fn scan_kv_rows(lines: &[&str], start: usize, end: Option<usize>) -> Vec<KvRow> {
let mut rows: Vec<KvRow> = Vec::new();
let mut pending_desc: Vec<String> = Vec::new();
let mut i = start.saturating_sub(1);
let limit = end.map(|e| e.saturating_sub(1)).unwrap_or(lines.len());
while i < limit {
let Some(&line) = lines.get(i) else { break };
if let Some(text) = desc_line(line) {
pending_desc.push(text.to_string());
i += 1;
continue;
}
match parse_kv_row(line) {
Some(mut row) => {
row.desc = std::mem::take(&mut pending_desc).join("\n");
rows.push(row);
}
None if end.is_some() => {
pending_desc.clear();
}
None => break,
}
i += 1;
}
rows
}
pub(crate) fn desc_line(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix(crate::hurl::entry::DESC_MARKER.trim_end())?;
match rest.strip_prefix(' ') {
Some(text) => Some(text.trim_end()),
None if rest.is_empty() => Some(""),
None => None,
}
}
fn parse_kv_row(line: &str) -> Option<KvRow> {
let (enabled, rest) = uncomment(line);
let (key, value) = split_kv(rest)?;
Some(KvRow::toggled(key, value, 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,
rows_end: Option<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,
desc: String::new(),
},
));
}
}
rows.extend(scan_disabled_form_rows(lines, rows_start, rows_end));
rows.sort_by_key(|(line, _)| *line);
let descs = scan_row_descriptions(lines, rows_start, rows_end);
rows.into_iter()
.map(|(line, mut f)| {
if let Some(desc) = descs.get(&line) {
f.desc = desc.clone();
}
f
})
.collect()
}
fn scan_row_descriptions(
lines: &[&str],
start: usize,
end: Option<usize>,
) -> std::collections::HashMap<usize, String> {
let mut out = std::collections::HashMap::new();
let mut pending: Vec<String> = Vec::new();
let mut i = start.saturating_sub(1);
let limit = end.map(|e| e.saturating_sub(1)).unwrap_or(lines.len());
while i < limit {
let Some(&line) = lines.get(i) else { break };
match desc_line(line) {
Some(text) => pending.push(text.to_string()),
None if !pending.is_empty() => {
out.insert(i + 1, std::mem::take(&mut pending).join("\n"));
}
None => {}
}
i += 1;
}
out
}
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,
end: Option<usize>,
) -> Vec<(usize, FormField)> {
let mut out = Vec::new();
let mut i = start.saturating_sub(1);
let limit = end.map(|e| e.saturating_sub(1)).unwrap_or(lines.len());
while i < limit {
let Some(&line) = lines.get(i) else { break };
if desc_line(line).is_some() {
i += 1;
continue;
}
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 if end.is_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,
desc: String::new(),
})
}
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,
desc: String::new(),
};
}
FormField {
key: key.to_string(),
value: path,
kind: FormFieldKind::File,
content_type: (!ct.is_empty()).then(|| ct.to_string()),
base64_prefix: None,
enabled: true,
desc: String::new(),
}
}
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,
desc: String::new(),
},
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,
desc: String::new(),
};
}
FormField {
key: fp.key.to_source().to_string(),
value: fp.value.filename.to_string(),
kind: FormFieldKind::File,
content_type,
base64_prefix: None,
enabled: true,
desc: String::new(),
}
}
}
}
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 blank_line_before_headers_does_not_drop_them() {
let content = "# Get token\nPOST {{ URL }}/oauth2\n\nContent-Length: 0\nUser-Agent: crabman/0.1.0\nAccept: */*\nclient_id: {{ CLIENT_ID }}\n\nHTTP 200\n[Captures]\naccess_token: jsonpath \"$.token\"\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert_eq!(
e[0].headers,
vec![
("Content-Length".into(), "0".into(), true),
("User-Agent".into(), "crabman/0.1.0".into(), true),
("Accept".into(), "*/*".into(), true),
("client_id".into(), "{{ CLIENT_ID }}".into(), true),
],
"a blank line after the request line must not drop the headers"
);
}
#[test]
fn blank_line_before_headers_without_body_leaves_headers_intact() {
let content = "GET http://h/x\n\nAccept: application/json\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert_eq!(
e[0].headers,
vec![("Accept".into(), "application/json".into(), true)]
);
assert!(e[0].body.is_none());
}
#[test]
fn blank_line_before_json_body_with_no_headers_stays_empty() {
let content = "POST http://h/x\n\n{\n \"k\": \"v\"\n}\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert!(e[0].headers.is_empty(), "the JSON body is not a header");
assert_eq!(e[0].body.as_deref(), Some("{\n \"k\": \"v\"\n}"));
}
#[test]
fn blank_line_after_section_header_keeps_rows() {
let content = "GET http://h/x\n[QueryStringParams]\n\npage: 1\nsize: 20\n[Cookies]\n\ntheme: dark\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert_eq!(
e[0].queries,
vec![
("page".into(), "1".into(), true),
("size".into(), "20".into(), true),
]
);
assert_eq!(e[0].cookies, vec![("theme".into(), "dark".into(), true)]);
}
#[test]
fn blank_and_comment_lines_between_headers_are_tolerated() {
let content = "GET http://h/x\nAccept: 1\n\n# a prose note\nContent-Type: 2\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert_eq!(
e[0].headers,
vec![
("Accept".into(), "1".into(), true),
("Content-Type".into(), "2".into(), true),
],
"an interior blank + prose comment must not truncate the header block"
);
}
#[test]
fn comment_before_first_header_is_skipped() {
let content = "GET http://h/x\n# leading note\nAccept: 1\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert_eq!(e[0].headers, vec![("Accept".into(), "1".into(), true)]);
}
#[test]
fn trailing_and_all_disabled_headers_before_http_are_recovered() {
let trailing = "GET http://h/x\nAccept: 1\n# X-Debug: on\nHTTP 200\n";
let e = parse_hurl(trailing);
assert_eq!(
e[0].headers,
vec![
("Accept".into(), "1".into(), true),
("X-Debug".into(), "on".into(), false),
]
);
let all_disabled = "GET http://h/x\n# A: 1\n# B: 2\nHTTP 200\n";
let e = parse_hurl(all_disabled);
assert_eq!(
e[0].headers,
vec![
("A".into(), "1".into(), false),
("B".into(), "2".into(), false),
]
);
}
#[test]
fn a_blank_line_header_scan_never_bleeds_into_the_next_request() {
let content = "GET http://h/a\nAccept: 1\n\n# X-Not-Mine: v\nGET http://h/b\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 2);
assert_eq!(
e[0].headers,
vec![("Accept".into(), "1".into(), true)],
"the second entry's banner must not leak into the first entry's headers"
);
}
#[test]
fn open_mode_zero_header_request_does_not_absorb_next_entrys_comment() {
let content =
"GET http://api/health\n\n# TODO: fix auth below\nPOST http://api/login\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 2);
assert!(
e[0].headers.is_empty(),
"a zero-header request must not absorb the next entry's comment: {:?}",
e[0].headers
);
}
#[test]
fn all_disabled_and_trailing_section_rows_before_http_are_recovered() {
let content = "GET http://h/x\n[QueryStringParams]\n# a: 1\n# b: 2\n[Cookies]\ntheme: dark\n# hidden: y\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
assert_eq!(
e[0].queries,
vec![
("a".into(), "1".into(), false),
("b".into(), "2".into(), false),
]
);
assert_eq!(
e[0].cookies,
vec![
("theme".into(), "dark".into(), true),
("hidden".into(), "y".into(), false),
]
);
}
#[test]
fn blank_line_after_form_header_keeps_disabled_rows() {
let content = "POST http://h/x\n[Form]\n\nname: alice\n# nickname: al\nHTTP 200\n";
let e = parse_hurl(content);
assert_eq!(e.len(), 1);
let fields: Vec<(String, bool)> = e[0]
.form_fields
.iter()
.map(|f| (f.key.clone(), f.enabled))
.collect();
assert_eq!(
fields,
vec![("name".into(), true), ("nickname".into(), false)]
);
}
#[test]
fn hurl_round_trips_through_serialize_and_parse() {
let original = vec![
HurlEntry::from_fields(
"Create post",
"POST",
"{{ BASE_URL }}/posts",
vec![KvRow::toggled("Content-Type", "application/json", true)],
"{\n \"title\": \"hi\"\n}",
),
HurlEntry::from_fields(
"Health",
"GET",
"{{ BASE_URL }}/health",
vec![KvRow::toggled("Accept", "application/json", 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![
KvRow::toggled("session", "abc123", true),
KvRow::toggled("theme", "dark", 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,
desc: String::new(),
},
FormField {
key: "pass".to_string(),
value: "secret".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
},
];
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,
desc: String::new(),
},
FormField {
key: "field2".to_string(),
value: "example.txt".to_string(),
kind: FormFieldKind::File,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
},
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,
desc: String::new(),
},
];
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,
desc: String::new(),
}];
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,
desc: String::new(),
}];
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,
desc: String::new(),
}];
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![
KvRow::toggled("Accept", "application/json", true),
KvRow::toggled("X-Off", "no", 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![
KvRow::toggled("session", "abc", true),
KvRow::toggled("stale", "1", false),
];
entry.queries = vec![
KvRow::toggled("page", "2", false),
KvRow::toggled("q", "hi", 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![
KvRow::toggled("A", "1", false),
KvRow::toggled("B", "2", true),
KvRow::toggled("C", "3", 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,
desc: String::new(),
},
FormField {
key: "off".to_string(),
value: "no".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: false,
desc: String::new(),
},
];
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,
desc: String::new(),
},
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,
desc: String::new(),
},
];
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,
desc: String::new(),
},
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,
desc: String::new(),
},
];
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);
}
fn assert_comments_round_trip(src: &str) -> Vec<HurlEntry> {
let first = parse_hurl(src);
let text = collection_to_hurl(&first);
let second = parse_hurl(&text);
let c1: Vec<_> = first.iter().map(|e| e.comments.clone()).collect();
let c2: Vec<_> = second.iter().map(|e| e.comments.clone()).collect();
assert_eq!(
c1, c2,
"comments must be stable across a round trip\n--- serialized ---\n{text}"
);
second
}
#[test]
fn a_comment_before_asserts_round_trips_before_asserts() {
let src = "GET http://h/a\nHTTP 200\n# validate the token\n[Asserts]\njsonpath \"$.token\" exists\n";
let e = parse_hurl(src);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::Asserts,
text: "# validate the token".into(),
}]
);
let text = e[0].to_hurl();
assert!(
text.contains("# validate the token\n[Asserts]"),
"the comment must stay directly before [Asserts]:\n{text}"
);
assert_comments_round_trip(src);
}
#[test]
fn a_prose_comment_in_the_header_region_is_kept_and_anchored_to_headers() {
let src = "POST http://h/a\n# auth headers below\nAuthorization: Bearer x\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::Headers,
text: "# auth headers below".into(),
}]
);
assert_eq!(
e[0].headers,
vec![("Authorization".into(), "Bearer x".into(), true)]
);
assert_comments_round_trip(src);
}
#[test]
fn a_prose_comment_and_a_disabled_row_coexist_without_duplication() {
let src = "GET http://h/a\n# X-Debug: 1\n# just a note\nAccept: 1\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(
e[0].headers,
vec![
("X-Debug".into(), "1".into(), false),
("Accept".into(), "1".into(), true),
]
);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::Headers,
text: "# just a note".into(),
}]
);
let entries = assert_comments_round_trip(src);
let text = collection_to_hurl(&entries);
assert_eq!(text.matches("# X-Debug: 1").count(), 1, "{text}");
assert_eq!(text.matches("# just a note").count(), 1, "{text}");
}
#[test]
fn a_reports_block_is_not_re_captured_as_prose() {
let src = "GET http://h/a\nHTTP 200\n# [Reports]\n# total: jsonpath \"$.total\"\n";
let e = parse_hurl(src);
assert_eq!(
e[0].reports,
vec![("total".into(), "jsonpath \"$.total\"".into())]
);
assert!(
e[0].comments.is_empty(),
"the reports block must not leak into prose comments: {:?}",
e[0].comments
);
let text = e[0].to_hurl();
assert_eq!(text.matches("# [Reports]").count(), 1, "{text}");
assert_eq!(text.matches("# total:").count(), 1, "{text}");
}
#[test]
fn a_banner_and_extra_leading_prose_round_trip() {
let src = "#####\n# File header\n#####\n\n# Get token\nGET http://h/a\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(e[0].title, "Get token");
assert_eq!(
e[0].comments,
vec![
EntryComment {
anchor: CommentAnchor::Lead,
text: "#####".into()
},
EntryComment {
anchor: CommentAnchor::Lead,
text: "# File header".into()
},
EntryComment {
anchor: CommentAnchor::Lead,
text: "#####".into()
},
]
);
assert_comments_round_trip(src);
}
#[test]
fn a_trailing_comment_round_trips_at_the_end() {
let src = "GET http://h/a\nHTTP 200\n[Asserts]\njsonpath \"$.x\" == 1\n# checked above\n";
let e = parse_hurl(src);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::Trailing,
text: "# checked above".into(),
}]
);
assert_comments_round_trip(src);
}
#[test]
fn a_comment_between_two_entries_is_kept_and_does_not_cross_over() {
let src = "GET http://h/a\nHTTP 200\n# note about the first request\n\n# Second\nPOST http://h/b\nHTTP 201\n";
let e = parse_hurl(src);
assert_eq!(e.len(), 2);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::Trailing,
text: "# note about the first request".into(),
}]
);
assert_eq!(e[1].title, "Second");
assert!(e[1].comments.is_empty(), "{:?}", e[1].comments);
assert_comments_round_trip(src);
}
#[test]
fn a_hash_line_inside_a_multiline_body_is_not_captured_as_a_comment() {
let src = "POST http://h/a\n```\n# not a comment, this is body text\n```\nHTTP 200\n";
let e = parse_hurl(src);
assert!(
e[0].comments.is_empty(),
"multiline body content must not be captured as prose: {:?}",
e[0].comments
);
assert!(
e[0].body
.as_deref()
.unwrap_or_default()
.contains("# not a comment"),
"the body must still contain the # line: {:?}",
e[0].body
);
}
#[test]
fn a_comment_only_entry_keeps_its_comment_without_bleeding_into_the_next() {
let src = "GET http://h/a\n\n# a floating note\nPOST http://h/b\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(e.len(), 2);
assert!(e[0].headers.is_empty());
assert!(e[0].comments.is_empty(), "{:?}", e[0].comments);
assert_eq!(e[1].title, "a floating note");
assert!(e[1].comments.is_empty(), "{:?}", e[1].comments);
assert_comments_round_trip(src);
}
#[test]
fn comments_survive_a_full_document_round_trip_unchanged() {
let src = "# top of file\n\n# Login\nPOST http://h/login\n# creds below\nContent-Type: json\n[Cookies]\n# the session cookie\nsid: abc\nHTTP 200\n# then assert\n[Asserts]\njsonpath \"$.ok\" == true\n# done\n";
let once = collection_to_hurl(&parse_hurl(src));
let twice = collection_to_hurl(&parse_hurl(&once));
assert_eq!(once, twice, "round trip must be idempotent:\n{once}");
assert_comments_round_trip(src);
}
fn assert_sections_round_trip(src: &str) -> Vec<HurlEntry> {
let first = parse_hurl(src);
let text = collection_to_hurl(&first);
assert!(
parse_hurl_error(&text).is_none(),
"serialized text must parse via hurl_core:\n{text}\nerror: {:?}",
parse_hurl_error(&text)
);
let second = parse_hurl(&text);
assert_eq!(first.len(), second.len());
for (a, b) in first.iter().zip(&second) {
assert_eq!(a.options, b.options, "options drift:\n{text}");
assert_eq!(
a.response_version, b.response_version,
"version drift:\n{text}"
);
assert_eq!(
a.response_headers, b.response_headers,
"resp headers drift:\n{text}"
);
assert_eq!(a.response_body, b.response_body, "resp body drift:\n{text}");
}
second
}
#[test]
fn request_options_section_round_trips() {
let src = "POST http://h/a\n[Options]\nretry: 3\ninsecure: true\nvariable: host=example.net\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(
e[0].options,
vec![
("retry".into(), "3".into(), true),
("insecure".into(), "true".into(), true),
("variable".into(), "host=example.net".into(), true),
]
);
assert_sections_round_trip(src);
}
#[test]
fn a_disabled_option_row_round_trips_as_a_comment() {
let src = "GET http://h/a\n[Options]\nretry: 3\n# insecure: true\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(
e[0].options,
vec![
("retry".into(), "3".into(), true),
("insecure".into(), "true".into(), false),
]
);
assert!(e[0].comments.is_empty(), "{:?}", e[0].comments);
assert_sections_round_trip(src);
}
#[test]
fn options_and_a_body_coexist_and_round_trip() {
let src = "POST http://h/a\n[Options]\nretry: 2\n```\n{\"x\":1}\n```\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(e[0].options, vec![("retry".into(), "2".into(), true)]);
assert_eq!(e[0].body.as_deref(), Some("```\n{\"x\":1}\n```"));
let text = e[0].to_hurl();
assert!(
text.find("[Options]").unwrap() < text.find("```").unwrap(),
"[Options] must be emitted before the body:\n{text}"
);
assert_sections_round_trip(src);
}
#[test]
fn response_headers_round_trip() {
let src = "GET http://h/a\nHTTP 200\nContent-Type: application/json\nX-Trace: abc\n[Asserts]\njsonpath \"$.ok\" == true\n";
let e = parse_hurl(src);
assert_eq!(
e[0].response_headers,
vec![
("Content-Type".into(), "application/json".into(), true),
("X-Trace".into(), "abc".into(), true),
]
);
assert_eq!(e[0].asserts, vec!["jsonpath \"$.ok\" == true".to_string()]);
assert_sections_round_trip(src);
}
#[test]
fn a_disabled_response_header_round_trips_as_a_comment() {
let src = "GET http://h/a\nHTTP 200\nContent-Type: application/json\n# X-Trace: abc\n";
let e = parse_hurl(src);
assert_eq!(
e[0].response_headers,
vec![
("Content-Type".into(), "application/json".into(), true),
("X-Trace".into(), "abc".into(), false),
]
);
assert!(e[0].comments.is_empty(), "{:?}", e[0].comments);
assert_sections_round_trip(src);
}
#[test]
fn response_body_round_trips_after_sections() {
let src =
"GET http://h/a\nHTTP 200\n[Asserts]\njsonpath \"$.a\" == 1\n```\n{\"a\":1}\n```\n";
let e = parse_hurl(src);
assert_eq!(e[0].response_body.as_deref(), Some("```\n{\"a\":1}\n```"));
assert_eq!(e[0].asserts, vec!["jsonpath \"$.a\" == 1".to_string()]);
let text = e[0].to_hurl();
assert!(
text.find("[Asserts]").unwrap() < text.rfind("```").unwrap(),
"the response body must follow the response sections:\n{text}"
);
assert_sections_round_trip(src);
}
#[test]
fn a_hash_line_inside_a_response_body_is_not_captured_as_a_comment() {
let src = "GET http://h/a\nHTTP 200\n```\n# not a comment\n```\n";
let e = parse_hurl(src);
assert!(e[0].comments.is_empty(), "{:?}", e[0].comments);
assert!(
e[0].response_body
.as_deref()
.unwrap_or_default()
.contains("# not a comment")
);
assert_sections_round_trip(src);
}
#[test]
fn response_http_version_round_trips() {
let src = "GET http://h/a\nHTTP/1.1 200\n";
let e = parse_hurl(src);
assert_eq!(e[0].response_version.as_deref(), Some("HTTP/1.1"));
assert_eq!(e[0].expected_status, Some(200));
let text = e[0].to_hurl();
assert!(
text.contains("HTTP/1.1 200"),
"version must round-trip:\n{text}"
);
assert_sections_round_trip(src);
}
#[test]
fn version_agnostic_http_keyword_stays_versionless() {
let src = "GET http://h/a\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(e[0].response_version, None);
assert!(e[0].to_hurl().contains("HTTP 200"));
assert_sections_round_trip(src);
}
#[test]
fn a_version_with_no_explicit_status_uses_the_wildcard() {
let src = "GET http://h/a\nHTTP/2 *\n[Asserts]\njsonpath \"$.x\" == 1\n";
let e = parse_hurl(src);
assert_eq!(e[0].response_version.as_deref(), Some("HTTP/2"));
assert_eq!(e[0].expected_status, None);
assert!(e[0].to_hurl().contains("HTTP/2 *"));
assert_sections_round_trip(src);
}
#[test]
fn a_comment_before_options_anchors_to_options() {
let src = "GET http://h/a\n# tuning\n[Options]\nretry: 3\nHTTP 200\n";
let e = parse_hurl(src);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::Options,
text: "# tuning".into(),
}]
);
assert!(e[0].to_hurl().contains("# tuning\n[Options]"));
assert_comments_round_trip(src);
}
#[test]
fn a_comment_among_response_headers_stays_in_the_response_area() {
let src = "GET http://h/a\nHTTP 200\n# trace headers\nX-Trace: abc\n[Asserts]\njsonpath \"$.ok\" == true\n";
let e = parse_hurl(src);
assert_eq!(
e[0].comments,
vec![EntryComment {
anchor: CommentAnchor::ResponseHeaders,
text: "# trace headers".into(),
}]
);
let text = e[0].to_hurl();
assert!(
text.find("HTTP 200").unwrap() < text.find("# trace headers").unwrap()
&& text.find("# trace headers").unwrap() < text.find("[Asserts]").unwrap(),
"the comment must stay between the HTTP line and [Asserts]:\n{text}"
);
assert_comments_round_trip(src);
}
#[test]
fn options_response_headers_body_and_comments_all_survive_one_document() {
let src = "# Big one\nPOST http://h/a\nContent-Type: json\n[Options]\nretry: 2\n```\n{\"x\":1}\n```\nHTTP/1.1 201\nX-Trace: t\n[Asserts]\njsonpath \"$.id\" exists\n```\n{\"id\":9}\n```\n# all checked\n";
let e = parse_hurl(src);
assert_eq!(e[0].options, vec![("retry".into(), "2".into(), true)]);
assert_eq!(e[0].response_version.as_deref(), Some("HTTP/1.1"));
assert_eq!(
e[0].response_headers,
vec![("X-Trace".into(), "t".into(), true)]
);
assert_eq!(e[0].response_body.as_deref(), Some("```\n{\"id\":9}\n```"));
assert_eq!(e[0].body.as_deref(), Some("```\n{\"x\":1}\n```"));
assert_sections_round_trip(src);
assert_comments_round_trip(src);
}
#[test]
fn options_and_response_fields_do_not_bleed_into_the_next_request() {
let src = concat!(
"GET http://h/a\n",
"[Options]\nretry: 1\n",
"HTTP/1.1 200\n",
"X-A: a\n",
"[Asserts]\njsonpath \"$.a\" == 1\n",
"```\n{\"a\":1}\n```\n",
"\n",
"GET http://h/b\n",
"[Options]\nretry: 2\n",
"HTTP/2 201\n",
"X-B: b\n",
"[Asserts]\njsonpath \"$.b\" == 2\n",
"```\n{\"b\":2}\n```\n",
);
let e = parse_hurl(src);
assert_eq!(e.len(), 2, "two distinct entries");
assert_eq!(e[0].options, vec![("retry".into(), "1".into(), true)]);
assert_eq!(e[0].response_version.as_deref(), Some("HTTP/1.1"));
assert_eq!(
e[0].response_headers,
vec![("X-A".into(), "a".into(), true)]
);
assert_eq!(e[0].response_body.as_deref(), Some("```\n{\"a\":1}\n```"));
assert_eq!(e[1].options, vec![("retry".into(), "2".into(), true)]);
assert_eq!(e[1].response_version.as_deref(), Some("HTTP/2"));
assert_eq!(
e[1].response_headers,
vec![("X-B".into(), "b".into(), true)]
);
assert_eq!(e[1].response_body.as_deref(), Some("```\n{\"b\":2}\n```"));
assert_sections_round_trip(src);
}
#[test]
fn a_header_description_survives_a_round_trip_through_the_hurl_text() {
let mut e = HurlEntry::from_fields("Get", "GET", "http://h/x", vec![], "");
e.headers = vec![KvRow {
key: "X-Trace".into(),
value: "on".into(),
enabled: true,
desc: "only for staging".into(),
}];
let text = collection_to_hurl(&[e]);
assert!(
text.contains("# @desc only for staging"),
"the note should be written above its row: {text}"
);
let back = parse_hurl(&text);
assert_eq!(
back[0].headers[0].desc, "only for staging",
"and it should come back attached to the same row"
);
assert_eq!(back[0].headers[0].key, "X-Trace");
}
#[test]
fn a_multi_line_description_round_trips_as_several_marker_lines() {
let mut e = HurlEntry::from_fields("Get", "GET", "http://h/x", vec![], "");
e.headers = vec![KvRow {
key: "X-Trace".into(),
value: "on".into(),
enabled: true,
desc: "first line\nsecond line".into(),
}];
let text = collection_to_hurl(&[e]);
assert_eq!(
text.matches("# @desc ").count(),
2,
"one marker per line of the note: {text}"
);
let back = parse_hurl(&text);
assert_eq!(back[0].headers[0].desc, "first line\nsecond line");
}
#[test]
fn a_description_is_not_also_captured_as_a_prose_comment() {
let mut e = HurlEntry::from_fields("Get", "GET", "http://h/x", vec![], "");
e.headers = vec![KvRow {
key: "X-Trace".into(),
value: "on".into(),
enabled: true,
desc: "only for staging".into(),
}];
let once = collection_to_hurl(&[e]);
let twice = collection_to_hurl(&parse_hurl(&once));
assert_eq!(once, twice, "a save/load cycle must be a fixed point");
assert_eq!(
twice.matches("only for staging").count(),
1,
"the note must not be duplicated as prose: {twice}"
);
}
#[test]
fn a_disabled_row_keeps_both_its_note_and_its_disabled_state() {
let mut e = HurlEntry::from_fields("Get", "GET", "http://h/x", vec![], "");
e.headers = vec![KvRow {
key: "X-Trace".into(),
value: "on".into(),
enabled: false,
desc: "off until the rollout".into(),
}];
let back = parse_hurl(&collection_to_hurl(&[e]));
let row = &back[0].headers[0];
assert!(!row.enabled, "the row should still be off");
assert_eq!(row.desc, "off until the rollout");
}
#[test]
fn a_form_field_description_survives_a_round_trip() {
let mut e = HurlEntry::from_fields("Post", "POST", "http://h/x", vec![], "");
e.form_fields = vec![crate::hurl::FormField {
key: "region".into(),
value: "eu-west-1".into(),
enabled: true,
desc: "which cluster to hit".into(),
..Default::default()
}];
let back = parse_hurl(&collection_to_hurl(&[e]));
assert_eq!(back[0].form_fields[0].desc, "which cluster to hit");
}
#[test]
fn prose_that_merely_starts_like_the_marker_is_left_as_a_comment() {
let text = "POST http://h/x\n# @description of the endpoint\nX-Trace: on\nHTTP 200\n";
let back = parse_hurl(text);
assert_eq!(
back[0].headers[0].desc, "",
"the row should not have adopted the comment as its note"
);
assert!(
back[0]
.comments
.iter()
.any(|c| c.text.contains("@description")),
"and the line should survive as a comment: {:?}",
back[0].comments
);
}
}