use crate::report::run::HelperCollection;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::collection::Collection;
use crate::environment::Environment;
use crate::hurl::HurlEntry;
use crate::session::effective_env;
use super::flow::ReportFlow;
use super::validate::{self, Context, Diagnostic};
pub struct ReportRunInputs {
pub flow: ReportFlow,
pub entries: Vec<HurlEntry>,
pub helpers: Vec<HelperCollection>,
pub base_vars: HashMap<String, String>,
pub named_envs: HashMap<String, HashMap<String, String>>,
pub root: Option<PathBuf>,
pub file_root: Option<PathBuf>,
pub language: crate::i18n::Language,
pub params: super::params::ParamValues,
}
pub(crate) fn flatten_env(env: &Environment) -> HashMap<String, String> {
env.vars
.iter()
.map(|v| (v.key.clone(), v.value.clone()))
.collect()
}
pub(crate) fn resolve_ref_path(report_path: Option<&Path>, cref: &str) -> PathBuf {
let p = Path::new(cref);
if p.is_absolute() {
return p.to_path_buf();
}
if let Some(dir) = report_path.and_then(|rp| rp.parent()) {
return dir.join(p);
}
p.to_path_buf()
}
pub(crate) fn paths_equal(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
pub fn report_base_dir(flow: &ReportFlow, report_path: Option<&Path>) -> (PathBuf, bool) {
if let Some(r) = flow.header.root()
&& !r.trim().is_empty()
{
return (resolve_ref_path(report_path, r), true);
}
if let Some(dir) = report_path.and_then(|p| p.parent()) {
return (dir.to_path_buf(), true);
}
(std::env::current_dir().unwrap_or_default(), false)
}
pub fn resolve_bound_collection(
collections: &[Collection],
flow: &ReportFlow,
report_path: Option<&Path>,
) -> Option<usize> {
let cref = flow.header.collection()?;
if cref.starts_with("git:") {
return None;
}
let target = resolve_ref_path(report_path, cref);
collections
.iter()
.position(|c| c.path.as_ref().is_some_and(|p| paths_equal(p, &target)) || c.name == cref)
}
pub fn bound_entries(
collections: &[Collection],
flow: &ReportFlow,
report_path: Option<&Path>,
) -> Option<Vec<HurlEntry>> {
if let Some(ci) = resolve_bound_collection(collections, flow, report_path) {
return Some(collections[ci].entries.clone());
}
let cref = flow.header.collection()?;
if cref.starts_with("git:") {
return None;
}
let text = std::fs::read_to_string(resolve_ref_path(report_path, cref)).ok()?;
if crate::postman::looks_like_postman(&text) {
return Some(crate::postman::import_postman(&text));
}
if crate::hurl::parse_hurl_error(&text).is_some() {
return None;
}
Some(crate::hurl::parse_hurl(&text))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestChoice {
pub qualified: String,
pub title: String,
pub alias: Option<String>,
}
pub fn request_choices(entries: &[HurlEntry], helpers: &[HelperCollection]) -> Vec<RequestChoice> {
let mut out: Vec<RequestChoice> = entries
.iter()
.map(|e| RequestChoice {
qualified: e.title.clone(),
title: e.title.clone(),
alias: None,
})
.collect();
for h in helpers {
out.extend(h.entries.iter().map(|e| RequestChoice {
qualified: format!("{}/{}", h.alias, e.title),
title: e.title.clone(),
alias: Some(h.alias.clone()),
}));
}
out
}
pub fn load_helpers(
collections: &[Collection],
flow: &ReportFlow,
report_path: Option<&Path>,
strings: &crate::i18n::Strings,
) -> (Vec<HelperCollection>, Vec<(String, String)>) {
let mut loaded = Vec::new();
let mut errors = Vec::new();
for c in flow.header.collections().into_iter().skip(1) {
let Some(alias) = c.alias else { continue };
let reference = c.reference.trim();
if reference.is_empty() {
continue;
}
let open = collections.iter().find(|col| {
col.name == reference
|| (!reference.starts_with("git:")
&& col
.path
.as_ref()
.is_some_and(|p| paths_equal(p, &resolve_ref_path(report_path, reference))))
});
if let Some(col) = open {
loaded.push(HelperCollection {
alias: alias.to_string(),
entries: col.entries.clone(),
});
continue;
}
if reference.starts_with("git:") {
errors.push((
reference.to_string(),
strings.diag_collection_helper_not_open.to_string(),
));
continue;
}
let path = resolve_ref_path(report_path, reference);
match std::fs::read_to_string(&path) {
Ok(text) if crate::postman::looks_like_postman(&text) => {
loaded.push(HelperCollection {
alias: alias.to_string(),
entries: crate::postman::import_postman(&text),
})
}
Ok(text) => match crate::hurl::parse_hurl_error(&text) {
Some(err) => errors.push((reference.to_string(), err)),
None => loaded.push(HelperCollection {
alias: alias.to_string(),
entries: crate::hurl::parse_hurl(&text),
}),
},
Err(e) => errors.push((reference.to_string(), e.to_string())),
}
}
(loaded, errors)
}
fn base_var_names(
collections: &[Collection],
global_envs: &[Environment],
active_env_id: Option<u64>,
flow: &ReportFlow,
bound: Option<usize>,
) -> Option<Vec<String>> {
match (bound, flow.header.environment()) {
(_, Some(name)) => {
let name = name.trim();
global_envs
.iter()
.find(|e| e.name == name)
.map(|env| env.vars.iter().map(|v| v.key.clone()).collect())
}
(Some(ci), None) => Some(
effective_env(collections, global_envs, ci, active_env_id)
.map(|env| env.vars.iter().map(|v| v.key.clone()).collect())
.unwrap_or_default(),
),
(None, _) => None,
}
}
pub fn report_diagnostics(
collections: &[Collection],
global_envs: &[Environment],
active_env_id: Option<u64>,
flow: &ReportFlow,
report_path: Option<&Path>,
strings: &crate::i18n::Strings,
) -> Vec<Diagnostic> {
let bound = resolve_bound_collection(collections, flow, report_path);
let request_entries_owned = bound_entries(collections, flow, report_path);
let titles: Option<Vec<String>> = request_entries_owned
.as_ref()
.map(|es| es.iter().map(|e| e.title.clone()).collect());
let fields: Option<Vec<(String, Vec<String>)>> = request_entries_owned.as_ref().map(|es| {
es.iter()
.map(|e| {
(
e.title.clone(),
e.reports.iter().map(|(n, _)| n.clone()).collect(),
)
})
.collect()
});
let env_names: Vec<String> = global_envs.iter().map(|e| e.name.clone()).collect();
let (base_dir, anchored) = report_base_dir(flow, report_path);
let base_var_names = base_var_names(collections, global_envs, active_env_id, flow, bound);
let mut all_env_var_names: Vec<String> = global_envs
.iter()
.flat_map(|e| e.vars.iter().map(|v| v.key.clone()))
.collect();
all_env_var_names.sort();
all_env_var_names.dedup();
let (helpers, helper_errors) = load_helpers(collections, flow, report_path, strings);
let ctx = Context {
request_titles: titles.as_deref(),
env_names: Some(&env_names),
request_fields: fields.as_deref(),
root: anchored.then_some(base_dir.as_path()),
base_var_names: base_var_names.as_deref(),
all_env_var_names: Some(&all_env_var_names),
request_entries: request_entries_owned.as_deref(),
helpers: &helpers,
helper_errors: &helper_errors,
strings,
};
validate::validate(flow, &ctx)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn diagnostics_fingerprint(
collections: &[Collection],
global_envs: &[Environment],
active_env_id: Option<u64>,
flow: &ReportFlow,
report_path: Option<&Path>,
strings: &crate::i18n::Strings,
) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
flow.to_text().hash(&mut h);
report_path.hash(&mut h);
active_env_id.hash(&mut h);
(strings.diag_var_maybe_undefined.as_ptr() as usize).hash(&mut h);
for c in collections {
c.name.hash(&mut h);
c.path.hash(&mut h);
c.linked_env_id.hash(&mut h);
c.entries.len().hash(&mut h);
for e in &c.entries {
e.title.hash(&mut h);
e.method.hash(&mut h);
e.url.hash(&mut h);
e.body_wire().hash(&mut h);
e.basic_auth.hash(&mut h);
for kv in e
.headers
.iter()
.chain(&e.queries)
.chain(&e.cookies)
.chain(&e.options)
{
kv.key.hash(&mut h);
kv.value.hash(&mut h);
kv.enabled.hash(&mut h);
}
for f in &e.form_fields {
f.key.hash(&mut h);
f.value.hash(&mut h);
}
for (k, v) in e.captures.iter().chain(&e.reports) {
k.hash(&mut h);
v.hash(&mut h);
}
}
}
for e in global_envs {
e.id.hash(&mut h);
e.name.hash(&mut h);
for v in &e.vars {
v.key.hash(&mut h);
}
}
h.finish()
}
pub fn report_run_inputs(
collections: &[Collection],
global_envs: &[Environment],
active_env_id: Option<u64>,
flow: &ReportFlow,
report_path: Option<&Path>,
) -> Result<ReportRunInputs, RunInputError> {
let ci = resolve_bound_collection(collections, flow, report_path);
let entries = bound_entries(collections, flow, report_path).ok_or(RunInputError::Unbound)?;
let base_vars = match flow
.header
.environment()
.map(str::trim)
.filter(|e| !e.is_empty())
{
Some(name) => global_envs
.iter()
.find(|e| e.name == name)
.map(flatten_env)
.unwrap_or_default(),
None => ci
.and_then(|ci| effective_env(collections, global_envs, ci, active_env_id))
.map(|env| flatten_env(&env))
.unwrap_or_default(),
};
let named_envs = global_envs
.iter()
.map(|e| (e.name.clone(), flatten_env(e)))
.collect();
let report_dir = report_path.and_then(|p| p.parent()).map(Path::to_path_buf);
let root = match flow.header.root() {
Some(r) if !r.trim().is_empty() => Some(resolve_ref_path(report_path, r)),
_ => report_dir,
};
let collection_path: Option<PathBuf> = match ci {
Some(ci) => collections[ci].path.clone(),
None => flow
.header
.collection()
.filter(|c| !c.starts_with("git:"))
.map(|c| resolve_ref_path(report_path, c)),
};
let file_root = collection_path
.as_deref()
.and_then(|p| p.parent())
.map(Path::to_path_buf);
let (helpers, _) = load_helpers(
collections,
flow,
report_path,
crate::i18n::Strings::english(),
);
Ok(ReportRunInputs {
flow: flow.clone(),
entries,
helpers,
base_vars,
named_envs,
root,
file_root,
language: crate::i18n::Language::default(),
params: Default::default(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunInputError {
Unbound,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hurl::{HurlEntry, KvRow};
fn strings() -> crate::i18n::Strings {
crate::i18n::Strings::for_language(&crate::i18n::Language::English)
}
fn entry() -> HurlEntry {
HurlEntry {
title: "Oauth".into(),
method: "POST".into(),
url: "https://api.example.com/token".into(),
..Default::default()
}
}
fn fingerprint(cols: &[Collection], envs: &[Environment], flow: &ReportFlow) -> u64 {
diagnostics_fingerprint(cols, envs, None, flow, None, &strings())
}
#[test]
fn fingerprint_notices_every_input_it_guards() {
let flow = crate::report::parse_flow("# collection: c\nREPORT REQUEST Oauth\n").unwrap();
let cols = vec![Collection::new("c".into(), vec![entry()])];
let envs: Vec<Environment> = Vec::new();
let base = fingerprint(&cols, &envs, &flow);
assert_eq!(base, fingerprint(&cols, &envs, &flow), "must be stable");
let mut cases: Vec<(&str, Collection)> = Vec::new();
let mut c = cols[0].clone();
c.entries[0].title = "Renamed".into();
cases.push(("title", c));
let mut c = cols[0].clone();
c.entries[0].url = "https://api.example.com/other".into();
cases.push(("url", c));
let mut c = cols[0].clone();
c.entries[0].method = "GET".into();
cases.push(("method", c));
let mut c = cols[0].clone();
c.entries[0].body_src = Some("{{tok}}".into());
cases.push(("body", c));
let mut c = cols[0].clone();
c.entries[0].headers.push(KvRow::new("A", "{{v}}"));
cases.push(("header", c));
let mut c = cols[0].clone();
c.entries[0]
.captures
.push(("cap".into(), "jsonpath \"$.a\"".into()));
cases.push(("capture", c));
let mut c = cols[0].clone();
c.entries[0]
.reports
.push(("F".into(), "jsonpath \"$.a\"".into()));
cases.push(("report field", c));
let mut c = cols[0].clone();
c.entries.push(entry());
cases.push(("entry count", c));
let mut c = cols[0].clone();
c.name = "other".into();
cases.push(("collection name", c));
for (what, c) in cases {
assert_ne!(
base,
fingerprint(&[c], &envs, &flow),
"changing the {what} must change the fingerprint"
);
}
let other = crate::report::parse_flow("# collection: c\nREPORT REQUEST Renamed\n").unwrap();
assert_ne!(base, fingerprint(&cols, &envs, &other), "flow");
let mut env = Environment {
id: 1,
name: "e".into(),
vars: Vec::new(),
path: None,
git_origin: None,
};
env.vars
.push(crate::environment::EnvVar::user("HOST".into(), "a".into()));
let with_env = vec![env.clone()];
let env_key = fingerprint(&cols, &with_env, &flow);
assert_ne!(base, env_key, "an env variable name must count");
let mut quiet = env;
quiet.vars[0] = crate::environment::EnvVar::user("HOST".into(), "b".into());
assert_eq!(
env_key,
fingerprint(&cols, &[quiet], &flow),
"a value change must not re-key"
);
}
}
#[cfg(test)]
mod helper_loading_tests {
use super::*;
use crate::report::parser::parse_flow;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"paperboy_helpers_{tag}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
d
}
const HELPER_HURL: &str = "# fetch_frame\nGET http://example.test/frame\n\n";
const POSTMAN_JSON: &str = r#"{
"info": { "name": "API", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{ "name": "Oauth", "request": { "method": "POST", "url": { "raw": "http://example.test/t" } } }
]
}"#;
#[test]
fn a_helper_is_read_from_disk_relative_to_the_report() {
let dir = tmpdir("disk");
std::fs::write(dir.join("h.hurl"), HELPER_HURL).unwrap();
let report = dir.join("r.trail");
let flow = parse_flow(
"# collection: ./api.hurl\n# collection: ./h.hurl AS h\n\nREQUEST h/fetch_frame\n",
)
.expect("parses");
let (helpers, errors) = load_helpers(
&[],
&flow,
Some(report.as_path()),
crate::i18n::Strings::english(),
);
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(helpers.len(), 1);
assert_eq!(helpers[0].alias, "h");
assert_eq!(helpers[0].entries.len(), 1);
assert!(crate::report::run::resolve_qualified(&[], &helpers, "h/fetch_frame").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_primary_collection_is_read_from_disk_when_no_tab_has_it_open() {
let dir = tmpdir("primary");
std::fs::write(
dir.join("api.hurl"),
"# Oauth\nPOST http://example.test/t\n\n",
)
.unwrap();
let report = dir.join("r.trail");
let flow = parse_flow("# collection: ./api.hurl\n\nREQUEST Oauth\n").expect("parses");
let entries = bound_entries(&[], &flow, Some(report.as_path())).expect("read from disk");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].title, "Oauth");
let diags = report_diagnostics(
&[],
&[],
None,
&flow,
Some(report.as_path()),
crate::i18n::Strings::english(),
);
let s = crate::i18n::Strings::english();
assert!(
!diags
.iter()
.any(|d| d.message == s.diag_collection_not_loaded),
"{diags:?}"
);
assert!(
!diags.iter().any(|d| d.message.contains("Oauth")),
"the request resolves, so nothing should complain about it: {diags:?}"
);
let inputs = report_run_inputs(&[], &[], None, &flow, Some(report.as_path()))
.expect("bound to the file on disk");
assert_eq!(inputs.entries.len(), 1);
assert_eq!(inputs.file_root.as_deref(), Some(dir.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_postman_export_bound_as_the_collection_is_imported_not_rejected() {
let dir = tmpdir("postman-primary");
std::fs::write(dir.join("api.json"), POSTMAN_JSON).unwrap();
let report = dir.join("r.trail");
let flow = parse_flow("# collection: ./api.json\n\nREQUEST Oauth\n").expect("parses");
let entries = bound_entries(&[], &flow, Some(report.as_path())).expect("imported");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].title, "Oauth");
let s = crate::i18n::Strings::english();
let diags = report_diagnostics(&[], &[], None, &flow, Some(report.as_path()), s);
assert!(
!diags
.iter()
.any(|d| d.message == s.diag_collection_not_loaded),
"{diags:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_postman_export_bound_as_a_helper_is_imported_not_rejected() {
let dir = tmpdir("postman-helper");
std::fs::write(dir.join("h.json"), POSTMAN_JSON).unwrap();
let report = dir.join("r.trail");
let flow = parse_flow(
"# collection: ./api.hurl\n# collection: ./h.json AS h\n\nREQUEST h/Oauth\n",
)
.expect("parses");
let (helpers, errors) = load_helpers(
&[],
&flow,
Some(report.as_path()),
crate::i18n::Strings::english(),
);
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(helpers.len(), 1);
assert_eq!(helpers[0].entries.len(), 1);
assert!(crate::report::run::resolve_qualified(&[], &helpers, "h/Oauth").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_git_collection_reference_is_not_fetched_from_disk() {
let flow = parse_flow("# collection: git:origin#main:api.hurl\n\nREQUEST Oauth\n")
.expect("parses");
assert!(bound_entries(&[], &flow, None).is_none());
}
#[test]
fn a_missing_helper_file_is_reported_not_silently_empty() {
let dir = tmpdir("missing");
let report = dir.join("r.trail");
let flow =
parse_flow("# collection: ./api.hurl\n# collection: ./gone.hurl AS h\n\nREQUEST h/x\n")
.expect("parses");
let (helpers, errors) = load_helpers(
&[],
&flow,
Some(report.as_path()),
crate::i18n::Strings::english(),
);
assert!(helpers.is_empty());
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].0, "./gone.hurl");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn choices_put_the_primary_collection_first() {
let primary = [crate::hurl::HurlEntry {
title: "upload".into(),
..Default::default()
}];
let helpers = [HelperCollection {
alias: "h".into(),
entries: vec![crate::hurl::HurlEntry {
title: "fetch_frame".into(),
..Default::default()
}],
}];
let choices = request_choices(&primary, &helpers);
assert_eq!(choices[0].qualified, "upload");
assert_eq!(choices[0].alias, None);
assert_eq!(choices[1].qualified, "h/fetch_frame");
assert_eq!(choices[1].title, "fetch_frame");
}
}