use std::collections::HashMap;
use std::fs;
use std::io::IsTerminal;
use ratatui::crossterm::style::Stylize;
use crate::environment::{looks_like_env, parse_vars};
use crate::hurl::{
EntryOutcome, FormFieldKind, collection_to_hurl, expand_base64_form_fields, parse_hurl_error,
run_hurl, run_hurl_streaming,
};
use crate::postman::{looks_like_postman, parse_collection};
use crate::request;
use crate::shared_utils::stem;
pub fn run(collection_path: String, env_path: Option<String>, batch: bool) -> i32 {
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 col_name = stem(&collection_path, "collection");
let entries = parse_collection(&col_content);
if entries.is_empty() {
match (!looks_like_postman(&col_content))
.then(|| 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 has_base64 = entries.iter().any(|e| {
e.form_fields
.iter()
.any(|f| f.kind == FormFieldKind::Base64File)
});
let mut entries = entries;
let unreadable: Vec<String> = entries
.iter()
.filter(|e| e.is_unreadable())
.map(|e| {
if e.title.is_empty() {
"<unnamed>".to_string()
} else {
e.title.clone()
}
})
.collect();
let skipped = !unreadable.is_empty();
if skipped {
eprintln!(
"warning: {} request(s) in '{collection_path}' could not be read and were skipped: {}",
unreadable.len(),
unreadable.join(", ")
);
entries.retain(|e| !e.is_unreadable());
if entries.is_empty() {
eprintln!("error: nothing in '{collection_path}' could be read as a request");
return 1;
}
}
if has_base64 {
let root = std::path::Path::new(&collection_path).parent();
if let Err(e) = expand_base64_form_fields(&mut entries, root) {
eprintln!("error: cannot read a Base64 File form field: {e}");
return 1;
}
}
let run_content = if looks_like_postman(&col_content) || has_base64 || skipped {
for e in &mut entries {
e.ensure_run_content_length();
}
collection_to_hurl(&entries)
} else {
col_content
};
let mut vars: HashMap<String, String> = HashMap::new();
let mut env_name: Option<String> = None;
if let Some(env_path) = &env_path {
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 = stem(env_path, "env");
env_name = Some(name.clone());
for v in &parse_vars(name, &env_content).vars {
vars.insert(v.key.clone(), v.value.clone());
}
}
let mut run_content = run_content;
if request::strip_bound_variable_options(&mut entries, &vars) {
for e in &mut entries {
e.ensure_run_content_length();
}
run_content = collection_to_hurl(&entries);
}
let color = color_enabled();
println!();
println!(
"{}",
paint(
color,
Hue::Bold,
&format!("🦀 PaperBoy — running collection \"{col_name}\"")
)
);
if let Some(name) = &env_name {
println!(" Environment : {name}");
}
println!(" Requests : {}", entries.len());
if !batch {
println!(
"{}",
paint(
color,
Hue::Dim,
" (streaming results as they finish; cookies set by one request are not \
automatically carried to the next — pass --batch if your collection relies on \
that. An explicit [Cookies] section on a request is unaffected either way.)"
)
);
}
println!("{}", "─".repeat(60));
let total = entries.len();
let titles: Vec<&str> = entries.iter().map(|e| e.title.as_str()).collect();
let file_root = std::path::Path::new(&collection_path).parent();
let mut per_request: Vec<Option<bool>> = vec![None; total];
let mut record = |eo: &EntryOutcome| credit(&mut per_request, eo);
let out = if batch {
let out = run_hurl(&run_content, &vars, file_root);
for eo in out.entries.iter() {
print_entry(
color,
eo.entry_index,
total,
titles.get(eo.entry_index).copied(),
eo,
);
record(eo);
}
out
} else {
run_hurl_streaming(&run_content, &vars, file_root, |eo| {
print_entry(
color,
eo.entry_index,
total,
titles.get(eo.entry_index).copied(),
eo,
);
record(eo);
})
};
let passed = per_request.iter().filter(|r| **r == Some(true)).count();
let failed = per_request.iter().filter(|r| **r == Some(false)).count();
if out.entries.is_empty() {
if let Some(e) = &out.error {
eprintln!("\nerror: {e}");
}
return 1;
}
println!();
println!("{}", "─".repeat(60));
let summary = format!(" Passed: {passed} Failed: {failed} Total: {total}");
println!(
"{}",
paint(
color,
if failed > 0 { Hue::Red } else { Hue::Green },
&summary
)
);
println!();
if failed > 0 { 1 } else { 0 }
}
fn credit(per_request: &mut [Option<bool>], eo: &EntryOutcome) {
if let Some(slot) = per_request.get_mut(eo.entry_index) {
*slot = Some(slot.unwrap_or(true) && eo.ok);
}
}
fn print_entry(color: bool, idx: usize, total: usize, title: Option<&str>, eo: &EntryOutcome) {
println!();
println!(
"{}",
paint(
color,
Hue::Cyan,
&format!("[{}/{}] {} {}", idx + 1, total, eo.method, eo.url)
)
);
if let Some(title) = title.filter(|t| !t.is_empty()) {
println!(" {title}");
}
let status_hue = if eo.ok { Hue::Green } else { Hue::Red };
let icon = if eo.ok { "✓" } else { "✗" };
println!(
" {}",
paint(
color,
status_hue,
&format!("{icon} {} {}", eo.status, eo.status_text)
)
);
if let Some(err) = &eo.error {
println!(" {}", paint(color, Hue::Red, &format!("! {err}")));
}
if !eo.body.is_empty() {
for line in eo.body.lines().take(40) {
println!(" {line}");
}
let n = eo.body.lines().count();
if n > 40 {
println!(
"{}",
paint(color, Hue::Dim, &format!(" … ({} more lines)", n - 40))
);
}
}
if !eo.asserts.is_empty() {
let n_ok = eo.asserts.iter().filter(|a| a.passed).count();
let all_ok = n_ok == eo.asserts.len();
let badge = if all_ok { "✓" } else { "✗" };
println!(
" {}",
paint(
color,
if all_ok { Hue::Green } else { Hue::Red },
&format!("[Asserts] {badge} {n_ok}/{}", eo.asserts.len())
)
);
for a in &eo.asserts {
if a.passed {
println!(
" {}",
paint(color, Hue::Green, &format!("✓ {}", a.expr))
);
} else if a.detail.is_empty() {
println!(" {}", paint(color, Hue::Red, &format!("✗ {}", a.expr)));
} else {
println!(
" {}",
paint(color, Hue::Red, &format!("✗ {} ({})", a.expr, a.detail))
);
}
}
}
for (name, value) in &eo.captures {
println!(
" {}",
paint(color, Hue::Yellow, &format!("→ captured {name} = {value}"))
);
}
}
fn color_enabled() -> bool {
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
#[derive(Clone, Copy)]
enum Hue {
Green,
Red,
Yellow,
Cyan,
Dim,
Bold,
}
fn paint(on: bool, hue: Hue, s: &str) -> String {
if !on {
return s.to_string();
}
match hue {
Hue::Green => s.green().to_string(),
Hue::Red => s.red().to_string(),
Hue::Yellow => s.yellow().to_string(),
Hue::Cyan => s.cyan().to_string(),
Hue::Dim => s.dark_grey().to_string(),
Hue::Bold => s.bold().to_string(),
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_collection_with_nothing_to_run_exits_non_zero() {
let dir = std::env::temp_dir().join(format!("paperboy_cli_empty_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("empty.hurl");
std::fs::write(&path, "\n\n").unwrap();
let code = super::run(path.to_string_lossy().into_owned(), None, false);
assert_eq!(code, 1, "an empty collection is a failure, not a pass");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_repeated_request_counts_once_and_keeps_its_own_result() {
use crate::hurl::run::EntryOutcome;
let outcome = |entry_index: usize, ok: bool| EntryOutcome {
entry_index,
ok,
..EntryOutcome::default()
};
let mut per_request = vec![None; 2];
for eo in [outcome(0, true), outcome(0, false), outcome(1, true)] {
super::credit(&mut per_request, &eo);
}
assert_eq!(
per_request,
vec![Some(false), Some(true)],
"the repeat's failure sticks to request 0, and request 1 keeps its own pass"
);
let passed = per_request.iter().filter(|r| **r == Some(true)).count();
assert_eq!(passed, 1, "never more passes than there are requests");
}
#[test]
fn an_out_of_range_result_is_ignored() {
use crate::hurl::run::EntryOutcome;
let mut per_request = vec![None; 1];
super::credit(
&mut per_request,
&EntryOutcome {
entry_index: 9,
ok: true,
..EntryOutcome::default()
},
);
assert_eq!(per_request, vec![None]);
}
}