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::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};
pub fn run(
collection_path: Option<String>,
env_paths: Vec<String>,
report_path: String,
output: Option<String>,
dry_run: bool,
) -> i32 {
let to_stdout = output.as_deref() == Some("-");
let report = match Report::load_local(&report_path) {
Ok(r) => r,
Err(e) => {
eprintln!("error: cannot read report file: {e}");
return 1;
}
};
let 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 collection_path = match collection_path {
Some(c) => c,
None => match report.collection_ref() {
Some(c) => resolve_path(report_dir, &c).to_string_lossy().into_owned(),
None => {
eprintln!(
"error: no collection to run against — pass -c/--collection, or add a '# collection:' header to '{report_path}'"
);
return 1;
}
},
};
let col_content = match fs::read_to_string(&collection_path) {
Ok(c) => c,
Err(e) => {
eprintln!("error: cannot read collection file '{collection_path}': {e}");
return 1;
}
};
let entries = parse_collection(&col_content);
if entries.is_empty() {
match (!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 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),
strings: &crate::i18n::Strings::for_language(&crate::i18n::Language::English),
};
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;
}
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)");
}
let result = if dry_run {
let ctx = RunContext {
entries: &entries,
base_vars,
named_envs,
root,
runner: &dry,
sink: None,
};
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,
base_vars: base_vars.clone(),
named_envs: named_envs.clone(),
root: root.clone(),
runner: &dry,
sink: None,
};
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,
base_vars,
named_envs,
root,
runner: &live,
sink: Some(&sink),
};
let mut r = run_flow_raw(&flow, &ctx);
finalize(&mut r, &flow, &ctx);
eprintln!("\r running {total}/{total} done");
r
};
if !result.errors.is_empty() {
decor.line(&format!(" Errors : {}", result.errors.len()));
for e in &result.errors {
decor.line(&format!(" ! {e}"));
}
}
match write_output(&result, &flow.header, output.as_deref(), &report) {
Ok(OutputTarget::Stdout) => {
}
Ok(OutputTarget::File(path)) => {
decor.line(&format!(" Output : {}", path.display()));
}
Err(e) => {
eprintln!("error: cannot write output: {e}");
return 1;
}
}
0
}
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 = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("csv")
.to_ascii_lowercase();
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 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)))
}
fn sanitize_file_stem(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"report".to_string()
} else {
trimmed.to_string()
}
}
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(),
Some(out.to_string_lossy().into_owned()),
true, );
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 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(),
Some(out.to_string_lossy().into_owned()),
true, );
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(),
Some("-".to_string()),
true,
);
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(),
Some("-".to_string()),
true,
);
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(),
Some(dir.join("out.pdf").to_string_lossy().into_owned()),
true,
);
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,
),
] {
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(),
Some(out.to_string_lossy().into_owned()),
true, );
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(),
Some(out.to_string_lossy().into_owned()),
true, );
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(),
Some("-".to_string()),
true,
);
assert_eq!(
code, 1,
"no collection flag and no header is a fatal setup error"
);
fs::remove_dir_all(&dir).ok();
}
}