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 std::ops::Range;
use super::entry::{
BASE64_FILE_CT_MARKER, CommentAnchor, EntryComment, FormField, FormFieldKind, HurlEntry, KvRow,
RunStatus, decode_body_line, parse_body_marker,
};
use super::json_comments;
pub fn parse_hurl(content: &str) -> Vec<HurlEntry> {
let Ok(file) = parse_hurl_file(content) else {
return recover_entries(content);
};
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 recover_entries(content: &str) -> Vec<HurlEntry> {
let lines: Vec<&str> = content.split_inclusive('\n').collect();
let starts = request_starts(&lines);
if starts.is_empty() {
return Vec::new();
}
let mut bounds: Vec<usize> = starts;
bounds[0] = 0;
bounds.push(lines.len());
const JOIN_LIMIT: usize = 8;
let mut out: Vec<HurlEntry> = Vec::new();
let mut i = 0;
while i + 1 < bounds.len() {
let alone = lines[bounds[i]..bounds[i + 1]].concat();
let mut healed = None;
match parse_hurl_file(&alone) {
Ok(_) => {
let entries = parse_hurl(&alone);
if !entries.is_empty() {
healed = Some((entries, i + 1));
}
}
Err(e) if e.pos.line >= alone.lines().count() => {
let far = bounds.len().min(i + 1 + JOIN_LIMIT);
for j in (i + 2..far).rev() {
let text = lines[bounds[i]..bounds[j]].concat();
if parse_hurl_file(&text).is_ok() {
let entries = parse_hurl(&text);
if !entries.is_empty() {
healed = Some((entries, j));
break;
}
}
}
}
Err(_) => {}
}
match healed {
Some((entries, j)) => {
out.extend(entries);
i = j;
}
None => {
out.push(HurlEntry::unreadable(&alone));
i += 1;
}
}
}
out
}
fn request_starts(lines: &[&str]) -> Vec<usize> {
let mut starts: Vec<usize> = Vec::new();
let mut in_body = false;
for (i, l) in lines.iter().enumerate() {
if l.trim_start().starts_with("```") {
in_body = !in_body;
continue;
}
if in_body || !looks_like_a_request_line(l) {
continue;
}
let floor = starts.last().map(|&p| p + 1).unwrap_or(0);
starts.push(title_block_top(lines, i, floor));
}
starts
}
fn title_block_top(lines: &[&str], method: usize, floor: usize) -> usize {
let mut top = method;
while top > floor {
let prev = lines[top - 1].trim();
if !prev.starts_with('#') {
break;
}
if is_reports_marker(prev) || parse_body_marker(prev).is_some() {
return method;
}
top -= 1;
}
top
}
fn looks_like_a_request_line(line: &str) -> bool {
let line = line.trim_end_matches(['\n', '\r']);
if line.starts_with(char::is_whitespace) {
return false;
}
let Some((method, rest)) = line.split_once(' ') else {
return false;
};
if method == "HTTP" || method.starts_with("HTTP/") {
return false;
}
!method.is_empty()
&& method
.chars()
.all(|c| c.is_ascii_uppercase() || c == '-' || c == '_')
&& !rest.trim().is_empty()
}
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(|b| body_source(b, lines));
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());
let file_body = req.body.as_ref().and_then(|b| body_source(b, lines));
let claimed = claim_body_block(lines, scan_start, scan_end, file_body.as_deref());
let claimed_range = claimed.as_ref().map(|(r, _)| r.clone());
let url_line = req.url.source_info.start.line;
let header_end = match (
first_anchor_after(&anchors, url_line),
claimed_range.as_ref().map(|r| r.start),
) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
HurlEntry {
uid: 0,
unparsed: None,
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, url_line + 1, header_end),
basic_auth,
form_fields,
is_multipart,
queries: query_params,
cookies,
options,
body_src: claimed.map(|(_, text)| text).or(file_body),
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,
claimed_range,
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)],
body_block: Option<Range<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)
|| body_block.as_ref().is_some_and(|r| r.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();
if crate::hurl::key_problem(key).is_some() {
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(),
})
}
pub(crate) 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, lines: &[&str]) -> 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(x) => source_line(x.space0.source_info.start.line, lines)?,
Bytes::File(x) => source_line(x.space0.source_info.start.line, lines)?,
};
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 body_blocks(lines: &[&str], from: usize, to: usize) -> Vec<(Range<usize>, String)> {
let hi = to.min(lines.len() + 1);
let mut out = Vec::new();
for i in from..hi {
let Some(n) = lines
.get(i.wrapping_sub(1))
.and_then(|l| parse_body_marker(l))
else {
continue;
};
let Some(end) = i.checked_add(1).and_then(|e| e.checked_add(n)) else {
continue;
};
let well_formed = end <= hi
&& (i + 1..end).all(|j| {
lines
.get(j - 1)
.is_some_and(|l| l.trim_start().starts_with('#'))
});
if well_formed {
let text = (i + 1..end)
.map(|j| decode_body_line(lines[j - 1]))
.collect::<Vec<_>>()
.join("\n");
out.push((i..end, text));
}
}
out
}
fn claim_body_block(
lines: &[&str],
from: usize,
to: usize,
body: Option<&str>,
) -> Option<(Range<usize>, String)> {
let body = body?;
body_blocks(lines, from, to)
.into_iter()
.find(|(_, text)| json_comments::bodies_equivalent(&json_comments::wire_body(text), body))
}
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('#')
.trim()
.trim_matches(|c| matches!(c, '-' | '='))
.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";
let recovered = parse_hurl(content);
assert!(recovered.iter().all(|e| e.is_unreadable()));
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_src.as_deref(), Some("{\n \"k\": \"v\"\n}"));
assert_eq!(e[1].method, "GET");
assert!(e[1].body_src.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_src.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_src.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_src, b.body_src);
}
}
#[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_src
.as_deref()
.unwrap_or_default()
.contains("# not a comment"),
"the body must still contain the # line: {:?}",
e[0].body_src
);
}
#[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_src.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_file_body_survives_a_save() {
let src = "POST http://h/a\nContent-Type: application/json\nfile, body.json;\n";
let e = parse_hurl(src);
assert_eq!(e[0].body_src.as_deref(), Some("file, body.json;"));
let text = collection_to_hurl(&e);
assert!(text.contains("file, body.json;"), "\n{text}");
assert_eq!(parse_hurl_error(&text), None, "\n{text}");
assert_eq!(collection_to_hurl(&parse_hurl(&text)), text);
}
#[test]
fn a_base64_body_survives_a_save() {
let src = "POST http://h/a\nbase64,SGVsbG8=;\n";
let e = parse_hurl(src);
assert_eq!(e[0].body_src.as_deref(), Some("base64,SGVsbG8=;"));
let text = collection_to_hurl(&e);
assert!(text.contains("base64,SGVsbG8=;"), "\n{text}");
assert_eq!(parse_hurl_error(&text), None, "\n{text}");
assert_eq!(collection_to_hurl(&parse_hurl(&text)), text);
}
#[test]
fn a_file_response_body_survives_a_save() {
let src = "POST http://h/a\n{\"a\":1}\n\nHTTP 200\nfile,expected.json;\n";
let e = parse_hurl(src);
assert_eq!(e[0].response_body.as_deref(), Some("file,expected.json;"));
let text = collection_to_hurl(&e);
assert!(text.contains("file,expected.json;"), "\n{text}");
assert_eq!(parse_hurl_error(&text), None, "\n{text}");
assert_eq!(collection_to_hurl(&parse_hurl(&text)), text);
}
#[test]
fn a_file_body_is_not_lost_when_another_request_is_edited() {
let src = "POST http://h/a\nfile, body.json;\n\nGET http://h/b\n";
let mut e = parse_hurl(src);
e[1].url = "http://h/c".into();
let text = collection_to_hurl(&e);
assert!(
text.contains("file, body.json;"),
"the untouched request keeps its body:\n{text}"
);
assert_eq!(parse_hurl(&text).len(), 2, "\n{text}");
}
#[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_src.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
);
}
#[test]
fn a_commented_body_survives_a_save_and_a_reload() {
let mut e = HurlEntry {
title: "t".into(),
method: "POST".into(),
url: "http://h/a".into(),
..Default::default()
};
let authored = "{\n // who\n \"id\": {{user_id}} // the caller\n}";
e.body_src = Some(authored.into());
let text = collection_to_hurl(&[e]);
assert!(text.contains("# [Body] 4"), "\n{text}");
assert!(
text.contains("{\n \"id\": {{user_id}}\n}"),
"the wire body is strict JSON:\n{text}"
);
let back = parse_hurl(&text);
assert_eq!(back.len(), 1);
assert_eq!(back[0].body_src.as_deref(), Some(authored));
assert!(
!back[0].comments.iter().any(|c| c.text.contains("[Body]")),
"{:?}",
back[0].comments
);
assert_eq!(collection_to_hurl(&back), text, "a second save is stable");
}
#[test]
fn a_block_that_no_longer_describes_the_body_is_kept_as_comments() {
let src = "POST http://h/a\n\
# [Body] 3\n\
# {\n\
# \"id\": 1 // the caller\n\
# }\n\
{\"id\": 999}\n";
let e = parse_hurl(src);
assert_eq!(
e[0].body_src.as_deref(),
Some("{\"id\": 999}"),
"the body in the file wins"
);
let text = collection_to_hurl(&e);
assert!(
text.contains("# \"id\": 1 // the caller"),
"the orphaned notes are still there:\n{text}"
);
assert!(text.contains("{\"id\": 999}"), "\n{text}");
}
#[test]
fn reformatting_the_body_elsewhere_does_not_orphan_the_comments() {
let src = "POST http://h/a\n\
# [Body] 3\n\
# {\n\
# \"id\": 1, // the caller\n\
# \"b\": 2\n\
# }\n\
{\"b\":2,\"id\":1}\n";
assert!(parse_hurl(src)[0].body_src.as_deref() == Some("{\"b\":2,\"id\":1}"));
let ok = src.replace("# [Body] 3", "# [Body] 4");
let e = parse_hurl(&ok);
assert_eq!(
e[0].body_src.as_deref(),
Some("{\n \"id\": 1, // the caller\n \"b\": 2\n}"),
"a reordered, reformatted body still matches"
);
}
#[test]
fn body_content_cannot_terminate_its_own_block() {
let mut e = HurlEntry {
title: "t".into(),
method: "POST".into(),
url: "http://h/a".into(),
..Default::default()
};
let authored = "{\n // note\n \"a\": \"[Body] 1\"\n}";
e.body_src = Some(authored.into());
let text = collection_to_hurl(&[e]);
assert_eq!(parse_hurl(&text)[0].body_src.as_deref(), Some(authored));
}
#[test]
fn an_uncommented_body_gains_no_block() {
let src = "POST http://h/a\n{\"id\": 1}\n";
let e = parse_hurl(src);
let text = collection_to_hurl(&e);
assert!(!text.contains("[Body]"), "\n{text}");
assert_eq!(text, src);
}
#[test]
fn commenting_out_a_last_field_does_not_empty_the_collection() {
let mut a = HurlEntry {
method: "POST".into(),
url: "http://h/a".into(),
..Default::default()
};
let authored = "{\n \"a\": 1,\n // \"b\": 2\n}";
a.body_src = Some(authored.into());
let b = HurlEntry {
method: "GET".into(),
url: "http://h/b".into(),
..Default::default()
};
let text = collection_to_hurl(&[a, b]);
assert!(parse_hurl_error(&text).is_none(), "invalid hurl:\n{text}");
let back = parse_hurl(&text);
assert_eq!(back.len(), 2, "the other request went with it:\n{text}");
assert_eq!(back[0].body_src.as_deref(), Some(authored));
assert_eq!(back[0].body_wire().as_deref(), Some("{\n \"a\": 1\n}"));
}
#[test]
fn a_body_marker_claiming_the_whole_address_space_is_ignored() {
let text = format!(
"POST http://h/a\n# [Body] {}\n# {{\"a\":1}}\n{{\"a\":1}}\n",
usize::MAX
);
let back = parse_hurl(&text);
assert_eq!(back.len(), 1);
assert_eq!(back[0].body_wire().as_deref(), Some("{\"a\":1}"));
assert!(collection_to_hurl(&back).contains("[Body]"));
}
#[test]
fn an_edit_to_a_non_json_body_beats_the_block_describing_it() {
let text = "POST http://h/a\n# [Body] 1\n# <a>x y</a>\n<a>x y</a>\n";
let back = parse_hurl(text);
assert_eq!(back[0].body_wire().as_deref(), Some("<a>x y</a>"));
assert!(collection_to_hurl(&back).contains("# <a>x y</a>"));
}
#[test]
fn a_body_authored_with_windows_line_endings_settles() {
let mut e = HurlEntry {
method: "POST".into(),
url: "http://h/a".into(),
..Default::default()
};
e.body_src = Some("{\r\n // who\r\n \"a\": 1\r\n}".into());
let once = collection_to_hurl(&[e]);
let twice = collection_to_hurl(&parse_hurl(&once));
assert_eq!(once, twice, "not a fixed point");
assert!(!once.contains('\r'), "\n{once:?}");
}
#[test]
fn leftover_notes_are_found_and_can_be_thrown_away() {
let text = "POST http://h/a\n\
# [Body] 4\n\
# {\n\
# //extra comment\n\
# \"a\": 2 // just a test\n\
#\n\
# }\n\
{\n \"a\": 2\n}\n";
let mut back = parse_hurl(text);
let e = &mut back[0];
let (at, notes) = e.stale_body_notes().expect("the leftover block");
assert_eq!(at.len(), 5, "the marker and the four lines it claims");
assert_eq!(
notes,
"{\n //extra comment\n \"a\": 2 // just a test\n"
);
assert_eq!(e.comments.len(), 6);
assert!(e.discard_body_notes());
assert_eq!(e.comments.len(), 1, "only the unclaimed `# }} ` is left");
assert_eq!(e.body_wire().as_deref(), Some("{\n \"a\": 2\n}"));
assert!(e.stale_body_notes().is_none());
}
#[test]
fn leftover_notes_can_be_taken_back_as_the_body() {
let text = "POST http://h/a\n\
# [Body] 3\n\
# {\n\
# \"a\": 1 // mine\n\
# }\n\
{\n \"b\": 2\n}\n";
let mut back = parse_hurl(text);
let e = &mut back[0];
assert!(e.stale_body_notes().is_some(), "the bodies disagree");
assert!(e.can_adopt_body_notes());
assert!(e.adopt_body_notes());
assert_eq!(e.body_src.as_deref(), Some("{\n \"a\": 1 // mine\n}"));
assert_eq!(e.body_wire().as_deref(), Some("{\n \"a\": 1\n}"));
assert!(
e.comments.is_empty(),
"the block is the body now, not prose"
);
let out = collection_to_hurl(&back);
assert_eq!(
parse_hurl(&out)[0].body_src.as_deref(),
Some("{\n \"a\": 1 // mine\n}")
);
}
#[test]
fn notes_that_would_not_survive_being_a_body_cannot_be_adopted() {
let text = "POST http://h/a\n\
# [Body] 4\n\
# {\n\
# //extra comment\n\
# \"a\": 2 // just a test\n\
#\n\
# }\n\
{\n \"a\": 2\n}\n";
let mut back = parse_hurl(text);
let e = &mut back[0];
assert!(e.stale_body_notes().is_some());
assert!(!e.can_adopt_body_notes(), "it would not be valid Hurl");
assert!(!e.adopt_body_notes());
assert_eq!(
e.body_wire().as_deref(),
Some("{\n \"a\": 2\n}"),
"body untouched"
);
}
#[test]
fn notes_that_are_not_a_body_at_all_cannot_be_adopted() {
let text = "POST http://h/a\n\
# [Body] 1\n\
# hello world\n\
{\n \"real\": 1\n}\n\
HTTP 200\n\n\
GET http://h/keepme\nHTTP 200\n";
let mut back = parse_hurl(text);
assert_eq!(back.len(), 2);
let e = &mut back[0];
assert!(
e.stale_body_notes().is_some(),
"the notes are still offered"
);
assert!(!e.can_adopt_body_notes(), "prose is not a body");
assert!(!e.adopt_body_notes());
assert_eq!(e.body_wire().as_deref(), Some("{\n \"real\": 1\n}"));
let out = collection_to_hurl(&back);
assert_eq!(parse_hurl(&out).len(), 2, "no request was lost");
}
#[test]
fn a_body_marker_claiming_no_lines_is_not_leftover_notes() {
let text = "POST http://h/a\n# [Body] 0\n{\n \"real\": 1\n}\n";
let mut back = parse_hurl(text);
let e = &mut back[0];
assert!(e.stale_body_notes().is_none());
assert!(!e.can_adopt_body_notes());
assert!(!e.adopt_body_notes());
assert_eq!(e.body_wire().as_deref(), Some("{\n \"real\": 1\n}"));
}
#[test]
fn a_body_marker_claiming_the_whole_address_space_is_not_leftover_notes() {
let text = "POST http://h/a\n# [Body] 18446744073709551615\n{\n \"a\": 1\n}\n";
let back = parse_hurl(text);
assert!(back[0].stale_body_notes().is_none());
}
#[test]
fn an_overrunning_block_does_not_hide_a_well_formed_one_below_it() {
let text = "POST http://h/a\n\
# [Body] 9\n\
# {\n\
# \"old\": 1\n\
# }\n\
# [Body] 3\n\
# {\n\
# \"b\": 9 // note\n\
# }\n\
{\n \"real\": 1\n}\n";
let back = parse_hurl(text);
let (_, notes) = back[0]
.stale_body_notes()
.expect("the well-formed block is still found");
assert_eq!(notes, "{\n \"b\": 9 // note\n}");
}
#[test]
fn a_stale_block_cannot_hide_the_good_one_beneath_it() {
let text = "POST http://h/a\n\
# [Body] 7\n\
# {\n\
# \"old\": 1\n\
# }\n\
# [Body] 3\n\
# {\n\
# \"b\": 9 // new note\n\
# }\n\
{\n \"b\": 9\n}\n";
let back = parse_hurl(&text);
assert_eq!(
back[0].body_src.as_deref(),
Some("{\n \"b\": 9 // new note\n}"),
"the good block was hidden by the stale one"
);
assert_eq!(back[0].body_wire().as_deref(), Some("{\n \"b\": 9\n}"));
assert!(collection_to_hurl(&back).contains("# \"old\": 1"));
}
}
#[cfg(test)]
mod recovery_tests {
use super::*;
use crate::hurl::collection_to_hurl;
#[test]
fn a_broken_request_no_longer_takes_the_file_with_it() {
let text = "# one\nGET http://h/1\nHTTP 200\n\n\
# two\nPOST http://h/2\n[Captures]\nx: jsonpath \"$.a\"\n\n\
# three\nGET http://h/3\nHTTP 200\n";
assert!(
parse_hurl_file(text).is_err(),
"this really is an unparseable file"
);
let es = parse_hurl(text);
assert_eq!(es.len(), 3);
assert_eq!(es[0].url, "http://h/1");
assert_eq!(es[2].url, "http://h/3");
assert!(!es[0].is_unreadable() && !es[2].is_unreadable());
assert!(es[1].is_unreadable(), "only the damaged one is text");
assert_eq!(es[1].title, "two", "named, so it can be found again");
}
#[test]
fn unreadable_text_is_kept_and_written_back_unchanged() {
let text = "# one\nGET http://h/1\nHTTP 200\n\n\
# two\nPOST http://h/2\n[Captures]\nx: jsonpath \"$.a\"\n";
let es = parse_hurl(text);
let raw = es[1].unparsed.as_deref().expect("kept verbatim");
assert!(raw.contains("[Captures]") && raw.contains("x: jsonpath \"$.a\""));
let out = collection_to_hurl(&es);
assert!(out.contains("# two\nPOST http://h/2\n[Captures]\nx: jsonpath \"$.a\""));
let again = parse_hurl(&out);
assert_eq!(again.len(), 2);
assert!(again[1].is_unreadable());
assert_eq!(collection_to_hurl(&again), out, "a second save is a no-op");
}
#[test]
fn a_body_with_comments_left_in_it_costs_only_its_own_request() {
let text = "GET http://h/1\nHTTP 200\n\n\
POST http://h/2\n{\n \"a\": 1 // note\n}\n\n\
GET http://h/3\nHTTP 200\n";
let es = parse_hurl(text);
assert_eq!(es.len(), 3);
assert!(es[1].is_unreadable());
assert!(
collection_to_hurl(&es).contains("\"a\": 1 // note"),
"the user's own text is still there to repair"
);
}
#[test]
fn a_method_like_line_inside_a_body_does_not_split_a_request() {
let text = "POST http://h/1\n```\nGET /inside is data\n```\nHTTP 200\n\n\
GET http://h/2\n[Captures]\nx: jsonpath \"$.a\"\n";
let es = parse_hurl(text);
assert_eq!(es.len(), 2, "the good request was not cut in half");
assert_eq!(es[0].url, "http://h/1");
assert!(!es[0].is_unreadable());
assert!(es[0].body_wire().unwrap().contains("GET /inside is data"));
}
#[test]
fn a_response_line_is_not_a_place_to_cut() {
let text = "GET http://h/1\nHTTP 200\n[Asserts]\njsonpath \"$.a\" == 1\n\n\
GET http://h/2\n[Captures]\nx: jsonpath \"$.a\"\n";
let es = parse_hurl(text);
assert_eq!(es.len(), 2);
assert_eq!(es[0].expected_status, Some(200));
assert_eq!(es[0].asserts, vec!["jsonpath \"$.a\" == 1".to_string()]);
}
#[test]
fn the_first_and_last_requests_are_recovered_too() {
let broken_first = "POST http://h/1\n[Captures]\nx: jsonpath \"$.a\"\n\n\
GET http://h/2\nHTTP 200\n";
let es = parse_hurl(broken_first);
assert_eq!(es.len(), 2);
assert!(es[0].is_unreadable() && !es[1].is_unreadable());
let broken_last = "GET http://h/1\nHTTP 200\n\n\
POST http://h/2\n[Captures]\nx: jsonpath \"$.a\"\n";
let es = parse_hurl(broken_last);
assert_eq!(es.len(), 2);
assert!(!es[0].is_unreadable() && es[1].is_unreadable());
}
#[test]
fn text_that_is_not_a_collection_at_all_still_yields_nothing() {
assert!(parse_hurl("hello\nworld\n").is_empty());
assert!(parse_hurl("").is_empty());
assert!(parse_hurl("\n\n \n").is_empty());
assert!(parse_hurl("# just a comment\n").is_empty());
}
#[test]
fn every_line_of_a_damaged_file_survives_somewhere() {
let text = "# banner\n\n# one\nGET http://h/1\nHTTP 200\n\n\
# two\nPOST http://h/2\n[Captures]\nx: jsonpath \"$.a\"\n\n\
# three\nPUT http://h/3\n{\n \"b\": 2\n}\nHTTP 201\n";
let out = collection_to_hurl(&parse_hurl(text));
for line in text.lines().filter(|l| !l.trim().is_empty()) {
assert!(out.contains(line), "lost {line:?} from\n{out}");
}
}
#[test]
fn a_reports_block_is_not_pulled_into_the_next_request() {
let text = "GET http://h/1\nHTTP 200\n# [Reports]\n# code: status\n\
GET http://h/2\nHTTP 200\n\n\
POST http://h/3\n[Captures]\nx: jsonpath \"$.a\"\n";
let es = parse_hurl(text);
let with_reports: Vec<_> = es.iter().filter(|e| !e.reports.is_empty()).collect();
assert_eq!(with_reports.len(), 1, "exactly one request has reports");
assert_eq!(with_reports[0].url, "http://h/1");
}
}
#[cfg(test)]
mod recovery_hardening_tests {
use super::*;
use crate::hurl::collection_to_hurl;
#[test]
fn a_body_full_of_request_like_lines_is_never_cut_into_requests() {
let mut body = String::new();
for k in 0..40 {
body.push_str(&format!("GET /orders/{k}\n"));
}
let text = format!(
"GET http://h/broken\n[Captures]\nx: jsonpath \"$.a\"\n\n\
POST http://h/bulk\n```\n{body}```\nHTTP 200\n"
);
assert!(parse_hurl_file(&text).is_err(), "the file really is broken");
let es = parse_hurl(&text);
assert_eq!(es.len(), 2, "one broken request and one good one: {es:#?}");
assert!(es[0].is_unreadable());
assert!(!es[1].is_unreadable());
assert_eq!(es[1].url, "http://h/bulk");
let sent = es[1].body_wire().expect("the body survived");
for k in 0..40 {
assert!(sent.contains(&format!("GET /orders/{k}")), "lost line {k}");
}
assert!(
es.iter()
.all(|e| e.is_unreadable() || e.url.starts_with("http"))
);
let once = collection_to_hurl(&es);
assert_eq!(collection_to_hurl(&parse_hurl(&once)), once, "stable");
}
#[test]
fn recovering_a_large_damaged_file_is_quick() {
let filler: String = (0..120)
.map(|i| format!(" \"key_{i}\": \"value {i}\",\n"))
.collect();
let mut text = String::new();
for k in 0..1500 {
text.push_str(&format!(
"POST http://h/{k}\n[Captures]\nx: jsonpath \"$.a\"\n{filler}\n"
));
}
let started = std::time::Instant::now();
let es = parse_hurl(&text);
let took = started.elapsed();
assert_eq!(es.len(), 1500);
assert!(
took < std::time::Duration::from_secs(8),
"recovering a 4MB damaged file took {took:?}"
);
}
#[test]
fn a_comment_above_a_method_line_names_the_same_request_either_way() {
let healthy = "GET http://h/1\nHTTP 200\n# note\nPOST http://h/2\nHTTP 200\n";
let healthy = parse_hurl(healthy);
assert_eq!(healthy[1].title, "note", "the ordinary parser's rule");
let broken = "GET http://h/1\n[Captures]\nx: jsonpath \"$.a\"\n\
# note\nPOST http://h/2\nHTTP 200\n";
let broken = parse_hurl(broken);
assert!(broken[0].is_unreadable());
assert_eq!(broken[1].title, "note", "recovery follows the same rule");
}
#[test]
fn a_body_cut_off_by_a_damaged_fence_is_still_recovered() {
let text = "POST http://h/1\n```\nGET /a\nGET /b\n```\nHTTP 200\n\n\
GET http://h/2\nHTTP 200\n";
let es = parse_hurl(text);
assert_eq!(es.len(), 2);
assert!(es.iter().all(|e| !e.is_unreadable()));
assert_eq!(es[1].url, "http://h/2");
}
#[test]
fn a_hyphen_or_equals_inside_a_title_survives_a_round_trip() {
let entry =
HurlEntry::from_fields("Get user-profile v2=beta", "GET", "http://h/x", vec![], "");
let text = collection_to_hurl(&[entry]);
let back = parse_hurl(&text);
assert_eq!(back.len(), 1);
assert_eq!(back[0].title, "Get user-profile v2=beta");
}
#[test]
fn a_banner_around_a_title_is_still_stripped() {
let entries = parse_hurl("# ==== Login ====\nGET http://h/x\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].title, "Login");
}
}