use std::collections::{HashMap, HashSet};
use super::flow::{
EnvClause, FlowNode, ParamKind, Pattern, Producer, ReportFlow, ReportStmt, RoleRef, ShowField,
};
use crate::i18n::{Strings, fill};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub severity: Severity,
pub message: String,
}
impl Diagnostic {
fn error(msg: impl Into<String>) -> Self {
Diagnostic {
severity: Severity::Error,
message: msg.into(),
}
}
fn warning(msg: impl Into<String>) -> Self {
Diagnostic {
severity: Severity::Warning,
message: msg.into(),
}
}
}
pub struct Context<'a> {
pub request_titles: Option<&'a [String]>,
pub env_names: Option<&'a [String]>,
pub request_fields: Option<&'a [(String, Vec<String>)]>,
pub root: Option<&'a std::path::Path>,
pub base_var_names: Option<&'a [String]>,
pub all_env_var_names: Option<&'a [String]>,
pub request_entries: Option<&'a [crate::hurl::HurlEntry]>,
pub helpers: &'a [super::run::HelperCollection],
pub helper_errors: &'a [(String, String)],
pub strings: &'a Strings,
}
impl Default for Context<'_> {
fn default() -> Self {
Self {
request_titles: None,
env_names: None,
request_fields: None,
root: None,
base_var_names: None,
all_env_var_names: None,
request_entries: None,
helpers: &[],
helper_errors: &[],
strings: Strings::english(),
}
}
}
fn emits_a_column(nodes: &[FlowNode]) -> bool {
nodes.iter().any(|n| match n {
FlowNode::Report(_) => true,
FlowNode::ForEach { body, .. } | FlowNode::ForEnvs { body, .. } => emits_a_column(body),
_ => false,
})
}
pub fn validate(flow: &ReportFlow, ctx: &Context) -> Vec<Diagnostic> {
let mut diags = Vec::new();
let s = ctx.strings;
match flow.header.collection() {
None => diags.push(Diagnostic::error(s.diag_collection_unset)),
Some(c) if c.trim().is_empty() => diags.push(Diagnostic::error(s.diag_collection_unset)),
Some(_) => {}
}
check_collection_directives(flow, ctx, &mut diags);
if let Some(out) = flow.header.output() {
let out = out.trim();
if !out.is_empty()
&& !super::writer::OUTPUT_EXTENSIONS
.iter()
.any(|e| out.eq_ignore_ascii_case(e))
{
diags.push(Diagnostic::error(fill(
s.diag_output_unsupported,
&[out, &super::writer::OUTPUT_EXTENSIONS.join(", ")],
)));
}
}
if let Some(spec) = flow.header.columns() {
let cols = super::model::parse_columns(spec);
let mut seen: Vec<&str> = Vec::new();
let mut reported: Vec<&str> = Vec::new();
for header in cols.iter().map(|c| c.header.as_str()) {
if seen.contains(&header) {
if !reported.contains(&header) {
diags.push(Diagnostic::error(fill(s.diag_duplicate_column, &[&header])));
reported.push(header);
}
} else {
seen.push(header);
}
}
}
let labels = super::labels::LabelMap::parse(&flow.header.labels());
for line in labels.malformed() {
diags.push(Diagnostic::warning(fill(s.diag_labels_malformed, &[line])));
}
for (synonym, kept, asked) in labels.conflicts() {
diags.push(Diagnostic::warning(fill(
s.diag_labels_conflict,
&[synonym, kept, asked, kept],
)));
}
let images = flow.column_images();
for (header, template) in flow.column_truths() {
if template.trim().is_empty() {
diags.push(Diagnostic::warning(fill(s.diag_truth_empty, &[&header])));
}
if images.contains_key(&header) {
diags.push(Diagnostic::warning(fill(s.diag_truth_on_image, &[&header])));
}
}
if let Some(env) = flow.header.environment() {
let env = env.trim();
if env.is_empty() {
diags.push(Diagnostic::error(s.diag_environment_unset));
} else if let Some(loaded) = ctx.env_names
&& !loaded.iter().any(|e| e == env)
{
diags.push(Diagnostic::error(fill(
s.diag_environment_not_loaded,
&[env],
)));
}
}
if flow.header.baseline().is_some_and(|b| !b.trim().is_empty())
&& super::compare::comparison_roles(flow).is_some()
{
diags.push(Diagnostic::warning(s.diag_baseline_ignored));
} else if let Some(rel) = flow
.header
.baseline()
.map(str::trim)
.filter(|b| !b.is_empty())
&& let Some(root) = ctx.root
{
let path = super::producers::resolve_path(Some(root), rel);
if !path.exists() {
diags.push(Diagnostic::warning(fill(
s.diag_baseline_missing,
&[rel, &path.display().to_string()],
)));
}
}
if !flow.nodes.is_empty() && !emits_a_column(&flow.nodes) {
diags.push(Diagnostic::warning(s.diag_no_columns));
}
if ctx.request_titles.is_none() {
diags.push(Diagnostic::warning(s.diag_collection_not_loaded));
}
check_params(flow, ctx, &mut diags);
check_env_refs(flow, ctx, &mut diags);
let mut scopes: Vec<HashMap<String, Producer>> = vec![HashMap::new()];
walk(&flow.nodes, ctx, &mut scopes, &mut diags);
if ctx.request_entries.is_some() && ctx.base_var_names.is_some() {
let mut defined = initial_defined_vars(ctx);
check_var_availability(&flow.nodes, ctx, &mut defined, &mut diags);
}
diags
}
fn is_prelude_node(node: &FlowNode) -> bool {
matches!(
node,
FlowNode::Param(_) | FlowNode::Assign { .. } | FlowNode::Comment(_)
)
}
fn check_params(flow: &ReportFlow, ctx: &Context, diags: &mut Vec<Diagnostic>) {
let s = ctx.strings;
let prelude_end = flow
.nodes
.iter()
.position(|n| !is_prelude_node(n))
.unwrap_or(flow.nodes.len());
for p in nested_params(&flow.nodes[prelude_end..]) {
diags.push(Diagnostic::error(fill(
s.diag_param_not_in_prelude,
&[&p.name],
)));
}
let mut seen: HashSet<&str> = HashSet::new();
let mut prompts: HashSet<String> = HashSet::new();
for p in flow.params() {
if !seen.insert(&p.name) {
diags.push(Diagnostic::error(fill(s.diag_param_duplicate, &[&p.name])));
}
let prompt = p.prompt();
if !prompts.insert(prompt.to_lowercase()) {
diags.push(Diagnostic::warning(fill(
s.diag_param_prompt_clash,
&[&prompt],
)));
}
check_param_default(p, ctx, diags);
}
}
fn nested_params<'a>(nodes: &'a [FlowNode]) -> Vec<&'a super::flow::ParamDecl> {
let mut out = Vec::new();
for node in nodes {
match node {
FlowNode::Param(p) => out.push(p),
FlowNode::ForEach { body, .. } | FlowNode::ForEnvs { body, .. } => {
out.extend(nested_params(body));
}
_ => {}
}
}
out
}
fn check_env_refs(flow: &ReportFlow, ctx: &Context, diags: &mut Vec<Diagnostic>) {
let s = ctx.strings;
let declared = flow.params();
let defaults = super::params::effective(&declared, &Default::default());
for name in env_ref_names(&flow.nodes) {
for key in crate::environment::referenced_keys(name) {
if !declared.iter().any(|p| p.name == key) {
diags.push(Diagnostic::error(fill(
s.diag_env_ref_not_a_param,
&[&key, name],
)));
}
}
let resolved = crate::environment::substitute(name, &defaults);
if resolved.contains("{{") {
continue;
}
if let Some(loaded) = ctx.env_names
&& !loaded.iter().any(|e| *e == resolved)
{
diags.push(Diagnostic::warning(fill(
s.diag_env_ref_default_not_loaded,
&[name, &resolved],
)));
}
}
}
fn env_ref_names<'a>(nodes: &'a [FlowNode]) -> Vec<&'a String> {
let mut out = Vec::new();
for node in nodes {
match node {
FlowNode::ForEnvs { clause, body, .. } => {
let names: Vec<&String> = match clause {
EnvClause::Plain(names) => names.iter().collect(),
EnvClause::Roles {
baseline,
comparisons,
..
} => baseline
.iter()
.chain(comparisons.iter())
.filter_map(|r| match r {
RoleRef::Env(n) => Some(n),
RoleRef::File(_) => None,
})
.collect(),
};
out.extend(names.into_iter().filter(|n| n.contains("{{")));
out.extend(env_ref_names(body));
}
FlowNode::ForEach { body, .. } => out.extend(env_ref_names(body)),
_ => {}
}
}
out
}
fn check_param_default(p: &super::flow::ParamDecl, ctx: &Context, diags: &mut Vec<Diagnostic>) {
let s = ctx.strings;
let default = p.default.as_deref().map(str::trim);
match &p.kind {
ParamKind::Choice(options) => {
if options.is_empty() {
diags.push(Diagnostic::error(fill(s.diag_param_no_choices, &[&p.name])));
} else if let Some(d) = default.filter(|d| !d.is_empty())
&& !options.iter().any(|o| o == d)
{
diags.push(Diagnostic::error(fill(
s.diag_param_bad_choice,
&[&p.name, d, &options.join(", ")],
)));
}
}
ParamKind::Number => {
if let Some(d) = default.filter(|d| !d.is_empty())
&& !d.contains("{{")
&& d.parse::<f64>().is_err()
{
diags.push(Diagnostic::error(fill(
s.diag_param_not_a_number,
&[&p.name, d],
)));
}
}
ParamKind::Env => {
if let Some(d) = default.filter(|d| !d.is_empty())
&& !d.contains("{{")
&& let Some(loaded) = ctx.env_names
&& !loaded.iter().any(|e| e == d)
{
diags.push(Diagnostic::warning(fill(
s.diag_param_env_not_loaded,
&[&p.name, d],
)));
}
}
ParamKind::Text | ParamKind::Folder | ParamKind::File => {}
}
}
fn walk(
nodes: &[FlowNode],
ctx: &Context,
scopes: &mut Vec<HashMap<String, Producer>>,
diags: &mut Vec<Diagnostic>,
) {
for node in nodes {
match node {
FlowNode::Comment(_) => {}
FlowNode::Assign { .. } => {}
FlowNode::Param(_) => {}
FlowNode::ListDecl { name, producer } => {
check_producer(producer, ctx, scopes, diags);
if scopes.iter().any(|s| s.contains_key(name)) {
diags.push(Diagnostic::warning(fill(
ctx.strings.diag_list_shadowed,
&[name],
)));
}
scopes
.last_mut()
.unwrap()
.insert(name.clone(), producer.clone());
}
FlowNode::Request { name } => check_request_name(name, ctx, diags),
FlowNode::Report(stmt) => check_report(stmt, ctx, diags),
FlowNode::ForEach {
pattern,
producer,
body,
..
} => {
check_producer(producer, ctx, scopes, diags);
check_arity(pattern, producer, scopes, ctx.strings, diags);
scopes.push(HashMap::new());
walk(body, ctx, scopes, diags);
scopes.pop();
}
FlowNode::ForEnvs { clause, body, .. } => {
check_env_clause(clause, ctx, diags);
scopes.push(HashMap::new());
walk(body, ctx, scopes, diags);
scopes.pop();
}
}
}
}
fn check_report(stmt: &ReportStmt, ctx: &Context, diags: &mut Vec<Diagnostic>) {
if let ReportStmt::Request {
name,
show,
hide,
with,
..
} = stmt
{
check_request_name(name, ctx, diags);
check_show_hide_overlap(show, hide, ctx.strings, diags);
check_show_fields(name, show, with, ctx, diags);
check_hide_fields(name, hide, with, ctx, diags);
}
}
fn check_show_fields(
name: &str,
show: &[ShowField],
with: &[super::flow::WithItem],
ctx: &Context,
diags: &mut Vec<Diagnostic>,
) {
if show.is_empty() {
return;
}
let Some(report_fields) = declared_report_fields(name, ctx) else {
return;
};
let report_fields = &report_fields;
let with_fields: Vec<&str> = with
.iter()
.filter_map(|w| match w {
super::flow::WithItem::Field { name, .. } => Some(name.as_str()),
_ => None,
})
.collect();
for field in show {
let field = field.name();
let known = super::run::INTRINSIC_FIELDS.contains(&field)
|| with_fields.contains(&field)
|| report_fields.iter().any(|f| f == field);
if !known {
diags.push(Diagnostic::warning(fill(
ctx.strings.diag_show_unknown,
&[field, name],
)));
}
}
}
fn check_show_hide_overlap(
show: &[ShowField],
hide: &[String],
s: &Strings,
diags: &mut Vec<Diagnostic>,
) {
for field in show {
let field = field.name();
if hide.iter().any(|h| h == field) {
diags.push(Diagnostic::error(fill(s.diag_show_hide_conflict, &[field])));
}
}
}
fn check_hide_fields(
name: &str,
hide: &[String],
with: &[super::flow::WithItem],
ctx: &Context,
diags: &mut Vec<Diagnostic>,
) {
if hide.is_empty() {
return;
}
let Some(report_fields) = declared_report_fields(name, ctx) else {
return;
};
let resolved = Some((String::new(), report_fields));
let Some((_, report_fields)) = resolved else {
return;
};
let with_fields: Vec<&str> = with
.iter()
.filter_map(|w| match w {
super::flow::WithItem::Field { name, .. } => Some(name.as_str()),
_ => None,
})
.collect();
for field in hide {
let known = super::run::INTRINSIC_FIELDS.contains(&field.as_str())
|| with_fields.contains(&field.as_str())
|| report_fields.iter().any(|f| f == field);
if !known {
diags.push(Diagnostic::warning(fill(
ctx.strings.diag_hide_unknown,
&[field, name],
)));
}
}
}
fn split_helper<'a>(
name: &'a str,
ctx: &'a Context,
) -> Option<(&'a super::run::HelperCollection, &'a str)> {
let (alias, rest) = name.split_once('/')?;
ctx.helpers
.iter()
.find(|h| h.alias == alias)
.map(|h| (h, rest))
}
fn check_collection_directives(flow: &ReportFlow, ctx: &Context, diags: &mut Vec<Diagnostic>) {
let s = ctx.strings;
let refs = flow.header.collections();
let mut seen: Vec<&str> = Vec::new();
for (i, c) in refs.iter().enumerate() {
if i == 0 {
if c.alias.is_some() {
diags.push(Diagnostic::error(s.diag_collection_primary_alias));
}
continue;
}
let Some(alias) = c.alias else {
diags.push(Diagnostic::error(fill(
s.diag_collection_alias_missing,
&[c.reference],
)));
continue;
};
if !crate::report::parser::is_ident(alias) {
diags.push(Diagnostic::error(fill(
s.diag_collection_alias_invalid,
&[alias],
)));
continue;
}
if seen.contains(&alias) {
diags.push(Diagnostic::error(fill(
s.diag_collection_alias_duplicate,
&[alias],
)));
continue;
}
seen.push(alias);
if let Some(titles) = ctx.request_titles
&& titles
.iter()
.any(|t| t.split('/').next() == Some(alias) && t.contains('/'))
{
diags.push(Diagnostic::error(fill(
s.diag_collection_alias_shadows_folder,
&[alias],
)));
}
}
for (reference, reason) in ctx.helper_errors {
diags.push(Diagnostic::error(fill(
s.diag_collection_helper_unreadable,
&[reference, reason],
)));
}
}
fn check_request_name(name: &str, ctx: &Context, diags: &mut Vec<Diagnostic>) {
if let Some((helper, rest)) = split_helper(name, ctx) {
let titles: Vec<String> = helper.entries.iter().map(|e| e.title.clone()).collect();
check_name_in(rest, &titles, name, ctx, diags);
return;
}
let Some(titles) = ctx.request_titles else {
return;
};
check_name_in(name, titles, name, ctx, diags);
}
fn check_name_in(
name: &str,
titles: &[String],
shown: &str,
ctx: &Context,
diags: &mut Vec<Diagnostic>,
) {
let exact = titles.iter().filter(|t| t.as_str() == name).count();
if exact == 1 {
return;
}
if exact > 1 {
diags.push(Diagnostic::error(fill(
ctx.strings.diag_request_ambiguous_title,
&[shown, &exact.to_string()],
)));
return;
}
let leaves: Vec<&String> = titles
.iter()
.filter(|t| t.rsplit('/').next() == Some(name))
.collect();
match leaves.len() {
1 => {}
0 => diags.push(Diagnostic::error(fill(
ctx.strings.diag_request_not_found,
&[shown],
))),
n => diags.push(Diagnostic::error(fill(
ctx.strings.diag_request_ambiguous_leaf,
&[shown, &n.to_string()],
))),
}
}
fn check_env_clause(clause: &EnvClause, ctx: &Context, diags: &mut Vec<Diagnostic>) {
let names: Vec<&String> = match clause {
EnvClause::Plain(names) => {
if names.is_empty() {
diags.push(Diagnostic::error(ctx.strings.diag_envs_empty));
}
names.iter().collect()
}
EnvClause::Roles {
baseline,
comparisons,
..
} => {
if baseline.len() > 1 {
diags.push(Diagnostic::error(ctx.strings.diag_baseline_multiple));
}
if comparisons.is_empty() {
diags.push(Diagnostic::error(ctx.strings.diag_comparison_missing));
}
if let Some(root) = ctx.root {
for rel in baseline
.iter()
.chain(comparisons.iter())
.filter_map(|r| match r {
RoleRef::File(p) => Some(p),
RoleRef::Env(_) => None,
})
{
let path = super::producers::resolve_path(Some(root), rel);
if !path.exists() {
diags.push(Diagnostic::warning(fill(
ctx.strings.diag_baseline_missing,
&[rel, &path.display().to_string()],
)));
}
}
}
baseline
.iter()
.chain(comparisons.iter())
.filter_map(|r| match r {
RoleRef::Env(n) => Some(n),
RoleRef::File(_) => None,
})
.collect()
}
};
if let Some(loaded) = ctx.env_names {
for n in names {
if n.contains("{{") {
continue;
}
if !loaded.iter().any(|e| e == n) {
diags.push(Diagnostic::error(fill(
ctx.strings.diag_environment_not_loaded,
&[n],
)));
}
}
}
}
fn producer_arity(p: &Producer, scopes: &[HashMap<String, Producer>]) -> Option<usize> {
match p {
Producer::Files { .. } | Producer::Folders { .. } => Some(1),
Producer::Zip(ps) => Some(ps.len()),
Producer::Concat(ps) => {
let arities: Vec<usize> = ps
.iter()
.filter_map(|p| producer_arity(p, scopes))
.collect();
if arities.len() != ps.len() {
return None;
}
match arities.first() {
Some(&first) if arities.iter().all(|&a| a == first) => Some(first),
_ => None,
}
}
Producer::Tuples { .. } => None,
Producer::List(elems) => {
let arities: Vec<usize> = elems
.iter()
.map(|e| match e {
super::flow::Element::Scalar(_) => 1,
super::flow::Element::Tuple(items) => items.len(),
})
.collect();
match arities.first() {
None => Some(1),
Some(&first) if arities.iter().all(|&a| a == first) => Some(first),
_ => None,
}
}
Producer::Named(name) => scopes
.iter()
.rev()
.find_map(|s| s.get(name))
.and_then(|inner| producer_arity(inner, scopes)),
}
}
fn check_arity(
pattern: &Pattern,
producer: &Producer,
scopes: &[HashMap<String, Producer>],
s: &Strings,
diags: &mut Vec<Diagnostic>,
) {
let Some(arity) = producer_arity(producer, scopes) else {
return; };
let binders = pattern.binders.len();
if pattern.rest {
if binders > arity {
diags.push(Diagnostic::error(fill(
s.diag_pattern_before_rest,
&[&binders.to_string(), &arity.to_string()],
)));
}
} else if binders != arity {
diags.push(Diagnostic::error(fill(
s.diag_pattern_arity,
&[&binders.to_string(), &arity.to_string()],
)));
}
}
fn check_producer(
producer: &Producer,
ctx: &Context,
scopes: &[HashMap<String, Producer>],
diags: &mut Vec<Diagnostic>,
) {
if let Producer::Named(name) = producer
&& !scopes.iter().rev().any(|s| s.contains_key(name))
{
diags.push(Diagnostic::error(fill(
ctx.strings.diag_unknown_list,
&[name, name],
)));
}
if let Producer::List(elems) = producer {
let arities: Vec<usize> = elems
.iter()
.map(|e| match e {
super::flow::Element::Scalar(_) => 1,
super::flow::Element::Tuple(items) => items.len(),
})
.collect();
if let Some(&first) = arities.first()
&& !arities.iter().all(|&a| a == first)
{
diags.push(Diagnostic::error(ctx.strings.diag_list_arity));
}
}
if let Producer::Zip(ps) = producer {
for p in ps {
check_producer(p, ctx, scopes, diags);
}
}
if let Producer::Concat(ps) = producer {
for p in ps {
check_producer(p, ctx, scopes, diags);
}
let arities: Vec<usize> = ps
.iter()
.filter_map(|p| producer_arity(p, scopes))
.collect();
if let Some(&first) = arities.first()
&& arities.len() == ps.len()
&& !arities.iter().all(|&a| a == first)
{
diags.push(Diagnostic::error(ctx.strings.diag_concat_arity));
}
}
}
fn initial_defined_vars(ctx: &Context) -> HashSet<String> {
let mut defined = HashSet::new();
if let Some(names) = ctx.base_var_names {
defined.extend(names.iter().cloned());
}
for name in [
"PRELUDE_NO_MATCH_MARKER",
"PRELUDE_RESPONSE_FORMAT",
"PRELUDE_MAX_PARALLEL",
] {
defined.insert(name.to_string());
}
defined
}
fn declared_report_fields(name: &str, ctx: &Context) -> Option<Vec<String>> {
if let Some((helper, rest)) = split_helper(name, ctx) {
return resolve_entry_by_name(&helper.entries, rest)
.map(|e| e.reports.iter().map(|(n, _)| n.clone()).collect());
}
let entries = ctx.request_fields?;
let by_exact = entries.iter().find(|(t, _)| t == name);
by_exact
.or_else(|| {
let mut leaves = entries
.iter()
.filter(|(t, _)| t.rsplit('/').next() == Some(name));
match (leaves.next(), leaves.next()) {
(Some(hit), None) => Some(hit),
_ => None,
}
})
.map(|(_, f)| f.clone())
}
fn resolve_entry_qualified<'a>(
name: &'a str,
ctx: &'a Context,
) -> Option<&'a crate::hurl::HurlEntry> {
if let Some((helper, rest)) = split_helper(name, ctx) {
return resolve_entry_by_name(&helper.entries, rest);
}
resolve_entry_by_name(ctx.request_entries?, name)
}
fn resolve_entry_by_name<'a>(
entries: &'a [crate::hurl::HurlEntry],
name: &str,
) -> Option<&'a crate::hurl::HurlEntry> {
let exact: Vec<_> = entries.iter().filter(|e| e.title == name).collect();
if exact.len() == 1 {
return Some(exact[0]);
}
if exact.len() > 1 {
return None; }
let leaves: Vec<_> = entries
.iter()
.filter(|e| e.title.rsplit('/').next() == Some(name))
.collect();
if leaves.len() == 1 {
Some(leaves[0])
} else {
None
}
}
fn producer_static_named_fields(producer: &Producer) -> Vec<String> {
match producer {
Producer::Folders { roles, .. } => roles.iter().map(|r| r.name.clone()).collect(),
Producer::Zip(ps) | Producer::Concat(ps) => {
ps.iter().flat_map(producer_static_named_fields).collect()
}
_ => Vec::new(),
}
}
fn warn_if_vars_undefined(
name: &str,
ctx: &Context,
defined: &HashSet<String>,
diags: &mut Vec<Diagnostic>,
) {
if ctx.request_entries.is_none() {
return;
}
let Some(entry) = resolve_entry_qualified(name, ctx) else {
return; };
let refs = crate::request::entry_referenced_keys(entry);
let mut refs: Vec<&String> = refs.iter().collect();
refs.sort();
for var in refs {
if !defined.contains(var.as_str()) {
diags.push(Diagnostic::warning(fill(
ctx.strings.diag_var_maybe_undefined,
&[name, &format!("{{{{{var}}}}}")],
)));
}
}
}
fn add_entry_captures(name: &str, ctx: &Context, defined: &mut HashSet<String>) {
if ctx.request_entries.is_none() {
return;
}
let Some(entry) = resolve_entry_qualified(name, ctx) else {
return;
};
for (cap_name, _) in &entry.captures {
defined.insert(cap_name.clone());
}
}
fn check_var_availability(
nodes: &[FlowNode],
ctx: &Context,
defined: &mut HashSet<String>,
diags: &mut Vec<Diagnostic>,
) {
for node in nodes {
match node {
FlowNode::Comment(_) => {}
FlowNode::Assign { key, .. } => {
defined.insert(key.clone());
}
FlowNode::Param(p) => {
defined.insert(p.name.clone());
}
FlowNode::ListDecl { .. } => {}
FlowNode::Request { name } => {
warn_if_vars_undefined(name, ctx, defined, diags);
add_entry_captures(name, ctx, defined);
}
FlowNode::Report(stmt) => {
if let ReportStmt::Request { name, .. } = stmt {
warn_if_vars_undefined(name, ctx, defined, diags);
add_entry_captures(name, ctx, defined);
}
}
FlowNode::ForEach {
pattern,
producer,
body,
..
} => {
let mut inner = defined.clone();
for binder_name in pattern.named() {
inner.insert(binder_name.to_string());
}
for fname in producer_static_named_fields(producer) {
inner.insert(fname);
}
check_var_availability(body, ctx, &mut inner, diags);
}
FlowNode::ForEnvs { var, body, .. } => {
let mut inner = defined.clone();
inner.insert(var.clone());
if let Some(env_vars) = ctx.all_env_var_names {
inner.extend(env_vars.iter().cloned());
check_var_availability(body, ctx, &mut inner, diags);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::parser::parse_flow;
fn diags_for(src: &str, titles: Option<&[String]>, envs: Option<&[String]>) -> Vec<Diagnostic> {
let flow = parse_flow(src).expect("test source should parse");
let ctx = Context {
request_titles: titles,
env_names: envs,
..Default::default()
};
validate(&flow, &ctx)
}
#[test]
fn an_environment_named_by_a_parameter_is_not_an_unloaded_environment() {
let src = "# collection: c\nPARAM COMPARE_ENV = \"api_dev\"\nPARAM BASELINE_ENV = \"api_staging\"\n\
PARALLEL(2) FOR TARGET IN ENVS BASELINE(\"{{BASELINE_ENV}}\") SHOW(TimeWait), COMPARISON(\"{{COMPARE_ENV}}\")\n\
REPORT REQUEST r\nEND\n";
let envs = ["api_dev".to_string(), "api_staging".to_string()];
let msgs: Vec<String> = diags_for(src, None, Some(&envs))
.into_iter()
.map(|d| d.message)
.collect();
assert!(
!msgs.iter().any(|m| m.contains("is not loaded")),
"the parameters resolve to loaded environments: {msgs:?}"
);
}
#[test]
fn a_parameter_pointing_at_an_unloaded_environment_is_only_a_warning() {
let src = "# collection: c\nPARAM TARGET_ENV = \"gone\"\n\
FOR T IN ENVS \"{{TARGET_ENV}}\"\n REPORT REQUEST r\nEND\n";
let envs = ["here".to_string()];
let diags = diags_for(src, None, Some(&envs));
assert!(
!diags
.iter()
.any(|d| d.severity == Severity::Error && d.message.contains("gone")),
"not an error: {diags:?}"
);
assert!(
diags
.iter()
.any(|d| d.severity == Severity::Warning && d.message.contains("gone")),
"but it does say so: {diags:?}"
);
}
#[test]
fn an_environment_reference_that_isnt_a_parameter_is_refused() {
let errs = errors_for(
"# collection: c\nTARGET_ENV = \"staging\"\n\
FOR T IN ENVS \"{{TARGET_ENV}}\"\n REPORT REQUEST r\nEND\n",
);
assert!(
errs.iter().any(|e| e.contains("TARGET_ENV")),
"says which name and that it needs declaring: {errs:?}"
);
}
#[test]
fn an_environment_named_by_an_unanswered_parameter_is_left_alone() {
let src = "# collection: c\nPARAM ENV TARGET_ENV\n\
FOR T IN ENVS \"{{TARGET_ENV}}\"\n REPORT REQUEST r\nEND\n";
let envs = ["here".to_string()];
let diags = diags_for(src, None, Some(&envs));
assert!(
!diags.iter().any(|d| d.message.contains("TARGET_ENV")),
"nothing to say yet: {diags:?}"
);
}
fn errors_for(src: &str) -> Vec<String> {
diags_for(src, None, None)
.into_iter()
.filter(|d| d.severity == Severity::Error)
.map(|d| d.message)
.collect()
}
#[test]
fn a_parameter_past_the_prelude_is_refused() {
let ok = errors_for(
"# collection: c\n# a comment\nPRELUDE_MAX_PARALLEL=2\nPARAM ENV TARGET = \"s\"\nREPORT REQUEST r\n",
);
assert!(ok.is_empty(), "prelude parameter should be fine: {ok:?}");
let late = errors_for("# collection: c\nREPORT REQUEST r\nPARAM ENV TARGET = \"s\"\n");
assert_eq!(late.len(), 1, "{late:?}");
assert!(late[0].contains("TARGET"), "{late:?}");
let nested = errors_for(
"# collection: c\nFOR F IN FILES \"x\"\n PARAM TEXT NOTE = \"n\"\n REPORT REQUEST r\nEND\n",
);
assert_eq!(nested.len(), 1, "{nested:?}");
assert!(nested[0].contains("NOTE"), "{nested:?}");
}
#[test]
fn a_parameter_must_agree_with_its_own_declared_type() {
let choice = errors_for(
"# collection: c\nPARAM CHOICE(\"a\", \"b\") PICK = \"c\"\nREPORT REQUEST r\n",
);
assert_eq!(choice.len(), 1, "{choice:?}");
assert!(
choice[0].contains("PICK") && choice[0].contains("a, b"),
"{choice:?}"
);
let empty = errors_for("# collection: c\nPARAM CHOICE() PICK\nREPORT REQUEST r\n");
assert_eq!(empty.len(), 1, "{empty:?}");
let number =
errors_for("# collection: c\nPARAM NUMBER TRIES = \"many\"\nREPORT REQUEST r\n");
assert_eq!(number.len(), 1, "{number:?}");
assert!(number[0].contains("TRIES"), "{number:?}");
let deferred =
errors_for("# collection: c\nPARAM NUMBER TRIES = \"{{RETRIES}}\"\nREPORT REQUEST r\n");
assert!(deferred.is_empty(), "{deferred:?}");
let dupes = errors_for(
"# collection: c\nPARAM TEXT A = \"1\"\nPARAM NUMBER A = \"2\"\nREPORT REQUEST r\n",
);
assert_eq!(dupes.len(), 1, "{dupes:?}");
assert!(dupes[0].contains('A'), "{dupes:?}");
}
#[test]
fn two_parameters_asking_the_same_question_are_flagged() {
let diags = diags_for(
"# collection: c\nPARAM TEXT TICKET_REF\nPARAM TEXT ticket_ref LABEL \"Ticket ref\"\nREPORT REQUEST r\n",
None,
None,
);
assert!(
!diags.iter().any(|d| d.severity == Severity::Error),
"distinct names, so the report still runs: {diags:?}"
);
assert!(
diags
.iter()
.any(|d| d.severity == Severity::Warning && d.message.contains("Ticket ref")),
"{diags:?}"
);
}
#[test]
fn an_unloaded_environment_default_is_only_a_warning() {
let loaded = ["staging".to_string()];
let diags = diags_for(
"# collection: c\nPARAM ENV TARGET = \"prod\"\nREPORT REQUEST r\n",
None,
Some(&loaded),
);
assert!(
!diags.iter().any(|d| d.severity == Severity::Error),
"{diags:?}"
);
assert!(
diags
.iter()
.any(|d| d.severity == Severity::Warning && d.message.contains("prod")),
"{diags:?}"
);
}
#[test]
fn variable_warnings_come_out_in_a_stable_order() {
use crate::hurl::HurlEntry;
let entry = HurlEntry {
title: "req".into(),
method: "GET".into(),
url: "http://x/{{alpha}}/{{bravo}}?q={{charlie}}".into(),
body: Some("{\"d\":\"{{delta}}\",\"e\":\"{{echo}}\",\"f\":\"{{foxtrot}}\"}".into()),
..Default::default()
};
let entries = [entry];
let titles = vec!["req".to_string()];
let flow = parse_flow("# collection: c\nREQUEST req\n").expect("parses");
let run = || {
let ctx = Context {
request_titles: Some(&titles),
request_entries: Some(&entries),
base_var_names: Some(&[]),
..Default::default()
};
validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.filter(|m| m.contains("{{"))
.collect::<Vec<_>>()
};
let first = run();
assert_eq!(first.len(), 6, "one warning per variable: {first:?}");
let order: Vec<&str> = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"].into();
for (msg, name) in first.iter().zip(&order) {
assert!(msg.contains(name), "expected {name} in {msg}");
}
for i in 0..50 {
assert_eq!(first, run(), "run {i} produced a different order");
}
}
fn errors(src: &str, titles: Option<&[String]>, envs: Option<&[String]>) -> Vec<String> {
diags_for(src, titles, envs)
.into_iter()
.filter(|d| d.severity == Severity::Error)
.map(|d| d.message)
.collect()
}
fn has_err(
src: &str,
titles: Option<&[String]>,
envs: Option<&[String]>,
needle: &str,
) -> bool {
errors(src, titles, envs)
.iter()
.any(|m| m.to_lowercase().contains(&needle.to_lowercase()))
}
fn titles() -> Vec<String> {
vec![
"Oauth".into(),
"CreateSession".into(),
"upload/process_file".into(),
"finalise_session".into(),
]
}
fn warnings_with_fields(src: &str, fields: &[(&str, &[&str])]) -> Vec<String> {
let flow = parse_flow(src).expect("test source should parse");
let titles: Vec<String> = fields.iter().map(|(t, _)| t.to_string()).collect();
let field_map: Vec<(String, Vec<String>)> = fields
.iter()
.map(|(t, fs)| (t.to_string(), fs.iter().map(|s| s.to_string()).collect()))
.collect();
let ctx = Context {
request_titles: Some(&titles),
request_fields: Some(&field_map),
..Default::default()
};
validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect()
}
#[test]
fn duplicate_column_headers_are_rejected() {
let titles = titles();
let errs = errors(
"# collection: c\n# columns: FILE AS X, Oauth.status AS X, Oauth AS X\nREPORT REQUEST Oauth\n",
Some(&titles),
None,
);
let dup: Vec<_> = errs.iter().filter(|m| m.contains("Two columns")).collect();
assert_eq!(dup.len(), 1, "one duplicate-header error: {errs:?}");
assert!(dup[0].contains('X'));
assert!(!has_err(
"# collection: c\n# columns: FILE AS Name, Oauth.status AS Status\nREPORT REQUEST Oauth\n",
Some(&titles),
None,
"Two columns",
));
}
#[test]
fn show_unknown_field_warns_but_known_fields_do_not() {
let warns = warnings_with_fields(
"REPORT REQUEST process SHOW(Response, Time, status, bogus)\n",
&[("process", &["status", "overall"])],
);
assert_eq!(warns.len(), 1, "only 'bogus' should warn: {warns:?}");
assert!(warns[0].contains("bogus"));
}
#[test]
fn show_with_field_counts_as_known() {
let warns = warnings_with_fields(
"REPORT REQUEST process SHOW(extra) WITH\n extra: jsonpath \"$.x\"\nEND\n",
&[("process", &[])],
);
assert!(
warns.iter().all(|w| !w.contains("extra")),
"WITH field should not warn: {warns:?}"
);
}
#[test]
fn show_is_not_validated_without_a_bound_collection() {
let flow = parse_flow("REPORT REQUEST process SHOW(bogus)\n").unwrap();
let ctx = Context::default();
let warns: Vec<_> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.filter(|d| d.message.contains("SHOW"))
.collect();
assert!(warns.is_empty(), "unbound flow shouldn't warn: {warns:?}");
}
#[test]
fn missing_collection_is_an_error() {
assert!(has_err(
"REQUEST Oauth\n",
None,
None,
"No collection chosen"
));
}
#[test]
fn empty_collection_directive_is_an_error() {
assert!(has_err(
"# collection:\nREQUEST Oauth\n",
None,
None,
"No collection chosen"
));
}
#[test]
fn valid_header_with_bound_collection_has_no_errors() {
let t = titles();
let errs = errors("# collection: ./c.hurl\nREQUEST Oauth\n", Some(&t), None);
assert!(errs.is_empty(), "unexpected errors: {errs:?}");
}
#[test]
fn unsupported_output_format_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\n# output: docx\nREQUEST Oauth\n",
Some(&t),
None,
"unsupported output format"
));
}
#[test]
fn csv_output_is_accepted() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\n# output: csv\nREQUEST Oauth\n",
Some(&t),
None,
"unsupported"
));
}
#[test]
fn xlsx_json_html_outputs_are_accepted() {
let t = titles();
for fmt in ["xlsx", "json", "html"] {
assert!(
!has_err(
&format!("# collection: ./c.hurl\n# output: {fmt}\nREQUEST Oauth\n"),
Some(&t),
None,
"unsupported"
),
"format {fmt} should be accepted"
);
}
}
#[test]
fn ground_truth_mistakes_warn_without_blocking_the_run() {
let t = titles();
let warn = |src: &str| -> Vec<String> {
diags_for(src, Some(&t), None)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect()
};
let said = |ws: &[String], needle: &str| {
ws.iter()
.any(|m| m.to_lowercase().contains(&needle.to_lowercase()))
};
let ws = warn("# collection: ./c.hurl\n# labels: nonsense\nREQUEST Oauth\n");
assert!(said(&ws, "declares nothing"), "{ws:?}");
let ws = warn(
"# collection: ./c.hurl\n# labels: Pass = ok, maybe\n# labels: Fail = maybe\nREQUEST Oauth\n",
);
assert!(said(&ws, "claimed by both"), "{ws:?}");
let ws = warn("# collection: ./c.hurl\nREPORT \"a\" AS V TRUTH \"\"\n");
assert!(said(&ws, "empty ground truth"), "{ws:?}");
let ws =
warn("# collection: ./c.hurl\nREPORT \"a\" AS V IMAGE(HEIGHT 40) TRUTH \"{{ e }}\"\n");
assert!(said(&ws, "shown as a picture"), "{ws:?}");
let src = "# collection: ./c.hurl\n# labels: Pass = ok, real\nREPORT \"a\" AS V TRUTH \"{{ e }}\"\n";
assert!(errors(src, Some(&t), None).is_empty());
let ws = warn(src);
assert!(
!said(&ws, "ground truth") && !said(&ws, "label"),
"a correct ground truth is silent: {ws:?}"
);
}
#[test]
fn environment_header_naming_an_unloaded_env_is_an_error() {
let t = titles();
let envs = ["au".to_string()];
assert!(has_err(
"# collection: ./c.hurl\n# environment: staging\nREQUEST Oauth\n",
Some(&t),
Some(&envs),
"environment 'staging' is not loaded"
));
}
#[test]
fn environment_header_naming_a_loaded_env_is_accepted() {
let t = titles();
let envs = ["au".to_string(), "staging".to_string()];
assert!(!has_err(
"# collection: ./c.hurl\n# environment: staging\nREQUEST Oauth\n",
Some(&t),
Some(&envs),
"is not loaded"
));
}
#[test]
fn empty_environment_header_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\n# environment:\nREQUEST Oauth\n",
Some(&t),
None,
"environment setting is empty"
));
}
#[test]
fn environment_header_is_not_checked_until_envs_are_known() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\n# environment: staging\nREQUEST Oauth\n",
Some(&t),
None,
"is not loaded"
));
}
#[test]
fn unbound_collection_warns_but_does_not_error_on_names() {
let diags = diags_for("# collection: ./c.hurl\nREQUEST Whatever\n", None, None);
assert!(
diags.iter().any(|d| d.severity == Severity::Warning
&& d.message.contains("collection isn't loaded"))
);
assert!(!diags.iter().any(|d| d.message.contains("not found")));
}
#[test]
fn request_name_resolves_by_exact_full_title() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\nREQUEST upload/process_file\n",
Some(&t),
None,
"not found"
));
}
#[test]
fn request_name_resolves_by_unique_leaf() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\nREPORT REQUEST process_file\n",
Some(&t),
None,
"not found"
));
}
#[test]
fn unknown_request_name_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nREQUEST nope\n",
Some(&t),
None,
"not found"
));
}
#[test]
fn ambiguous_leaf_is_an_error() {
let t = vec!["a/dup".to_string(), "b/dup".to_string()];
assert!(has_err(
"# collection: ./c.hurl\nREQUEST dup\n",
Some(&t),
None,
"ambiguous"
));
}
#[test]
fn envs_plain_empty_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nFOR T IN ENVS BASELINE(\"prod\")\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"at least one COMPARISON"
));
}
#[test]
fn envs_multiple_baseline_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nFOR T IN ENVS BASELINE(\"a\", \"b\"), COMPARISON(\"c\")\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"at most one BASELINE"
));
}
#[test]
fn baseline_directive_with_envs_comparison_warns_it_is_ignored() {
let t = titles();
let warns: Vec<String> = diags_for(
"# collection: ./c.hurl\n# baseline: prev.baseline\nFOR T IN ENVS BASELINE(\"a\"), COMPARISON(\"b\")\n REQUEST Oauth\nEND\n",
Some(&t),
None,
)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect();
assert!(
warns
.iter()
.any(|m| m.contains("baseline setting is ignored")),
"expected the ignored-baseline warning: {warns:?}"
);
}
#[test]
fn baseline_directive_without_envs_comparison_does_not_warn() {
let t = titles();
let warns: Vec<String> = diags_for(
"# collection: ./c.hurl\n# baseline: prev.baseline\nREPORT REQUEST Oauth\n",
Some(&t),
None,
)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect();
assert!(
!warns.iter().any(|m| m.contains("'# baseline:'")),
"a plain baseline diff should not warn: {warns:?}"
);
}
#[test]
fn missing_baseline_snapshot_warns_when_anchored() {
let dir = std::env::temp_dir().join(format!("pb-vbl-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let flow = parse_flow(
"# collection: ./c.hurl\n# baseline: missing.baseline\nREPORT REQUEST Oauth\n",
)
.unwrap();
let t = titles();
let ctx = Context {
request_titles: Some(&t),
root: Some(dir.as_path()),
..Default::default()
};
let warns: Vec<String> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect();
assert!(
warns.iter().any(|m| m.contains("was not found")),
"expected a missing-snapshot warning: {warns:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_baseline_file_role_warns_when_anchored() {
let dir = std::env::temp_dir().join(format!("pb-vrole-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let flow = parse_flow(
"# collection: ./c.hurl\nFOR T IN ENVS BASELINE(FILE(\"missing.baseline\")), COMPARISON(\"prod\")\n REPORT REQUEST Oauth\nEND\n",
)
.unwrap();
let t = titles();
let envs = vec!["prod".to_string()];
let ctx = Context {
request_titles: Some(&t),
env_names: Some(&envs),
root: Some(dir.as_path()),
..Default::default()
};
let warns: Vec<String> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect();
assert!(
warns
.iter()
.any(|m| m.contains("was not found") && m.contains("missing.baseline")),
"expected a missing-snapshot warning: {warns:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_comparison_file_role_is_checked_too_and_a_present_one_stays_quiet() {
let dir = std::env::temp_dir().join(format!("pb-vrole-ok-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("prev.baseline"), "{}").unwrap();
let flow = parse_flow(
"# collection: ./c.hurl\nFOR T IN ENVS BASELINE(FILE(\"prev.baseline\")), COMPARISON(FILE(\"gone.baseline\"))\n REPORT REQUEST Oauth\nEND\n",
)
.unwrap();
let t = titles();
let ctx = Context {
request_titles: Some(&t),
root: Some(dir.as_path()),
..Default::default()
};
let warns: Vec<String> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect();
assert!(
warns.iter().any(|m| m.contains("gone.baseline")),
"a comparison role's snapshot is checked as well: {warns:?}"
);
assert!(
!warns.iter().any(|m| m.contains("prev.baseline")),
"an existing snapshot must stay quiet: {warns:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_file_role_is_never_mistaken_for_an_unloaded_environment() {
let flow = parse_flow(
"# collection: ./c.hurl\nFOR T IN ENVS BASELINE(FILE(\"snap.baseline\")), COMPARISON(\"prod\")\n REPORT REQUEST Oauth\nEND\n",
)
.unwrap();
let t = titles();
let envs = vec!["prod".to_string()];
let ctx = Context {
request_titles: Some(&t),
env_names: Some(&envs),
..Default::default()
};
let msgs: Vec<String> = validate(&flow, &ctx)
.into_iter()
.map(|d| d.message)
.collect();
assert!(
!msgs.iter().any(|m| m.contains("snap.baseline")),
"an unanchored report must not report on the snapshot at all: {msgs:?}"
);
}
#[test]
fn present_baseline_snapshot_does_not_warn() {
let dir = std::env::temp_dir().join(format!("pb-vbl-ok-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("prev.baseline"), "{}").unwrap();
let flow =
parse_flow("# collection: ./c.hurl\n# baseline: prev.baseline\nREPORT REQUEST Oauth\n")
.unwrap();
let t = titles();
let ctx = Context {
request_titles: Some(&t),
root: Some(dir.as_path()),
..Default::default()
};
let warns: Vec<String> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message)
.collect();
assert!(
!warns.iter().any(|m| m.contains("was not found")),
"an existing snapshot should not warn: {warns:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn envs_unloaded_environment_is_an_error() {
let t = titles();
let envs = vec!["prod-au".to_string()];
assert!(has_err(
"# collection: ./c.hurl\nFOR T IN ENVS \"prod-au\", \"staging-au\"\n REQUEST Oauth\nEND\n",
Some(&t),
Some(&envs),
"'staging-au' is not loaded"
));
}
#[test]
fn envs_all_loaded_has_no_env_error() {
let t = titles();
let envs = vec!["prod-au".to_string(), "staging-au".to_string()];
assert!(!has_err(
"# collection: ./c.hurl\nFOR T IN ENVS \"prod-au\", \"staging-au\"\n REQUEST Oauth\nEND\n",
Some(&t),
Some(&envs),
"not loaded"
));
}
#[test]
fn arity_mismatch_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nFOR (A, B) IN FILES \"d\"\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"binds 2 name"
));
}
#[test]
fn arity_match_on_zip_is_ok() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\nFOR (A, B) IN ZIP(FILES \"x\", FILES \"y\")\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"binds"
));
}
#[test]
fn concat_of_same_arity_sources_is_ok() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\nFOR F IN CONCAT(FILES \"x\", FILES \"y\", FOLDERS \"z\")\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"arity"
));
}
#[test]
fn concat_of_mismatched_arity_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nFOR F IN CONCAT(FILES \"x\", ZIP(FILES \"a\", FILES \"b\"))\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"inconsistent arity"
));
}
#[test]
fn inconsistent_list_literal_arity_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nLIST L = [(\"a\", \"b\"), \"c\"]\nFOR (X, Y) IN L\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"inconsistent arity"
));
}
#[test]
fn rest_pattern_absorbs_extra_positions() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\nLIST L = [(\"a\", \"b\", \"c\")]\nFOR (X, ...) IN L\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"binds",
));
}
#[test]
fn unknown_list_reference_is_an_error() {
let t = titles();
assert!(has_err(
"# collection: ./c.hurl\nFOR X IN MISSING\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"unknown list"
));
}
#[test]
fn declared_list_reference_is_ok() {
let t = titles();
assert!(!has_err(
"# collection: ./c.hurl\nLIST DOCS = FILES \"d\"\nFOR X IN DOCS\n REQUEST Oauth\nEND\n",
Some(&t),
None,
"unknown list"
));
}
#[test]
fn show_and_hide_overlap_is_an_error() {
let t = titles();
let errs = errors(
"# collection: c\nREPORT REQUEST Oauth SHOW(HttpStatus, Time) HIDE(Time)\n",
Some(&t),
None,
);
let overlap: Vec<_> = errs.iter().filter(|m| m.contains("Time")).collect();
assert_eq!(overlap.len(), 1, "one overlap error for Time: {errs:?}");
assert!(
overlap[0].contains("conflict")
|| overlap[0].contains("SHOW")
|| overlap[0].contains("HIDE")
);
}
#[test]
fn hide_unknown_field_warns_but_known_fields_do_not() {
let warns = warnings_with_fields(
"REPORT REQUEST process HIDE(Response, Time, status, ghost)\n",
&[("process", &["status", "overall"])],
);
assert_eq!(warns.len(), 1, "only 'ghost' should warn: {warns:?}");
assert!(warns[0].contains("ghost"));
}
#[test]
fn hide_is_not_validated_without_a_bound_collection() {
let flow = parse_flow("REPORT REQUEST process HIDE(bogus)\n").unwrap();
let ctx = Context::default();
let warns: Vec<_> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning)
.filter(|d| d.message.contains("HIDE"))
.collect();
assert!(
warns.is_empty(),
"unbound flow shouldn't warn on HIDE: {warns:?}"
);
}
fn test_entry(title: &str, url_vars: &[&str], captures: &[&str]) -> crate::hurl::HurlEntry {
use crate::hurl::HurlEntry;
let url: String = url_vars
.iter()
.map(|v| format!("{{{{{}}}}} ", v))
.collect::<String>();
HurlEntry {
title: title.to_string(),
method: "GET".to_string(),
url: format!("http://example/{}x", url),
captures: captures
.iter()
.map(|c| ((*c).to_string(), "jsonpath \"$.v\"".to_string()))
.collect(),
..Default::default()
}
}
fn var_warns(
src: &str,
base_vars: &[&str],
all_env_vars: &[&str],
entries: &[crate::hurl::HurlEntry],
) -> Vec<String> {
let flow = parse_flow(src).expect("test source should parse");
let base: Vec<String> = base_vars.iter().map(|s| s.to_string()).collect();
let all_env: Vec<String> = all_env_vars.iter().map(|s| s.to_string()).collect();
let titles: Vec<String> = entries.iter().map(|e| e.title.clone()).collect();
let ctx = Context {
request_titles: Some(&titles),
base_var_names: Some(&base),
all_env_var_names: Some(&all_env),
request_entries: Some(entries),
..Default::default()
};
validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning && d.message.contains("may not be set"))
.map(|d| d.message)
.collect()
}
#[test]
fn missing_var_in_request_url_produces_a_warning() {
let entries = vec![test_entry("Oauth", &["TOKEN"], &[])];
let warns = var_warns(
"# collection: c\nREPORT REQUEST Oauth\n",
&[], &[],
&entries,
);
assert!(
warns.iter().any(|w| w.contains("TOKEN")),
"{{TOKEN}} should warn as undefined: {warns:?}"
);
}
#[test]
fn var_defined_by_base_env_does_not_warn() {
let entries = vec![test_entry("Oauth", &["BASE_URL"], &[])];
let warns = var_warns(
"# collection: c\nREPORT REQUEST Oauth\n",
&["BASE_URL"], &[],
&entries,
);
assert!(
warns.is_empty(),
"BASE_URL is in the base env — no warning expected: {warns:?}"
);
}
#[test]
fn var_defined_by_explicit_assignment_does_not_warn() {
let entries = vec![test_entry("Oauth", &["TOKEN"], &[])];
let warns = var_warns(
"# collection: c\nTOKEN=abc\nREPORT REQUEST Oauth\n",
&[], &[],
&entries,
);
assert!(
warns.is_empty(),
"TOKEN is assigned before the request — no warning: {warns:?}"
);
}
#[test]
fn var_defined_by_for_loop_binder_does_not_warn() {
let entries = vec![test_entry("Oauth", &["TOKEN"], &[])];
let warns = var_warns(
"# collection: c\nFOR TOKEN IN [\"x\", \"y\"]\n REPORT REQUEST Oauth\nEND\n",
&[],
&[],
&entries,
);
assert!(
warns.is_empty(),
"TOKEN is a FOR loop binder — no warning: {warns:?}"
);
}
#[test]
fn var_defined_by_prior_capture_does_not_warn() {
let auth = test_entry("Auth", &[], &["TOKEN"]);
let api = test_entry("Api", &["TOKEN"], &[]);
let entries = vec![auth, api];
let warns = var_warns(
"# collection: c\nREQUEST Auth\nREPORT REQUEST Api\n",
&[],
&[],
&entries,
);
assert!(
warns.is_empty(),
"TOKEN is captured by Auth before Api runs — no warning: {warns:?}"
);
}
#[test]
fn var_defined_by_envs_loop_does_not_warn() {
let entries = vec![test_entry("Api", &["REGION"], &[])];
let warns = var_warns(
"# collection: c\nFOR ENV IN ENVS \"prod\", \"staging\"\n REPORT REQUEST Api\nEND\n",
&[], &["REGION"], &entries,
);
assert!(
warns.is_empty(),
"REGION comes from the ENVS loop env — no warning: {warns:?}"
);
}
#[test]
fn no_warning_without_base_var_names_context() {
let entries = vec![test_entry("Oauth", &["MISSING"], &[])];
let titles: Vec<String> = entries.iter().map(|e| e.title.clone()).collect();
let flow = parse_flow("# collection: c\nREPORT REQUEST Oauth\n").unwrap();
let ctx = Context {
request_titles: Some(&titles),
base_var_names: None, request_entries: Some(&entries),
..Default::default()
};
let warns: Vec<_> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Warning && d.message.contains("may not be defined"))
.collect();
assert!(
warns.is_empty(),
"without base_var_names the check must be skipped: {warns:?}"
);
}
#[test]
fn capture_is_only_available_after_the_capturing_request() {
let auth = test_entry("Auth", &[], &["TOKEN"]);
let before = test_entry("Before", &["TOKEN"], &[]);
let after = test_entry("After", &["TOKEN"], &[]);
let entries = vec![auth.clone(), before.clone(), after.clone()];
let warns_before = var_warns(
"# collection: c\nREPORT REQUEST Before\nREQUEST Auth\nREPORT REQUEST After\n",
&[],
&[],
&entries,
);
assert!(
warns_before
.iter()
.any(|w| w.contains("TOKEN") && w.contains("Before")),
"TOKEN is not yet captured when Before runs: {warns_before:?}"
);
assert!(
!warns_before
.iter()
.any(|w| w.contains("TOKEN") && w.contains("After")),
"TOKEN IS captured by the time After runs: {warns_before:?}"
);
}
}
#[cfg(test)]
mod helper_collection_validation_tests {
use super::*;
use crate::hurl::HurlEntry;
use crate::report::parser::parse_flow;
use crate::report::run::HelperCollection;
fn entry(title: &str) -> HurlEntry {
HurlEntry {
title: title.to_string(),
method: "GET".into(),
url: "http://x".into(),
..Default::default()
}
}
fn check(src: &str, titles: &[&str], helpers: &[HelperCollection]) -> Vec<String> {
let flow = parse_flow(src).expect("parses");
let titles: Vec<String> = titles.iter().map(|t| t.to_string()).collect();
let ctx = Context {
request_titles: Some(&titles),
helpers,
..Default::default()
};
validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Error)
.map(|d| d.message)
.collect()
}
fn helper(alias: &str, titles: &[&str]) -> HelperCollection {
HelperCollection {
alias: alias.into(),
entries: titles.iter().map(|t| entry(t)).collect(),
}
}
#[test]
fn a_helper_request_validates_through_its_alias() {
let errs = check(
"# collection: ./api.hurl\n# collection: ./h.hurl AS h\n\nREQUEST h/fetch_frame\n",
&["upload"],
&[helper("h", &["fetch_frame"])],
);
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn a_helper_request_without_its_alias_is_an_error() {
let errs = check(
"# collection: ./api.hurl\n# collection: ./h.hurl AS h\n\nREQUEST fetch_frame\n",
&["upload"],
&[helper("h", &["fetch_frame"])],
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("fetch_frame"));
}
#[test]
fn a_missing_name_inside_a_helper_is_reported_with_the_alias() {
let errs = check(
"# collection: ./api.hurl\n# collection: ./h.hurl AS h\n\nREQUEST h/nope\n",
&["upload"],
&[helper("h", &["fetch_frame"])],
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("h/nope"), "{errs:?}");
}
#[test]
fn the_primary_collection_takes_no_alias() {
let errs = check(
"# collection: ./api.hurl AS main\n\nREQUEST upload\n",
&["upload"],
&[],
);
assert_eq!(errs.len(), 1, "{errs:?}");
}
#[test]
fn a_helper_must_be_aliased() {
let errs = check(
"# collection: ./api.hurl\n# collection: ./h.hurl\n\nREQUEST upload\n",
&["upload"],
&[],
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("./h.hurl"), "{errs:?}");
}
#[test]
fn two_helpers_cannot_share_an_alias() {
let errs = check(
"# collection: ./api.hurl\n# collection: ./a.hurl AS h\n# collection: ./b.hurl AS h\n\nREQUEST upload\n",
&["upload"],
&[helper("h", &["x"])],
);
assert_eq!(errs.len(), 1, "{errs:?}");
}
#[test]
fn an_alias_may_not_shadow_a_virtual_folder() {
let errs = check(
"# collection: ./api.hurl\n# collection: ./h.hurl AS auth\n\nREQUEST upload\n",
&["auth/login", "upload"],
&[helper("auth", &["x"])],
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("auth"), "{errs:?}");
}
#[test]
fn an_unreadable_helper_is_an_error_on_the_directive() {
let flow = parse_flow(
"# collection: ./api.hurl\n# collection: ./gone.hurl AS h\n\nREQUEST upload\n",
)
.expect("parses");
let titles = vec!["upload".to_string()];
let errors = vec![("./gone.hurl".to_string(), "no such file".to_string())];
let ctx = Context {
request_titles: Some(&titles),
helper_errors: &errors,
..Default::default()
};
let errs: Vec<String> = validate(&flow, &ctx)
.into_iter()
.filter(|d| d.severity == Severity::Error)
.map(|d| d.message)
.collect();
assert!(
errs.iter()
.any(|e| e.contains("./gone.hurl") && e.contains("no such file")),
"{errs:?}"
);
}
}