use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::environment::{looks_like_env, parse_vars};
use crate::postman::{looks_like_postman, parse_collection};
use crate::report::flow::Header;
use crate::report::params::{ParamValues, undeclared};
use crate::report::producers::resolve_path;
use crate::report::report::{expand_output_tokens, name_has_output_token};
use crate::report::run::{DryRunner, LiveRunner, RowEvent, RunContext, finalize, run_flow_raw};
use crate::report::validate::{Context, Severity, validate};
use crate::report::writer::{OUTPUT_EXTENSIONS, writer_for_extension};
use crate::report::{CsvWriter, Report, ReportResult, ReportWriter};
use crate::shared_utils::sanitize_file_stem;
fn random_seed() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
}
pub fn run(
collection_path: Option<String>,
env_paths: Vec<String>,
report_path: String,
outputs: Vec<String>,
dry_run: bool,
targets: Vec<String>,
shuffle: Option<Option<u64>>,
params: ParamValues,
) -> i32 {
let to_stdout = outputs.iter().any(|o| o == "-");
let report = match Report::load_local(&report_path) {
Ok(r) => r,
Err(e) => {
eprintln!("error: cannot read report file: {e}");
return 1;
}
};
let mut flow = match report.flow() {
Ok(f) => f,
Err(e) => {
eprintln!("error: report '{report_path}' has a syntax error: {e}");
return 1;
}
};
let report_dir = report.path.as_deref().and_then(Path::parent);
let undeclared_params = undeclared(&flow.params(), ¶ms);
if !undeclared_params.is_empty() {
let declared = flow.params();
let known = if declared.is_empty() {
"it declares none".to_string()
} else {
format!(
"it declares: {}",
declared
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
eprintln!(
"error: report '{report_path}' has no parameter named {} ({known})",
undeclared_params
.iter()
.map(|n| format!("'{n}'"))
.collect::<Vec<_>>()
.join(", ")
);
return 1;
}
if let Err(e) = check_outputs(&outputs, &flow.header) {
eprintln!("error: {e}");
return 1;
}
let embedded = flow.embedded_entries();
let collection_path = match collection_path {
Some(c) => Some(c),
None => match report.collection_ref() {
Some(c) => Some(resolve_path(report_dir, &c).to_string_lossy().into_owned()),
None if flow.requests.is_some() => None,
None => {
eprintln!(
"error: no collection to run against — pass -c/--collection, add a '# collection:' header to '{report_path}', or embed the requests in a REQUESTS section"
);
return 1;
}
},
};
let col_content = match &collection_path {
Some(path) => match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("error: cannot read collection file '{path}': {e}");
return 1;
}
},
None => String::new(),
};
let mut entries = match &collection_path {
Some(_) => parse_collection(&col_content),
None => Vec::new(),
};
let embedded_count = embedded.len();
entries.extend(embedded);
let collection_path = collection_path.unwrap_or_else(|| {
let s = if embedded_count == 1 { "" } else { "s" };
format!("(embedded: {embedded_count} request{s})")
});
if entries.is_empty() {
let embedded_why = flow
.requests
.as_deref()
.and_then(|t| crate::hurl::parse_hurl_error_from(t, flow.requests_line.max(1)));
match embedded_why.or_else(|| {
(!looks_like_postman(&col_content))
.then(|| crate::hurl::parse_hurl_error(&col_content))
.flatten()
}) {
Some(why) => eprintln!("error: no requests found in '{collection_path}' — {why}"),
None => eprintln!("error: no requests found in '{collection_path}'"),
}
return 1;
}
let env_paths: Vec<String> = if env_paths.is_empty() {
match report.environment_ref() {
Some(e) => vec![resolve_path(report_dir, &e).to_string_lossy().into_owned()],
None => Vec::new(),
}
} else {
env_paths
};
let mut base_vars: HashMap<String, String> = HashMap::new();
let mut named_envs: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut env_names_loaded: Vec<String> = Vec::new();
for env_path in &env_paths {
let env_content = match fs::read_to_string(env_path) {
Ok(c) => c,
Err(e) => {
eprintln!("error: cannot read environment file '{env_path}': {e}");
return 1;
}
};
if !looks_like_env(&env_content) {
eprintln!(
"error: '{env_path}' is not a valid environment file (expected KEY=value lines)"
);
return 1;
}
let name = crate::shared_utils::stem(env_path, "env");
if named_envs.contains_key(&name) {
eprintln!(
"error: duplicate environment name '{name}' (from '{env_path}') — each -e file must have a distinct stem so an ENVS clause can name it unambiguously"
);
return 1;
}
let env = parse_vars(name.clone(), &env_content);
let flat: HashMap<String, String> = env
.vars
.iter()
.map(|v| (v.key.clone(), v.value.clone()))
.collect();
if env_names_loaded.is_empty() {
base_vars = flat.clone();
}
named_envs.insert(name.clone(), flat);
env_names_loaded.push(name);
}
let titles: Vec<String> = entries.iter().map(|e| e.title.clone()).collect();
let fields: Vec<(String, Vec<String>)> = entries
.iter()
.map(|e| {
(
e.title.clone(),
e.reports.iter().map(|(n, _)| n.clone()).collect(),
)
})
.collect();
let env_names: Vec<String> = named_envs.keys().cloned().collect();
let cli_strings = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let (helpers, helper_errors) = crate::report::context::load_helpers(
&[],
&flow,
Some(std::path::Path::new(&report_path)),
&cli_strings,
);
let root: Option<PathBuf> = match flow.header.root() {
Some(r) if !r.trim().is_empty() => Some(resolve_path(report_dir, r)),
_ => report_dir.map(Path::to_path_buf),
};
let base_var_names_owned: Vec<String> = env_names_loaded
.first()
.and_then(|first_name| named_envs.get(first_name))
.map(|m| {
let mut keys: Vec<String> = m.keys().cloned().collect();
keys.sort();
keys
})
.unwrap_or_default();
let mut all_env_var_names_owned: Vec<String> = named_envs
.values()
.flat_map(|m| m.keys().cloned())
.collect();
all_env_var_names_owned.sort();
all_env_var_names_owned.dedup();
let ctx = Context {
request_titles: Some(&titles),
env_names: Some(&env_names),
request_fields: Some(&fields),
root: root.as_deref(),
base_var_names: Some(&base_var_names_owned),
all_env_var_names: Some(&all_env_var_names_owned),
request_entries: Some(&entries),
helpers: &helpers,
helper_errors: &helper_errors,
strings: &cli_strings,
};
let diags = validate(&flow, &ctx);
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
for d in &diags {
let tag = match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
eprintln!("{tag}: {}", d.message);
}
if has_error && !dry_run {
eprintln!("error: the report has validation errors — fix them or use --dry-run to preview");
return 1;
}
if !targets.is_empty()
&& let Err(errs) = crate::report::graph::prune_to_targets(
&mut flow,
&targets,
&entries,
&helpers,
&cli_strings,
)
{
for e in errs {
eprintln!("error: {e}");
}
return 1;
}
let file_root = Path::new(&collection_path).parent().map(Path::to_path_buf);
let live = LiveRunner {
file_root: file_root.clone(),
};
let dry = DryRunner;
let mut decor = Decor::new(to_stdout);
decor.line(&format!("PaperBoy — report \"{}\"", report.name));
decor.line(&format!(" Collection : {collection_path}"));
if let [one] = env_names_loaded.as_slice() {
decor.line(&format!(" Environment: {one}"));
} else if !env_names_loaded.is_empty() {
decor.line(&format!(
" Environments: {} (base: {})",
env_names_loaded.join(", "),
env_names_loaded[0]
));
}
if dry_run {
decor.line(" Mode : DRY RUN (no requests sent)");
}
if !targets.is_empty() {
decor.line(&format!(" Targets : {}", targets.join(", ")));
}
if !params.is_empty() {
let mut supplied: Vec<String> = params
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect();
supplied.sort();
decor.line(&format!(" Parameters : {}", supplied.join(", ")));
}
let shuffle = shuffle.map(|s| s.unwrap_or_else(random_seed));
if let Some(seed) = shuffle {
decor.line(&format!(
" Shuffle : seed {seed} (replay with --shuffle={seed})"
));
}
if dry_run {
let lines = crate::report::graph::explain(&flow, &entries, &helpers, &cli_strings);
if !lines.is_empty() {
decor.line("");
for l in lines {
decor.line(&l);
}
decor.line("");
}
}
let result = if dry_run {
let ctx = RunContext {
entries: &entries,
helpers: &helpers,
base_vars,
named_envs,
root,
runner: &dry,
strings: &cli_strings,
params: params.clone(),
sink: None,
shuffle,
};
let mut r = run_flow_raw(&flow, &ctx);
finalize(&mut r, &flow, &ctx);
decor.line(&format!(" Rows : {} projected", r.rows.len()));
r
} else {
let total = {
let ctx = RunContext {
entries: &entries,
helpers: &helpers,
base_vars: base_vars.clone(),
named_envs: named_envs.clone(),
root: root.clone(),
runner: &dry,
strings: &cli_strings,
params: params.clone(),
sink: None,
shuffle,
};
run_flow_raw(&flow, &ctx).rows.len()
};
decor.line(&format!(" Rows : {total}"));
let done = std::sync::atomic::AtomicUsize::new(0);
let sink = |ev: RowEvent| {
if !matches!(ev, RowEvent::Completed(_)) {
return;
}
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
eprint!("\r running {n}/{total} ");
let _ = std::io::stderr().flush();
};
let ctx = RunContext {
entries: &entries,
helpers: &helpers,
base_vars,
named_envs,
root,
runner: &live,
strings: &cli_strings,
params,
sink: Some(&sink),
shuffle,
};
let mut r = run_flow_raw(&flow, &ctx);
finalize(&mut r, &flow, &ctx);
eprintln!("\r running {total}/{total} done");
r
};
if !result.warnings.is_empty() {
decor.line(&format!(" Warnings : {}", result.warnings.len()));
for w in &result.warnings {
decor.line(&format!(" ~ {w}"));
}
}
if !result.skipped.is_empty() {
decor.line(&format!(
" Skipped : {} ({})",
result.skipped.len(),
result.skipped.join(", ")
));
}
if !result.errors.is_empty() {
decor.line(&format!(" Errors : {}", result.errors.len()));
for e in &result.errors {
decor.line(&format!(" ! {e}"));
}
}
let requested: Vec<Option<&str>> = if outputs.is_empty() {
vec![None]
} else {
outputs.iter().map(|o| Some(o.as_str())).collect()
};
let mut write_failed = false;
for target in requested {
match write_output(&result, &flow.header, target, &report) {
Ok(OutputTarget::Stdout) => {
}
Ok(OutputTarget::File(path)) => {
decor.line(&format!(" Output : {}", path.display()));
}
Err(e) => {
eprintln!("error: cannot write output: {e}");
write_failed = true;
}
}
}
if write_failed {
return 1;
}
match (result.skipped.is_empty(), result.errors.is_empty()) {
(false, _) => EXIT_SKIPPED,
(true, false) => 1,
(true, true) => 0,
}
}
pub const EXIT_SKIPPED: i32 = 3;
enum OutputTarget {
Stdout,
File(PathBuf),
}
fn write_output(
result: &ReportResult,
header: &Header,
output: Option<&str>,
report: &Report,
) -> Result<OutputTarget, String> {
match output {
Some("-") => {
let bytes = CsvWriter.write(result, header)?;
std::io::stdout()
.write_all(&bytes)
.map_err(|e| e.to_string())?;
Ok(OutputTarget::Stdout)
}
Some(path) => {
let ext = output_extension_of(path);
let writer = writer_for_extension(&ext).ok_or_else(|| unsupported_ext(&ext))?;
let bytes = writer.write(result, header)?;
fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
Ok(OutputTarget::File(PathBuf::from(path)))
}
None => {
let ext = output_extension_from_header(header)?;
let writer = writer_for_extension(&ext).ok_or_else(|| unsupported_ext(&ext))?;
let path = derived_output_path(report, &ext);
let bytes = writer.write(result, header)?;
fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(OutputTarget::File(path))
}
}
}
fn check_outputs(outputs: &[String], header: &Header) -> Result<(), String> {
if outputs.is_empty() {
output_extension_from_header(header)?;
return Ok(());
}
if outputs.iter().filter(|o| o.as_str() == "-").count() > 1 {
return Err("-o - was given more than once, but there is only one stdout".to_string());
}
let mut seen: Vec<&str> = Vec::new();
for out in outputs {
if out == "-" {
continue;
}
if seen.contains(&out.as_str()) {
return Err(format!("-o {out} was given more than once"));
}
seen.push(out);
let ext = output_extension_of(out);
if writer_for_extension(&ext).is_none() {
return Err(unsupported_ext(&ext));
}
}
Ok(())
}
fn output_extension_of(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("csv")
.to_ascii_lowercase()
}
fn output_extension_from_header(header: &Header) -> Result<String, String> {
let ext = header
.output()
.map(|f| f.trim().to_ascii_lowercase())
.filter(|f| !f.is_empty())
.unwrap_or_else(|| "csv".to_string());
if writer_for_extension(&ext).is_none() {
return Err(format!(
"unsupported '# output:' format '{ext}' (supported: {})",
OUTPUT_EXTENSIONS.join(", ")
));
}
Ok(ext)
}
fn unsupported_ext(ext: &str) -> String {
format!(
"unsupported output extension '.{ext}' (supported: {})",
OUTPUT_EXTENSIONS.join(", ")
)
}
fn derived_output_path(report: &Report, ext: &str) -> PathBuf {
if name_has_output_token(&report.name) {
let stem = sanitize_file_stem(&expand_output_tokens(&report.name));
let file = format!("{stem}.{ext}");
return match report.path.as_deref().and_then(Path::parent) {
Some(dir) => dir.join(file),
None => PathBuf::from(file),
};
}
if let Some(path) = &report.path {
return path.with_extension(ext);
}
PathBuf::from(format!("{}.{ext}", sanitize_file_stem(&report.name)))
}
struct Decor {
to_stderr: bool,
}
impl Decor {
fn new(csv_to_stdout: bool) -> Self {
Decor {
to_stderr: csv_to_stdout,
}
}
fn line(&mut self, s: &str) {
if self.to_stderr {
eprintln!("{s}");
} else {
println!("{s}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"paperboy_report_cli_{tag}_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn sanitize_file_stem_replaces_path_and_awkward_chars() {
assert_eq!(sanitize_file_stem("a/b:c"), "a_b_c");
assert_eq!(sanitize_file_stem("../escape"), "___escape");
assert_eq!(sanitize_file_stem(" keep me-1_2 "), "keep me-1_2");
assert_eq!(sanitize_file_stem(" "), "report");
}
#[test]
fn derived_output_path_uses_report_path_for_plain_name() {
let mut report = Report::from_text("nightly", "# name: nightly\n");
report.path = Some(PathBuf::from("/reports/nightly.trail"));
assert_eq!(
derived_output_path(&report, "csv"),
PathBuf::from("/reports/nightly.csv")
);
}
#[test]
fn derived_output_path_expands_time_token_next_to_report() {
let mut report = Report::from_text("run_{time}", "# name: run_{time}\n");
report.path = Some(PathBuf::from("/reports/nightly.trail"));
let out = derived_output_path(&report, "csv");
let name = out.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("run_"), "unexpected name: {name}");
assert!(name.ends_with(".csv"), "unexpected name: {name}");
assert!(!name.contains("{time}"), "token not expanded: {name}");
assert_eq!(out.parent(), Some(Path::new("/reports")));
}
#[test]
fn derived_output_path_pathless_report_sanitizes_name() {
let report = Report::from_text("weird/name", "# name: weird/name\n");
assert_eq!(
derived_output_path(&report, "csv"),
PathBuf::from("weird_name.csv")
);
}
#[test]
fn dry_run_writes_projected_csv_to_file() {
let dir = temp_dir("dry");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\nREPORT REQUEST Ping\n",
)
.unwrap();
let out = dir.join("out.csv");
let code = run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec![out.to_string_lossy().into_owned()],
true, Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 0, "dry run should succeed");
let csv = fs::read_to_string(&out).unwrap();
let mut lines = csv.lines();
assert_eq!(lines.next(), Some("Status"), "header row");
assert!(lines.next().is_some(), "one projected row expected");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_supplied_param_repoints_a_folders_loop() {
let dir = temp_dir("param");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let batch = dir.join("batch-07");
fs::create_dir_all(batch.join("case-a")).unwrap();
fs::create_dir_all(batch.join("case-b")).unwrap();
fs::create_dir_all(dir.join("empty")).unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\nPARAM FOLDER CASES = \"./empty\"\n\
FOR CASE IN FOLDERS \"{{CASES}}\"\n REPORT CASE\n REPORT REQUEST Ping\nEND\n",
)
.unwrap();
let run_with = |params: ParamValues, out: &Path| {
run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec![out.to_string_lossy().into_owned()],
true, Vec::new(),
None,
params,
)
};
let default_out = dir.join("default.csv");
assert_eq!(run_with(ParamValues::new(), &default_out), 0);
let csv = fs::read_to_string(&default_out).unwrap();
assert!(
!csv.contains("case-a"),
"the declared default should still point at ./empty:\n{csv}"
);
let chosen_out = dir.join("chosen.csv");
let mut params = ParamValues::new();
params.insert("CASES".into(), batch.to_string_lossy().into_owned());
assert_eq!(run_with(params, &chosen_out), 0);
let csv = fs::read_to_string(&chosen_out).unwrap();
assert!(
csv.contains("case-a") && csv.contains("case-b"),
"both cases from the supplied folder expected:\n{csv}"
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_param_the_report_does_not_declare_is_refused() {
let dir = temp_dir("badparam");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\nPARAM FOLDER CASES = \"./empty\"\n\
# columns: Ping.HttpStatus as Status\nREPORT REQUEST Ping\n",
)
.unwrap();
let out = dir.join("out.csv");
let mut params = ParamValues::new();
params.insert("CASE_DIR".into(), "./whatever".into());
let code = run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec![out.to_string_lossy().into_owned()],
true,
Vec::new(),
None,
params,
);
assert_eq!(code, 1, "an undeclared parameter is a setup error");
assert!(!out.exists(), "nothing should be written for a refused run");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn one_run_writes_every_requested_format() {
let dir = temp_dir("multiout");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\n\
REPORT REQUEST Ping\n",
)
.unwrap();
let html = dir.join("out.html");
let json = dir.join("out.json");
let csv = dir.join("out.csv");
let code = run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec![
html.to_string_lossy().into_owned(),
json.to_string_lossy().into_owned(),
csv.to_string_lossy().into_owned(),
],
true, Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 0, "a multi-output dry run should succeed");
let html_text = fs::read_to_string(&html).unwrap();
assert!(html_text.contains("<table"), "not HTML:\n{html_text}");
let json_text = fs::read_to_string(&json).unwrap();
assert!(
serde_json::from_str::<serde_json::Value>(&json_text).is_ok(),
"not JSON:\n{json_text}"
);
let csv_text = fs::read_to_string(&csv).unwrap();
assert!(csv_text.starts_with("Status"), "not CSV:\n{csv_text}");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn impossible_output_combinations_are_refused_before_the_run() {
let dir = temp_dir("badout");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\n\
REPORT REQUEST Ping\n",
)
.unwrap();
let go = |outs: Vec<String>| {
run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
outs,
true,
Vec::new(),
None,
ParamValues::new(),
)
};
assert_eq!(go(vec!["-".into(), "-".into()]), 1, "two stdouts");
let dup = dir.join("out.json").to_string_lossy().into_owned();
assert_eq!(go(vec![dup.clone(), dup.clone()]), 1, "duplicate path");
assert!(
!dir.join("out.json").exists(),
"a refused run writes nothing"
);
let good = dir.join("fine.csv");
assert_eq!(
go(vec![
good.to_string_lossy().into_owned(),
dir.join("out.docx").to_string_lossy().into_owned(),
]),
1,
"unsupported extension"
);
assert!(!good.exists(), "nothing is written for a refused run");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn stdout_and_a_file_can_be_asked_for_together() {
let dir = temp_dir("mixedout");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\n\
REPORT REQUEST Ping\n",
)
.unwrap();
let json = dir.join("out.json");
let code = run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec!["-".into(), json.to_string_lossy().into_owned()],
true,
Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 0);
assert!(json.exists(), "the file output still lands");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn multi_env_loads_every_env_and_runs_the_envs_loop() {
let dir = temp_dir("multienv");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
fs::write(dir.join("prod.vars"), "HOST=prod.test\n").unwrap();
fs::write(dir.join("staging.vars"), "HOST=staging.test\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\nFOR TARGET IN ENVS \"prod\", \"staging\"\n REPORT TARGET\n REPORT REQUEST Ping\nEND\n",
)
.unwrap();
let out = dir.join("out.csv");
let code = run(
Some(coll.to_string_lossy().into_owned()),
vec![
dir.join("prod.vars").to_string_lossy().into_owned(),
dir.join("staging.vars").to_string_lossy().into_owned(),
],
report.to_string_lossy().into_owned(),
vec![out.to_string_lossy().into_owned()],
true, Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 0, "a multi-env dry run should succeed");
let csv = fs::read_to_string(&out).unwrap();
assert!(csv.contains("prod"), "prod env row missing:\n{csv}");
assert!(csv.contains("staging"), "staging env row missing:\n{csv}");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn duplicate_env_stem_is_rejected() {
let dir = temp_dir("dupenv");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let a = dir.join("a");
let b = dir.join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
fs::write(a.join("prod.vars"), "HOST=a.test\n").unwrap();
fs::write(b.join("prod.vars"), "HOST=b.test\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\nREPORT REQUEST Ping\n",
)
.unwrap();
let code = run(
Some(coll.to_string_lossy().into_owned()),
vec![
a.join("prod.vars").to_string_lossy().into_owned(),
b.join("prod.vars").to_string_lossy().into_owned(),
],
report.to_string_lossy().into_owned(),
vec!["-".to_string()],
true,
Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 1, "a duplicate env stem is a fatal setup error");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_collection_is_a_setup_error() {
let dir = temp_dir("nocoll");
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: missing.hurl\nREPORT REQUEST Ping\n",
)
.unwrap();
let code = run(
Some(dir.join("missing.hurl").to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec!["-".to_string()],
true,
Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 1, "a missing collection is a fatal setup error");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn unsupported_output_extension_is_rejected() {
let dir = temp_dir("badext");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\nREPORT REQUEST Ping\n",
)
.unwrap();
let code = run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec![dir.join("out.docx").to_string_lossy().into_owned()],
true,
Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 1, "an unsupported extension should fail");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn dry_run_writes_each_supported_output_format() {
let dir = temp_dir("fmts");
let coll = dir.join("api.hurl");
fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
let report = dir.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\nREPORT REQUEST Ping\n",
)
.unwrap();
for (ext, check) in [
(
"json",
&(|b: &[u8]| b.starts_with(b"{")) as &dyn Fn(&[u8]) -> bool,
),
(
"html",
&(|b: &[u8]| b.starts_with(b"<!DOCTYPE html>")) as &dyn Fn(&[u8]) -> bool,
),
(
"xlsx",
&(|b: &[u8]| b.starts_with(b"PK")) as &dyn Fn(&[u8]) -> bool,
),
(
"pdf",
&(|b: &[u8]| b.starts_with(b"%PDF-")) as &dyn Fn(&[u8]) -> bool,
),
] {
let out = dir.join(format!("out.{ext}"));
let code = run(
Some(coll.to_string_lossy().into_owned()),
Vec::new(),
report.to_string_lossy().into_owned(),
vec![out.to_string_lossy().into_owned()],
true, Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 0, ".{ext} output should succeed");
let bytes = fs::read(&out).unwrap();
assert!(!bytes.is_empty(), ".{ext} is non-empty");
assert!(check(&bytes), ".{ext} has the expected magic/shape");
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn headers_supply_collection_and_environment_when_flags_omitted() {
let dir = temp_dir("hdrres");
let sub = dir.join("reports");
fs::create_dir_all(&sub).unwrap();
fs::write(
dir.join("api.hurl"),
"# Ping\nGET https://example.test/ping\nHTTP *\n",
)
.unwrap();
fs::write(dir.join("prod.vars"), "HOST=prod.test\n").unwrap();
let report = sub.join("r.trail");
fs::write(
&report,
"# name: r\n# collection: ../api.hurl\n# environment: ../prod.vars\nREPORT HOST\nREPORT REQUEST Ping\n",
)
.unwrap();
let out = dir.join("out.csv");
let code = run(
None, Vec::new(), report.to_string_lossy().into_owned(),
vec![out.to_string_lossy().into_owned()],
true, Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(code, 0, "header-resolved run should succeed");
let csv = fs::read_to_string(&out).unwrap();
assert!(csv.contains("prod.test"), "env not applied:\n{csv}");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_collection_and_no_header_is_a_setup_error() {
let dir = temp_dir("nohdr");
let report = dir.join("r.trail");
fs::write(&report, "# name: r\nREPORT REQUEST Ping\n").unwrap();
let code = run(
None,
Vec::new(),
report.to_string_lossy().into_owned(),
vec!["-".to_string()],
true,
Vec::new(),
None,
ParamValues::new(),
);
assert_eq!(
code, 1,
"no collection flag and no header is a fatal setup error"
);
fs::remove_dir_all(&dir).ok();
}
}