#![cfg_attr(not(feature = "gui"), allow(dead_code))]
use crate::i18n::Strings;
use crate::report::flow::{
Binder, EnvClause, FlowNode, HeaderLine, ImageSpec, OverrideTarget, ParallelSpec, Pattern,
Producer, ReportFlow, ReportStmt, ResponseFmt, RoleRef, ShowField, UsingItem, WithItem,
};
use crate::report::model::StatKind;
use crate::report::parse_flow;
pub struct ParamRow {
pub name: String,
pub required: bool,
pub default: Option<String>,
}
pub fn param_rows(declared: &[(String, String)], using: &[UsingItem]) -> Vec<ParamRow> {
let required = |name: &str| {
using
.iter()
.any(|i| matches!(i, UsingItem::Require(n) if n == name))
};
let mut rows: Vec<ParamRow> = declared
.iter()
.map(|(name, value)| ParamRow {
name: name.clone(),
required: required(name),
default: Some(value.clone()),
})
.collect();
for item in using {
if let UsingItem::Require(name) = item
&& !rows.iter().any(|r| &r.name == name)
{
rows.push(ParamRow {
name: name.clone(),
required: true,
default: None,
});
}
}
rows
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OverrideRow {
pub target: String,
pub value: String,
}
pub fn override_rows(using: &[UsingItem]) -> Vec<OverrideRow> {
using
.iter()
.filter_map(|i| match i {
UsingItem::Override { target, value } => Some(OverrideRow {
target: target.text(),
value: value.clone(),
}),
UsingItem::Require(_) => None,
})
.collect()
}
pub fn override_target_valid(target: &str) -> bool {
target.trim().is_empty() || OverrideTarget::parse(target.trim()).is_some()
}
pub fn override_items(rows: &[OverrideRow]) -> Vec<UsingItem> {
rows.iter()
.filter_map(|r| {
OverrideTarget::parse(r.target.trim()).map(|target| UsingItem::Override {
target,
value: r.value.clone(),
})
})
.collect()
}
pub fn using_items(params: &[ParamRow], carried: &[UsingItem]) -> Vec<UsingItem> {
let mut out: Vec<UsingItem> = params
.iter()
.filter(|p| p.required)
.map(|p| UsingItem::Require(p.name.clone()))
.collect();
out.extend(
carried
.iter()
.filter(|i| !matches!(i, UsingItem::Require(_)))
.cloned(),
);
out
}
pub(crate) struct NodeRow {
pub(crate) depth: usize,
pub(crate) label: String,
pub(crate) kind: RowKind,
pub(crate) path: Vec<usize>,
pub(crate) req_ok: Option<bool>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum RowKind {
Begin,
Leaf,
LoopHead,
LoopEnd,
Comment,
WithField(usize),
WithComment(usize),
WithAdd,
WithEnd,
}
impl RowKind {
pub(crate) fn is_with(self) -> bool {
matches!(
self,
RowKind::WithField(_) | RowKind::WithComment(_) | RowKind::WithAdd | RowKind::WithEnd
)
}
pub(crate) fn with_item(self) -> Option<usize> {
match self {
RowKind::WithField(i) | RowKind::WithComment(i) => Some(i),
_ => None,
}
}
}
pub(crate) fn flatten(flow: &ReportFlow, resolves: &impl Fn(&str) -> bool) -> Vec<NodeRow> {
flatten_expanded(flow, resolves, false)
}
pub(crate) fn flatten_expanded(
flow: &ReportFlow,
resolves: &impl Fn(&str) -> bool,
expand_with: bool,
) -> Vec<NodeRow> {
let mut rows = vec![NodeRow {
depth: 0,
label: String::new(),
kind: RowKind::Begin,
path: Vec::new(),
req_ok: None,
}];
let mut prefix = Vec::new();
push_nodes(
&flow.nodes,
&mut prefix,
1,
resolves,
expand_with,
&mut rows,
);
rows
}
pub(crate) fn node_with_items(node: &FlowNode) -> Option<&[WithItem]> {
match node {
FlowNode::Report(ReportStmt::Request { with, .. }) => Some(with),
_ => None,
}
}
pub(crate) fn with_item_label(item: &WithItem) -> String {
crate::report::flow::with_item_text(item)
}
fn push_nodes(
nodes: &[FlowNode],
prefix: &mut Vec<usize>,
depth: usize,
resolves: &impl Fn(&str) -> bool,
expand_with: bool,
rows: &mut Vec<NodeRow>,
) {
for (i, node) in nodes.iter().enumerate() {
prefix.push(i);
let req_ok = node.request_name().map(resolves);
if let Some(body) = loop_body(node) {
rows.push(NodeRow {
depth,
label: node.label(),
kind: RowKind::LoopHead,
path: prefix.clone(),
req_ok,
});
push_nodes(body, prefix, depth + 1, resolves, expand_with, rows);
rows.push(NodeRow {
depth,
label: String::new(),
kind: RowKind::LoopEnd,
path: prefix.clone(),
req_ok: None,
});
} else {
let with = expand_with
.then(|| node_with_items(node))
.flatten()
.filter(|w| !w.is_empty());
rows.push(NodeRow {
depth,
label: match with {
Some(_) => format!("{} WITH", node.header_line()),
None => node.label(),
},
kind: match node {
FlowNode::Comment(_) => RowKind::Comment,
_ => RowKind::Leaf,
},
path: prefix.clone(),
req_ok,
});
if let Some(with) = with {
for (wi, item) in with.iter().enumerate() {
rows.push(NodeRow {
depth: depth + 1,
label: with_item_label(item),
kind: match item {
WithItem::Comment(_) => RowKind::WithComment(wi),
_ => RowKind::WithField(wi),
},
path: prefix.clone(),
req_ok: None,
});
}
rows.push(NodeRow {
depth: depth + 1,
label: String::new(),
kind: RowKind::WithAdd,
path: prefix.clone(),
req_ok: None,
});
rows.push(NodeRow {
depth,
label: String::new(),
kind: RowKind::WithEnd,
path: prefix.clone(),
req_ok: None,
});
}
}
prefix.pop();
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct InsertPos {
pub(crate) parent: Vec<usize>,
pub(crate) index: usize,
}
pub(crate) fn insert_pos_after(rows: &[NodeRow], sel: usize) -> InsertPos {
let Some(row) = rows.get(sel) else {
return InsertPos {
parent: Vec::new(),
index: 0,
};
};
match row.kind {
RowKind::Begin => InsertPos {
parent: Vec::new(),
index: 0,
},
RowKind::LoopHead => InsertPos {
parent: row.path.clone(),
index: 0,
},
RowKind::Leaf
| RowKind::Comment
| RowKind::LoopEnd
| RowKind::WithField(_)
| RowKind::WithComment(_)
| RowKind::WithAdd
| RowKind::WithEnd => {
let (last, rest) = row.path.split_last().unwrap_or((&0, &[]));
InsertPos {
parent: rest.to_vec(),
index: last + 1,
}
}
}
}
fn loop_body(node: &FlowNode) -> Option<&Vec<FlowNode>> {
match node {
FlowNode::ForEach { body, .. } | FlowNode::ForEnvs { body, .. } => Some(body),
_ => None,
}
}
pub(crate) fn loop_producer_dir(node: &FlowNode) -> Option<&str> {
match node {
FlowNode::ForEach {
producer: Producer::Files { dir, .. } | Producer::Folders { dir, .. },
..
} => Some(dir),
_ => None,
}
}
pub(crate) fn loop_producer_dir_mut(node: &mut FlowNode) -> Option<&mut String> {
match node {
FlowNode::ForEach {
producer: Producer::Files { dir, .. } | Producer::Folders { dir, .. },
..
} => Some(dir),
_ => None,
}
}
fn body_at_mut<'a>(flow: &'a mut ReportFlow, parent: &[usize]) -> Option<&'a mut Vec<FlowNode>> {
let mut body = &mut flow.nodes;
for &i in parent {
body = body.get_mut(i)?.body_mut()?;
}
Some(body)
}
pub(crate) fn node_at<'a>(flow: &'a ReportFlow, path: &[usize]) -> Option<&'a FlowNode> {
let (last, rest) = path.split_last()?;
let mut body = &flow.nodes;
for &i in rest {
body = loop_body(body.get(i)?)?;
}
body.get(*last)
}
pub(crate) fn node_at_mut<'a>(
flow: &'a mut ReportFlow,
path: &[usize],
) -> Option<&'a mut FlowNode> {
let (last, rest) = path.split_last()?;
let body = body_at_mut(flow, rest)?;
body.get_mut(*last)
}
pub(crate) fn insert_node(flow: &mut ReportFlow, pos: &InsertPos, node: FlowNode) {
if let Some(body) = body_at_mut(flow, &pos.parent) {
let idx = pos.index.min(body.len());
body.insert(idx, node);
}
}
pub(crate) fn remove_node(flow: &mut ReportFlow, path: &[usize]) -> bool {
let Some((last, rest)) = path.split_last() else {
return false;
};
if let Some(body) = body_at_mut(flow, rest)
&& *last < body.len()
{
body.remove(*last);
return true;
}
false
}
pub(crate) fn move_node(flow: &mut ReportFlow, path: &[usize], up: bool) -> Option<Vec<usize>> {
let (last, rest) = path.split_last()?;
let body = body_at_mut(flow, rest)?;
let target = if up {
last.checked_sub(1)?
} else if last + 1 < body.len() {
last + 1
} else {
return None;
};
body.swap(*last, target);
let mut new_path = rest.to_vec();
new_path.push(target);
Some(new_path)
}
pub(crate) fn replace_node(flow: &mut ReportFlow, path: &[usize], mut new_node: FlowNode) -> bool {
let Some(slot) = node_at_mut(flow, path) else {
return false;
};
let old_body = slot.body_mut().map(std::mem::take);
if let (Some(ob), Some(nb)) = (old_body, new_node.body_mut()) {
*nb = ob;
}
*slot = new_node;
true
}
pub(crate) fn parse_one_node(text: &str, prefer_loop: bool) -> Option<FlowNode> {
let t = text.trim();
if t.is_empty() {
return None;
}
let bare = format!("{t}\n");
let looped = format!("{t}\nEND\n");
let attempts = if prefer_loop {
[looped, bare]
} else {
[bare, looped]
};
for wrap in attempts {
if let Ok(flow) = parse_flow(&wrap)
&& flow.nodes.len() == 1
{
return flow.nodes.into_iter().next();
}
}
None
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum NodeKind {
Request,
ReportRequest,
ReportVar,
ReportComputed,
Assign,
ForFiles,
ForFolders,
ForEnvs,
List,
}
impl NodeKind {
pub(crate) const ALL: [NodeKind; 9] = [
NodeKind::Request,
NodeKind::ReportRequest,
NodeKind::ReportVar,
NodeKind::ReportComputed,
NodeKind::Assign,
NodeKind::ForFiles,
NodeKind::ForFolders,
NodeKind::ForEnvs,
NodeKind::List,
];
pub(crate) fn label(self, s: &Strings) -> &'static str {
match self {
NodeKind::Request => s.node_kind_request,
NodeKind::ReportRequest => s.node_kind_report_request,
NodeKind::ReportVar => s.node_kind_report_var,
NodeKind::ReportComputed => s.node_kind_report_computed,
NodeKind::Assign => s.node_kind_assign,
NodeKind::ForFiles => s.node_kind_for_files,
NodeKind::ForFolders => s.node_kind_for_folders,
NodeKind::ForEnvs => s.node_kind_for_envs,
NodeKind::List => s.node_kind_list,
}
}
pub(crate) fn needs_request(self) -> bool {
matches!(self, NodeKind::Request | NodeKind::ReportRequest)
}
pub(crate) fn template(self) -> Option<FlowNode> {
Some(match self {
NodeKind::Request | NodeKind::ReportRequest => return None,
NodeKind::ReportVar => FlowNode::Report(ReportStmt::Vars(vec!["VAR".into()])),
NodeKind::ReportComputed => FlowNode::Report(ReportStmt::Computed {
template: "value".into(),
name: "column".into(),
stats: Vec::new(),
image: None,
truth: None,
detail: false,
}),
NodeKind::Assign => FlowNode::Assign {
key: "NAME".into(),
value: String::new(),
},
NodeKind::ForFiles => FlowNode::ForEach {
pattern: Pattern::single("FILE"),
producer: Producer::Files {
dir: String::new(),
glob: None,
},
body: Vec::new(),
parallel: None,
},
NodeKind::ForFolders => FlowNode::ForEach {
pattern: Pattern::single("FOLDER"),
producer: Producer::Folders {
dir: String::new(),
glob: None,
roles: Vec::new(),
},
body: Vec::new(),
parallel: None,
},
NodeKind::ForEnvs => FlowNode::ForEnvs {
var: "TARGET".into(),
clause: EnvClause::Roles {
baseline: vec![RoleRef::Env("baseline".into())],
comparisons: vec![RoleRef::Env("candidate".into())],
baseline_show: Vec::new(),
},
body: Vec::new(),
parallel: None,
},
NodeKind::List => FlowNode::ListDecl {
name: "ITEMS".into(),
producer: Producer::List(Vec::new()),
},
})
}
}
pub(crate) fn request_node(name: &str, report: bool) -> FlowNode {
if report {
FlowNode::Report(ReportStmt::Request {
name: name.to_string(),
alias: None,
using: Vec::new(),
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
})
} else {
FlowNode::Request {
name: name.to_string(),
using: Vec::new(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Modifier {
Report,
Parallel,
With,
As,
Response,
Show,
Hide,
Statistics,
}
impl Modifier {
pub(crate) const ALL: [Modifier; 8] = [
Modifier::Report,
Modifier::Parallel,
Modifier::With,
Modifier::As,
Modifier::Response,
Modifier::Show,
Modifier::Hide,
Modifier::Statistics,
];
pub(crate) fn label(self, s: &Strings) -> &'static str {
match self {
Modifier::Report => s.node_mod_report,
Modifier::Parallel => s.node_mod_parallel,
Modifier::With => s.node_mod_with,
Modifier::As => s.node_mod_as,
Modifier::Response => s.node_mod_response,
Modifier::Show => s.node_mod_show,
Modifier::Hide => s.node_mod_hide,
Modifier::Statistics => s.node_mod_statistics,
}
}
pub(crate) fn applies_to(self, node: &FlowNode) -> bool {
match self {
Modifier::Report => {
matches!(node, FlowNode::Request { .. } | FlowNode::Assign { .. })
}
Modifier::Parallel => matches!(
node,
FlowNode::ForEach { parallel: None, .. } | FlowNode::ForEnvs { parallel: None, .. }
),
Modifier::With => matches!(node, FlowNode::Report(ReportStmt::Request { .. })),
Modifier::As => match node {
FlowNode::Report(ReportStmt::Request { alias, .. }) => alias.is_none(),
FlowNode::Report(ReportStmt::Vars(vars)) => vars.len() == 1,
_ => false,
},
Modifier::Response => matches!(
node,
FlowNode::Report(ReportStmt::Request {
response_fmt: None,
..
})
),
Modifier::Show => {
matches!(node, FlowNode::Report(ReportStmt::Request { show, .. }) if show.is_empty())
}
Modifier::Hide => {
matches!(node, FlowNode::Report(ReportStmt::Request { hide, .. }) if hide.is_empty())
}
Modifier::Statistics => match node {
FlowNode::Report(ReportStmt::VarAs { stats, .. })
| FlowNode::Report(ReportStmt::Computed { stats, .. }) => stats.is_empty(),
_ => false,
},
}
}
pub(crate) fn reject_reason(self, node: &FlowNode, s: &Strings) -> Option<&'static str> {
if self.applies_to(node) {
return None;
}
let reported = matches!(node, FlowNode::Report(ReportStmt::Request { .. }));
Some(match self {
Modifier::Report => {
if matches!(node, FlowNode::Report(_)) {
s.mod_reject_present
} else {
s.mod_reject_report
}
}
Modifier::Parallel => {
if matches!(node, FlowNode::ForEach { .. } | FlowNode::ForEnvs { .. }) {
s.mod_reject_present
} else {
s.mod_reject_parallel
}
}
Modifier::With => s.mod_reject_with,
Modifier::As => {
if reported || matches!(node, FlowNode::Report(ReportStmt::Vars(v)) if v.len() == 1)
{
s.mod_reject_present
} else {
s.mod_reject_as
}
}
Modifier::Response | Modifier::Show | Modifier::Hide => {
if reported {
s.mod_reject_present
} else {
s.mod_reject_request_only
}
}
Modifier::Statistics => {
if matches!(
node,
FlowNode::Report(ReportStmt::VarAs { .. })
| FlowNode::Report(ReportStmt::Computed { .. })
) {
s.mod_reject_present
} else {
s.mod_reject_statistics
}
}
})
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum DetachWhich {
Report,
Parallel,
As,
With(usize),
Response,
Show,
Hide,
BaselineShow,
Role {
baseline: bool,
index: usize,
},
WithBlock,
Statistics,
Image,
Truth,
Detail,
}
pub(crate) fn vars_in_scope(
flow: &ReportFlow,
path: &[usize],
entries: &[crate::hurl::HurlEntry],
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let push = |name: &str, out: &mut Vec<String>| {
if !name.trim().is_empty() && !out.iter().any(|n| n == name) {
out.push(name.to_string());
}
};
let mut nodes: &[FlowNode] = &flow.nodes;
for index in path {
for node in nodes.iter().take(*index) {
match node {
FlowNode::Assign { key, .. } => push(key, &mut out),
FlowNode::Request { name, .. }
| FlowNode::Report(ReportStmt::Request { name, .. }) => {
if let Some(entry) = crate::report::run::resolve_title(entries, name) {
for (cap, _) in &entry.captures {
push(cap, &mut out);
}
}
}
_ => {}
}
}
let Some(parent) = nodes.get(*index) else {
break;
};
match parent {
FlowNode::ForEach {
pattern,
producer,
body,
..
} => {
for name in pattern.named() {
push(name, &mut out);
}
if let Producer::Folders { roles, .. } = producer {
for role in roles {
push(&role.name, &mut out);
}
}
nodes = body;
}
FlowNode::ForEnvs { var, body, .. } => {
push(var, &mut out);
nodes = body;
}
_ => break,
}
}
out
}
pub(crate) fn baseline_show_choices(
entries: &[crate::hurl::HurlEntry],
body: &[FlowNode],
selected: &[ShowField],
) -> Vec<(String, bool)> {
let mut names: Vec<String> = Vec::new();
let push = |n: &str, names: &mut Vec<String>| {
if !n.trim().is_empty() && !names.iter().any(|x| x == n) {
names.push(n.to_string());
}
};
for f in crate::report::run::INTRINSIC_FIELDS {
push(f, &mut names);
}
for req in reported_requests(body) {
if let Some(entry) = crate::report::run::resolve_title(entries, &req) {
for (f, _) in &entry.reports {
push(f, &mut names);
}
}
}
for f in selected {
push(f.name(), &mut names);
}
names
.iter()
.map(|n| (n.clone(), selected.iter().any(|sel| sel.name() == n)))
.collect()
}
pub(crate) fn reported_requests(body: &[FlowNode]) -> Vec<String> {
let mut out = Vec::new();
fn walk(nodes: &[FlowNode], out: &mut Vec<String>) {
for n in nodes {
match n {
FlowNode::Report(ReportStmt::Request { name, .. }) => out.push(name.clone()),
FlowNode::ForEnvs { body, .. } | FlowNode::ForEach { body, .. } => walk(body, out),
_ => {}
}
}
}
walk(body, &mut out);
out
}
pub(crate) fn attach_modifier(flow: &mut ReportFlow, path: &[usize], m: Modifier) -> bool {
match node_at_mut(flow, path) {
Some(node) => attach_to_node(node, m),
None => false,
}
}
pub(crate) fn attach_to_node(node: &mut FlowNode, m: Modifier) -> bool {
if !m.applies_to(node) {
return false;
}
match m {
Modifier::Report => {
if let FlowNode::Request { name, using } = node {
let name = std::mem::take(name);
let using = std::mem::take(using);
*node = FlowNode::Report(ReportStmt::Request {
name,
alias: None,
using,
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
});
}
}
Modifier::Parallel => match node {
FlowNode::ForEach { parallel, .. } | FlowNode::ForEnvs { parallel, .. } => {
*parallel = Some(ParallelSpec::default());
}
_ => {}
},
Modifier::With => {
if let FlowNode::Report(ReportStmt::Request { with, .. }) = node {
with.push(WithItem::Field {
name: "field".into(),
query: "HttpStatus".into(),
stats: Vec::new(),
image: None,
truth: None,
detail: false,
});
}
}
Modifier::As => match node {
FlowNode::Report(ReportStmt::Request { alias, .. }) => {
*alias = Some("alias".into());
}
FlowNode::Report(ReportStmt::Vars(vars)) if vars.len() == 1 => {
let var = vars.remove(0);
*node = FlowNode::Report(ReportStmt::VarAs {
var,
name: "name".into(),
stats: Vec::new(),
image: None,
truth: None,
detail: false,
});
}
_ => {}
},
Modifier::Response => {
if let FlowNode::Report(ReportStmt::Request { response_fmt, .. }) = node {
*response_fmt = Some(ResponseFmt::Pretty);
}
}
Modifier::Show => {
if let FlowNode::Report(ReportStmt::Request { show, .. }) = node
&& show.is_empty()
{
*show = vec!["HttpStatus".into()];
}
}
Modifier::Hide => {
if let FlowNode::Report(ReportStmt::Request { hide, .. }) = node
&& hide.is_empty()
{
*hide = vec!["HttpStatus".into()];
}
}
Modifier::Statistics => match node {
FlowNode::Report(ReportStmt::VarAs { stats, .. })
| FlowNode::Report(ReportStmt::Computed { stats, .. }) => {
*stats = vec![StatKind::Count];
}
_ => {}
},
}
true
}
pub(crate) fn attach_with_stats(flow: &mut ReportFlow, path: &[usize], index: usize) -> bool {
let Some(FlowNode::Report(ReportStmt::Request { with, .. })) = node_at_mut(flow, path) else {
return false;
};
match with.get_mut(index) {
Some(WithItem::Field { stats, .. }) if stats.is_empty() => {
*stats = vec![StatKind::Count];
true
}
_ => false,
}
}
pub(crate) fn with_stats_applies(with: &[WithItem], index: usize) -> bool {
matches!(with.get(index), Some(WithItem::Field { stats, .. }) if stats.is_empty())
}
pub(crate) fn report_assignment(flow: &mut ReportFlow, path: &[usize]) -> Option<Vec<usize>> {
let key = match node_at(flow, path)? {
FlowNode::Assign { key, .. } => key.clone(),
_ => return None,
};
let (last, rest) = path.split_last()?;
let mut existing = rest.to_vec();
existing.push(last + 1);
if let Some(FlowNode::Report(ReportStmt::Vars(vars))) = node_at(flow, &existing)
&& vars.as_slice() == [key.clone()]
{
return Some(existing);
}
let pos = InsertPos {
parent: rest.to_vec(),
index: last + 1,
};
insert_node(flow, &pos, FlowNode::Report(ReportStmt::Vars(vec![key])));
let mut new = rest.to_vec();
new.push(last + 1);
Some(new)
}
pub(crate) fn set_request_name(flow: &mut ReportFlow, path: &[usize], name: &str) -> bool {
match node_at_mut(flow, path) {
Some(FlowNode::Request { name: n, .. })
| Some(FlowNode::Report(ReportStmt::Request { name: n, .. })) => {
*n = name.to_string();
true
}
_ => false,
}
}
pub(crate) fn set_env_role(
flow: &mut ReportFlow,
path: &[usize],
baseline: bool,
index: usize,
name: &str,
) -> bool {
let Some(FlowNode::ForEnvs {
clause:
EnvClause::Roles {
baseline: b,
comparisons: c,
..
},
..
}) = node_at_mut(flow, path)
else {
return false;
};
let list = if baseline { b } else { c };
match list.get_mut(index) {
Some(r @ RoleRef::Env(_)) => {
*r = RoleRef::Env(name.to_string());
true
}
_ => false,
}
}
pub(crate) fn set_report_alias(flow: &mut ReportFlow, path: &[usize], text: &str) -> bool {
let t = text.trim();
match node_at_mut(flow, path) {
Some(FlowNode::Report(ReportStmt::Request { alias, .. })) => {
*alias = (!t.is_empty()).then(|| t.to_string());
true
}
Some(FlowNode::Report(ReportStmt::VarAs { name, .. }))
| Some(FlowNode::Report(ReportStmt::Computed { name, .. })) => {
if t.is_empty() {
return false;
}
*name = t.to_string();
true
}
_ => false,
}
}
pub(crate) fn set_parallel_degree(
flow: &mut ReportFlow,
path: &[usize],
degree: Option<u32>,
) -> bool {
if degree == Some(0) {
return false;
}
match node_at_mut(flow, path) {
Some(FlowNode::ForEach { parallel, .. }) | Some(FlowNode::ForEnvs { parallel, .. }) => {
match parallel {
Some(spec) => {
spec.degree = degree;
true
}
None => false,
}
}
_ => false,
}
}
pub(crate) fn set_loop_var(flow: &mut ReportFlow, path: &[usize], text: &str) -> bool {
let t = text.trim();
if !crate::report::parser::is_ident(t) {
return false;
}
match node_at_mut(flow, path) {
Some(FlowNode::ForEach { pattern, .. }) => {
if pattern.rest || pattern.binders.len() != 1 {
return false;
}
match &mut pattern.binders[0] {
Binder::Named(name) => {
if name == t {
return false;
}
*name = t.to_string();
true
}
Binder::Discard => false,
}
}
Some(FlowNode::ForEnvs { var, .. }) => {
if var == t {
return false;
}
*var = t.to_string();
true
}
_ => false,
}
}
pub(crate) fn loop_dir(flow: &ReportFlow, path: &[usize]) -> Option<String> {
match node_at(flow, path) {
Some(FlowNode::ForEach { producer, .. }) => match producer {
Producer::Files { dir, .. } | Producer::Folders { dir, .. } => Some(dir.clone()),
Producer::Tuples { path } => Some(path.clone()),
_ => None,
},
_ => None,
}
}
pub(crate) fn set_loop_dir(flow: &mut ReportFlow, path: &[usize], text: &str) -> bool {
let t = text.trim();
if t.is_empty() {
return false;
}
match node_at_mut(flow, path) {
Some(FlowNode::ForEach { producer, .. }) => match producer {
Producer::Files { dir, .. } | Producer::Folders { dir, .. } => {
if dir == t {
return false;
}
*dir = t.to_string();
true
}
Producer::Tuples { path } => {
if path == t {
return false;
}
*path = t.to_string();
true
}
_ => false,
},
_ => false,
}
}
pub(crate) fn param_decl(
flow: &ReportFlow,
path: &[usize],
) -> Option<crate::report::flow::ParamDecl> {
match node_at(flow, path) {
Some(FlowNode::Param(p)) => Some(p.clone()),
_ => None,
}
}
pub(crate) fn set_param_default(flow: &mut ReportFlow, path: &[usize], text: &str) -> bool {
let t = text.trim();
let new = (!t.is_empty()).then(|| t.to_string());
match node_at_mut(flow, path) {
Some(FlowNode::Param(p)) => {
if p.default == new {
return false;
}
p.default = new;
true
}
_ => false,
}
}
pub(crate) fn set_loop_glob(flow: &mut ReportFlow, path: &[usize], text: &str) -> bool {
let t = text.trim();
match node_at_mut(flow, path) {
Some(FlowNode::ForEach {
producer: Producer::Files { glob, .. } | Producer::Folders { glob, .. },
..
}) => {
let next = (!t.is_empty()).then(|| t.to_string());
if *glob == next {
return false;
}
*glob = next;
true
}
_ => false,
}
}
pub(crate) struct HeaderSpec {
pub(crate) key: &'static str,
pub(crate) always_shown: bool,
pub(crate) required: bool,
pub(crate) kind: HeaderKind,
pub(crate) repeatable: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum HeaderKind {
Collection,
Environment,
Format,
Folder,
File,
Text,
}
impl HeaderKind {
pub(crate) fn is_path(self) -> bool {
matches!(self, HeaderKind::Folder | HeaderKind::File)
}
}
pub(crate) fn header_specs() -> [HeaderSpec; 7] {
[
HeaderSpec {
key: "collection",
always_shown: true,
required: true,
kind: HeaderKind::Collection,
repeatable: true,
},
HeaderSpec {
key: "output",
repeatable: false,
always_shown: true,
required: false,
kind: HeaderKind::Format,
},
HeaderSpec {
key: "environment",
repeatable: false,
always_shown: false,
required: false,
kind: HeaderKind::Environment,
},
HeaderSpec {
key: "root",
repeatable: false,
always_shown: false,
required: false,
kind: HeaderKind::Folder,
},
HeaderSpec {
key: "baseline",
repeatable: false,
always_shown: false,
required: false,
kind: HeaderKind::File,
},
HeaderSpec {
key: "columns",
always_shown: false,
required: false,
kind: HeaderKind::Text,
repeatable: false,
},
HeaderSpec {
key: "labels",
always_shown: false,
required: false,
kind: HeaderKind::Text,
repeatable: true,
},
]
}
pub(crate) fn header_help(key: &str, s: &Strings) -> &'static str {
match key {
"collection" => s.chip_help_hdr_collection,
"output" => s.chip_help_hdr_output,
"environment" => s.chip_help_hdr_environment,
"root" => s.chip_help_hdr_root,
"baseline" => s.chip_help_hdr_baseline,
"labels" => s.chip_help_hdr_labels,
_ => s.chip_help_hdr_columns,
}
}
pub(crate) const HEADER_PLACEHOLDER: &str = "?";
pub(crate) fn header_unset(value: &str) -> bool {
value.is_empty() || value == HEADER_PLACEHOLDER
}
fn nth_directive(flow: &ReportFlow, key: &str, n: usize) -> Option<usize> {
flow.header
.lines
.iter()
.enumerate()
.filter(|(_, l)| matches!(l, HeaderLine::Directive { key: k, .. } if k.eq_ignore_ascii_case(key)))
.map(|(i, _)| i)
.nth(n)
}
pub(crate) fn set_header_nth(
flow: &mut ReportFlow,
key: &str,
n: usize,
value: Option<&str>,
) -> bool {
let value = value.map(str::trim).filter(|v| !v.is_empty());
match (nth_directive(flow, key, n), value) {
(Some(i), Some(v)) => {
let HeaderLine::Directive { value: old, .. } = &mut flow.header.lines[i] else {
return false;
};
if old == v {
return false;
}
*old = v.to_string();
true
}
(Some(i), None) => {
flow.header.lines.remove(i);
true
}
(None, Some(v)) => add_header(flow, key, v),
(None, None) => false,
}
}
pub(crate) fn add_header(flow: &mut ReportFlow, key: &str, value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
let at = flow
.header
.lines
.iter()
.rposition(|l| matches!(l, HeaderLine::Directive { .. }))
.map_or(0, |i| i + 1);
flow.header.lines.insert(
at,
HeaderLine::Directive {
key: key.to_string(),
value: value.to_string(),
},
);
true
}
pub(crate) fn add_with_field(
flow: &mut ReportFlow,
path: &[usize],
name: &str,
query: &str,
stats: Vec<StatKind>,
) -> Option<usize> {
if let Some(FlowNode::Report(ReportStmt::Request { with, .. })) = node_at_mut(flow, path) {
with.push(WithItem::Field {
name: name.to_string(),
query: query.to_string(),
stats,
image: None,
truth: None,
detail: false,
});
Some(with.len() - 1)
} else {
None
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ClauseForm {
pub(crate) truth: String,
pub(crate) detail: bool,
pub(crate) image_on: bool,
pub(crate) fit: bool,
pub(crate) height: String,
pub(crate) width: String,
}
impl ClauseForm {
pub(crate) fn of(image: Option<ImageSpec>, truth: Option<&str>, detail: bool) -> Self {
let px = |v: Option<u32>| v.map(|n| n.to_string()).unwrap_or_default();
ClauseForm {
truth: truth.unwrap_or_default().to_string(),
detail,
image_on: image.is_some(),
fit: image.is_some_and(|i| i.fit),
height: image.map(|i| px(i.height)).unwrap_or_default(),
width: image.map(|i| px(i.width)).unwrap_or_default(),
}
}
pub(crate) fn truth(&self) -> Option<String> {
Some(self.truth.trim().to_string()).filter(|t| !t.is_empty())
}
pub(crate) fn image(&self) -> Option<ImageSpec> {
if !self.image_on {
return None;
}
if self.fit {
return Some(ImageSpec {
fit: true,
..Default::default()
});
}
let px = |s: &String| s.trim().parse::<u32>().ok().filter(|n| *n > 0);
Some(ImageSpec {
height: px(&self.height),
width: px(&self.width),
fit: false,
})
}
pub(crate) fn toggle_image(&mut self) {
self.image_on = !self.image_on;
if !self.image_on {
self.fit = false;
self.height.clear();
self.width.clear();
}
}
pub(crate) fn toggle_fit(&mut self) {
self.fit = !self.fit;
if self.fit {
self.height.clear();
self.width.clear();
}
}
}
pub(crate) fn set_with_field(
flow: &mut ReportFlow,
path: &[usize],
index: usize,
name: &str,
query: &str,
stats: Vec<StatKind>,
clauses: &ClauseForm,
) -> bool {
if let Some(FlowNode::Report(ReportStmt::Request { with, .. })) = node_at_mut(flow, path)
&& let Some(WithItem::Field {
name: n,
query: q,
stats: st,
image: im,
truth: tr,
detail: de,
}) = with.get_mut(index)
{
*n = name.to_string();
*q = query.to_string();
*st = stats;
*im = clauses.image();
*tr = clauses.truth();
*de = clauses.detail;
true
} else {
false
}
}
pub(crate) fn detach_modifier(flow: &mut ReportFlow, path: &[usize], which: DetachWhich) -> bool {
let Some(node) = node_at_mut(flow, path) else {
return false;
};
detach_from_node(node, which)
}
pub(crate) fn detach_leaves_statement(node: &FlowNode, which: DetachWhich) -> bool {
!detach_from_node(&mut node.clone(), which)
}
#[derive(Clone, PartialEq, Debug)]
pub(crate) enum CarriedMod {
Report,
Parallel(Option<ParallelSpec>),
As(String),
With(WithItem),
Response(ResponseFmt),
Show(Vec<ShowField>),
Hide(Vec<String>),
BaselineShow(Vec<ShowField>),
Role {
baseline: bool,
role: RoleRef,
},
WithBlock(Vec<WithItem>),
Statistics(Vec<StatKind>),
}
pub(crate) fn carry_modifier(node: &FlowNode, which: DetachWhich) -> Option<CarriedMod> {
Some(match (which, node) {
(DetachWhich::Report, FlowNode::Report(_)) => CarriedMod::Report,
(
DetachWhich::Parallel,
FlowNode::ForEach { parallel, .. } | FlowNode::ForEnvs { parallel, .. },
) => CarriedMod::Parallel(parallel.clone()),
(DetachWhich::As, FlowNode::Report(ReportStmt::Request { alias, .. })) => {
CarriedMod::As(alias.clone()?)
}
(DetachWhich::As, FlowNode::Report(ReportStmt::VarAs { name, .. })) => {
CarriedMod::As(name.clone())
}
(DetachWhich::With(i), FlowNode::Report(ReportStmt::Request { with, .. })) => {
CarriedMod::With(with.get(i)?.clone())
}
(DetachWhich::Response, FlowNode::Report(ReportStmt::Request { response_fmt, .. })) => {
CarriedMod::Response(*response_fmt.as_ref()?)
}
(DetachWhich::Show, FlowNode::Report(ReportStmt::Request { show, .. })) => {
CarriedMod::Show(non_empty(show)?)
}
(DetachWhich::Hide, FlowNode::Report(ReportStmt::Request { hide, .. })) => {
CarriedMod::Hide(non_empty(hide)?)
}
(
DetachWhich::BaselineShow,
FlowNode::ForEnvs {
clause: EnvClause::Roles { baseline_show, .. },
..
},
) => CarriedMod::BaselineShow(non_empty(baseline_show)?),
(
DetachWhich::Role { baseline, index },
FlowNode::ForEnvs {
clause:
EnvClause::Roles {
baseline: b,
comparisons,
..
},
..
},
) => CarriedMod::Role {
baseline,
role: if baseline { b } else { comparisons }.get(index)?.clone(),
},
(DetachWhich::WithBlock, FlowNode::Report(ReportStmt::Request { with, .. })) => {
CarriedMod::WithBlock(non_empty(with)?)
}
(
DetachWhich::Statistics,
FlowNode::Report(ReportStmt::VarAs { stats, .. } | ReportStmt::Computed { stats, .. }),
) => CarriedMod::Statistics(non_empty(stats)?),
_ => return None,
})
}
fn non_empty<T: Clone>(v: &[T]) -> Option<Vec<T>> {
(!v.is_empty()).then(|| v.to_vec())
}
impl CarriedMod {
fn kind(&self) -> Option<Modifier> {
Some(match self {
CarriedMod::Report => Modifier::Report,
CarriedMod::Parallel(_) => Modifier::Parallel,
CarriedMod::As(_) => Modifier::As,
CarriedMod::With(_) => Modifier::With,
CarriedMod::Response(_) => Modifier::Response,
CarriedMod::Show(_) => Modifier::Show,
CarriedMod::Hide(_) => Modifier::Hide,
CarriedMod::Statistics(_) => Modifier::Statistics,
CarriedMod::BaselineShow(_) | CarriedMod::Role { .. } | CarriedMod::WithBlock(_) => {
return None;
}
})
}
pub(crate) fn applies_to(&self, node: &FlowNode) -> bool {
match self {
CarriedMod::Report => matches!(node, FlowNode::Request { .. }),
CarriedMod::BaselineShow(_) => matches!(
node,
FlowNode::ForEnvs {
clause: EnvClause::Roles { baseline_show, .. },
..
} if baseline_show.is_empty()
),
CarriedMod::Role { baseline, role } => matches!(
node,
FlowNode::ForEnvs {
clause: EnvClause::Roles { baseline: b, comparisons, .. },
..
} if !if *baseline { b } else { comparisons }.contains(role)
),
CarriedMod::WithBlock(_) => matches!(
node,
FlowNode::Report(ReportStmt::Request { with, .. }) if with.is_empty()
),
other => other.kind().is_some_and(|m: Modifier| m.applies_to(node)),
}
}
pub(crate) fn reject_reason(&self, node: &FlowNode, s: &Strings) -> Option<&'static str> {
if self.applies_to(node) {
return None;
}
Some(match self {
CarriedMod::Report => {
if matches!(node, FlowNode::Report(_)) {
s.mod_reject_present
} else {
s.mod_reject_report
}
}
CarriedMod::BaselineShow(_) | CarriedMod::Role { .. } => {
if matches!(
node,
FlowNode::ForEnvs {
clause: EnvClause::Roles { .. },
..
}
) {
s.mod_reject_present
} else {
s.mod_reject_compare_only
}
}
CarriedMod::WithBlock(_) => {
if matches!(node, FlowNode::Report(ReportStmt::Request { .. })) {
s.mod_reject_present
} else {
s.mod_reject_with
}
}
other => other.kind()?.reject_reason(node, s)?,
})
}
pub(crate) fn attach_to(&self, node: &mut FlowNode) -> bool {
if !self.applies_to(node) {
return false;
}
match self {
CarriedMod::Report => {
if let FlowNode::Request { name, using } = node {
let name = std::mem::take(name);
let using = std::mem::take(using);
*node = FlowNode::Report(ReportStmt::Request {
name,
alias: None,
using,
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
});
}
}
CarriedMod::Parallel(spec) => match node {
FlowNode::ForEach { parallel, .. } | FlowNode::ForEnvs { parallel, .. } => {
*parallel = Some(spec.clone().unwrap_or_default());
}
_ => {}
},
CarriedMod::As(name) => match node {
FlowNode::Report(ReportStmt::Request { alias, .. }) => *alias = Some(name.clone()),
FlowNode::Report(ReportStmt::Vars(vars)) if vars.len() == 1 => {
let var = vars.remove(0);
*node = FlowNode::Report(ReportStmt::VarAs {
var,
name: name.clone(),
stats: Vec::new(),
image: None,
truth: None,
detail: false,
});
}
_ => {}
},
CarriedMod::With(item) => {
if let FlowNode::Report(ReportStmt::Request { with, .. }) = node {
with.push(item.clone());
}
}
CarriedMod::Response(fmt) => {
if let FlowNode::Report(ReportStmt::Request { response_fmt, .. }) = node {
*response_fmt = Some(*fmt);
}
}
CarriedMod::Show(cols) => {
if let FlowNode::Report(ReportStmt::Request { show, .. }) = node {
*show = cols.clone();
}
}
CarriedMod::Hide(cols) => {
if let FlowNode::Report(ReportStmt::Request { hide, .. }) = node {
*hide = cols.clone();
}
}
CarriedMod::BaselineShow(cols) => {
if let FlowNode::ForEnvs {
clause: EnvClause::Roles { baseline_show, .. },
..
} = node
{
*baseline_show = cols.clone();
}
}
CarriedMod::Role { baseline, role } => {
if let FlowNode::ForEnvs {
clause:
EnvClause::Roles {
baseline: b,
comparisons,
..
},
..
} = node
{
if *baseline { b } else { comparisons }.push(role.clone());
}
}
CarriedMod::WithBlock(items) => {
if let FlowNode::Report(ReportStmt::Request { with, .. }) = node {
*with = items.clone();
}
}
CarriedMod::Statistics(stats) => match node {
FlowNode::Report(ReportStmt::VarAs { stats: s, .. })
| FlowNode::Report(ReportStmt::Computed { stats: s, .. }) => *s = stats.clone(),
_ => {}
},
}
true
}
}
pub(crate) fn transfer_modifier(
flow: &mut ReportFlow,
from: &[usize],
which: DetachWhich,
to: &[usize],
copy: bool,
) -> bool {
if from == to {
return false;
}
let Some(carried) = node_at(flow, from).and_then(|n| carry_modifier(n, which)) else {
return false;
};
if !node_at(flow, to).is_some_and(|n| carried.applies_to(n)) {
return false;
}
if !copy {
detach_modifier(flow, from, which);
}
node_at_mut(flow, to).is_some_and(|n| carried.attach_to(n))
}
fn detach_from_node(node: &mut FlowNode, which: DetachWhich) -> bool {
match which {
DetachWhich::Report => match node {
FlowNode::Report(ReportStmt::Request { name, using, .. }) => {
let name = std::mem::take(name);
let using = std::mem::take(using);
*node = FlowNode::Request { name, using };
false
}
FlowNode::Report(_) => true,
_ => false,
},
DetachWhich::Parallel => {
match node {
FlowNode::ForEach { parallel, .. } | FlowNode::ForEnvs { parallel, .. } => {
*parallel = None;
}
_ => {}
}
false
}
DetachWhich::As => {
match node {
FlowNode::Report(ReportStmt::Request { alias, .. }) => *alias = None,
FlowNode::Report(ReportStmt::VarAs { var, .. }) => {
let var = std::mem::take(var);
*node = FlowNode::Report(ReportStmt::Vars(vec![var]));
}
_ => {}
}
false
}
DetachWhich::With(i) => {
if let FlowNode::Report(ReportStmt::Request { with, .. }) = node
&& i < with.len()
{
with.remove(i);
}
false
}
DetachWhich::Response => {
if let FlowNode::Report(ReportStmt::Request { response_fmt, .. }) = node {
*response_fmt = None;
}
false
}
DetachWhich::Show => {
if let FlowNode::Report(ReportStmt::Request { show, .. }) = node {
show.clear();
}
false
}
DetachWhich::Hide => {
if let FlowNode::Report(ReportStmt::Request { hide, .. }) = node {
hide.clear();
}
false
}
DetachWhich::BaselineShow => {
if let FlowNode::ForEnvs {
clause: EnvClause::Roles { baseline_show, .. },
..
} = node
{
baseline_show.clear();
}
false
}
DetachWhich::Role { baseline, index } => {
if let FlowNode::ForEnvs { clause, .. } = node
&& let EnvClause::Roles {
baseline: b,
comparisons,
..
} = clause
{
let side = if baseline { &mut *b } else { &mut *comparisons };
if index < side.len() {
side.remove(index);
}
if b.is_empty() || comparisons.is_empty() {
let names: Vec<String> = b
.iter()
.chain(comparisons.iter())
.filter_map(|r| match r {
RoleRef::Env(n) => Some(n.clone()),
RoleRef::File(_) => None,
})
.collect();
*clause = EnvClause::Plain(names);
}
}
false
}
DetachWhich::WithBlock => {
if let FlowNode::Report(ReportStmt::Request { with, .. }) = node {
with.clear();
}
false
}
DetachWhich::Statistics => {
match node {
FlowNode::Report(ReportStmt::VarAs { stats, .. })
| FlowNode::Report(ReportStmt::Computed { stats, .. }) => stats.clear(),
_ => {}
}
false
}
DetachWhich::Image => {
clear_clause(node, |image, _, _| *image = None);
false
}
DetachWhich::Truth => {
clear_clause(node, |_, truth, _| *truth = None);
false
}
DetachWhich::Detail => {
clear_clause(node, |_, _, detail| *detail = false);
false
}
}
}
fn clear_clause(
node: &mut FlowNode,
f: impl FnOnce(&mut Option<ImageSpec>, &mut Option<String>, &mut bool),
) {
if let FlowNode::Report(
ReportStmt::VarAs {
image,
truth,
detail,
..
}
| ReportStmt::Computed {
image,
truth,
detail,
..
},
) = node
{
f(image, truth, detail);
}
}
pub(crate) fn take_node(flow: &mut ReportFlow, path: &[usize]) -> Option<FlowNode> {
let (last, rest) = path.split_last()?;
let body = body_at_mut(flow, rest)?;
if *last < body.len() {
Some(body.remove(*last))
} else {
None
}
}
pub(crate) fn move_node_to(
flow: &mut ReportFlow,
from: &[usize],
pos: &InsertPos,
) -> Option<Vec<usize>> {
if pos.parent.len() >= from.len() && pos.parent[..from.len()] == *from {
return None;
}
let node = take_node(flow, from)?;
let (from_last, from_parent) = from.split_last()?;
let d = from_parent.len();
let mut parent = pos.parent.clone();
let mut index = pos.index;
if parent.len() > d && parent[..d] == *from_parent {
if parent[d] > *from_last {
parent[d] -= 1;
}
} else if parent == *from_parent && *from_last < index {
index -= 1;
}
let dest = InsertPos { parent, index };
insert_node(flow, &dest, node);
let mut new_path = dest.parent;
new_path.push(dest.index);
Some(new_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::flow::FlowNode;
fn flow(src: &str) -> ReportFlow {
parse_flow(src).expect("test flow must parse")
}
fn always_ok(_: &str) -> bool {
true
}
#[test]
fn flatten_marks_begin_body_and_loop_end() {
let f = flow("REQUEST A\nFOR X IN FILES \"/d\"\n REQUEST B\nEND\n");
let rows = flatten(&f, &always_ok);
let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
assert_eq!(
kinds,
vec![
RowKind::Begin,
RowKind::Leaf,
RowKind::LoopHead,
RowKind::Leaf,
RowKind::LoopEnd,
]
);
assert_eq!(rows[2].path, rows[4].path);
assert_eq!(rows[3].path, vec![1, 0]);
}
#[test]
fn a_parameters_default_can_be_set_and_cleared() {
let mut f = flow("PARAM ENV TARGET = \"staging\" LABEL \"Environment\"\n");
assert!(set_param_default(&mut f, &[0], "prod"));
assert!(
f.to_text().contains("PARAM ENV TARGET = \"prod\""),
"{}",
f.to_text()
);
assert!(
f.to_text().contains("LABEL \"Environment\""),
"the rest of the declaration is untouched: {}",
f.to_text()
);
assert!(
!set_param_default(&mut f, &[0], "prod"),
"setting the same value again is not an edit"
);
assert!(set_param_default(&mut f, &[0], " "));
assert_eq!(param_decl(&f, &[0]).expect("still a PARAM").default, None);
assert!(
!f.to_text().contains('='),
"cleared, not emptied: {}",
f.to_text()
);
}
#[test]
fn insert_pos_after_targets_the_right_body() {
let f = flow("REQUEST A\nFOR X IN FILES \"/d\"\n REQUEST B\nEND\n");
let rows = flatten(&f, &always_ok);
let p = insert_pos_after(&rows, 0);
assert_eq!(p.parent, Vec::<usize>::new());
assert_eq!(p.index, 0);
let p = insert_pos_after(&rows, 1);
assert_eq!(p.parent, Vec::<usize>::new());
assert_eq!(p.index, 1);
let p = insert_pos_after(&rows, 2);
assert_eq!(p.parent, vec![1]);
assert_eq!(p.index, 0);
let p = insert_pos_after(&rows, 4);
assert_eq!(p.parent, Vec::<usize>::new());
assert_eq!(p.index, 2);
}
#[test]
fn insert_and_remove_round_trip() {
let mut f = flow("REQUEST A\n");
let pos = InsertPos {
parent: Vec::new(),
index: 1,
};
insert_node(&mut f, &pos, request_node("B", false));
assert_eq!(
f.nodes.iter().map(|n| n.request_name()).collect::<Vec<_>>(),
vec![Some("A"), Some("B")]
);
assert!(remove_node(&mut f, &[0]));
assert_eq!(
f.nodes.iter().map(|n| n.request_name()).collect::<Vec<_>>(),
vec![Some("B")]
);
}
#[test]
fn move_node_swaps_siblings_and_reports_boundaries() {
let mut f = flow("REQUEST A\nREQUEST B\n");
let np = move_node(&mut f, &[1], true).expect("can move up");
assert_eq!(np, vec![0]);
assert_eq!(
f.nodes.iter().map(|n| n.request_name()).collect::<Vec<_>>(),
vec![Some("B"), Some("A")]
);
assert!(move_node(&mut f, &[0], true).is_none());
}
#[test]
fn replace_node_keeps_a_loops_body() {
let mut f = flow("FOR X IN FILES \"/a\"\n REQUEST Inner\nEND\n");
let edited = parse_one_node("FOR X IN FILES \"/b\"", true).expect("loop parses");
assert!(replace_node(&mut f, &[0], edited));
let body = match &f.nodes[0] {
FlowNode::ForEach { body, .. } => body,
other => panic!("expected a loop, got {other:?}"),
};
assert_eq!(body.len(), 1);
assert_eq!(body[0].request_name(), Some("Inner"));
assert!(f.to_text().contains("\"/b\""));
}
#[test]
fn parse_one_node_needs_exactly_one_statement() {
assert!(parse_one_node("REQUEST A", false).is_some());
assert!(parse_one_node("FOR X IN FILES \"/d\"", true).is_some());
assert!(parse_one_node("REQUEST A\nREQUEST B", false).is_none());
assert!(parse_one_node("FOR", false).is_none());
assert!(parse_one_node(" ", false).is_none());
}
#[test]
fn parse_one_node_accepts_report_var_as() {
use crate::report::flow::ReportStmt;
match parse_one_node("REPORT FILE AS \"Pretty name\"", false) {
Some(FlowNode::Report(ReportStmt::VarAs { var, name, .. })) => {
assert_eq!(var, "FILE");
assert_eq!(name, "Pretty name");
}
other => panic!("expected a VarAs node, got {other:?}"),
}
}
#[test]
fn report_modifier_wraps_and_unwraps_a_request() {
let mut f = flow("REQUEST login\n");
assert!(Modifier::Report.applies_to(node_at(&f, &[0]).unwrap()));
assert!(attach_modifier(&mut f, &[0], Modifier::Report));
assert!(matches!(
node_at(&f, &[0]),
Some(FlowNode::Report(ReportStmt::Request { .. }))
));
assert!(!Modifier::Report.applies_to(node_at(&f, &[0]).unwrap()));
assert!(!detach_modifier(&mut f, &[0], DetachWhich::Report));
assert!(matches!(node_at(&f, &[0]), Some(FlowNode::Request { .. })));
}
#[test]
fn detaching_report_from_a_variable_asks_to_remove_the_row() {
let mut f = flow("REPORT userId\n");
assert!(detach_modifier(&mut f, &[0], DetachWhich::Report));
}
#[test]
fn set_loop_var_renames_a_single_binder_and_rejects_names_that_would_not_parse() {
let mut f = flow("FOR file IN FILES \".\" MATCH \"*.json\"\n REQUEST A\nEND\n");
assert!(set_loop_var(&mut f, &[0], "doc"));
assert!(
f.to_text().contains("FOR doc IN FILES"),
"the rename reached the source: {}",
f.to_text()
);
assert!(
!set_loop_var(&mut f, &[0], "doc"),
"no change is not a change"
);
for bad in ["", " ", "my file", "2fast", "a-b"] {
assert!(
!set_loop_var(&mut f, &[0], bad),
"{bad:?} is not an identifier and must be refused"
);
}
assert!(
f.to_text().contains("FOR doc IN FILES"),
"and a refused name leaves the loop alone"
);
let mut e = flow("FOR t IN ENVS BASELINE(\"prod\")\n REQUEST A\nEND\n");
assert!(set_loop_var(&mut e, &[0], "target"));
assert!(e.to_text().contains("FOR target IN ENVS"));
}
#[test]
fn set_loop_var_refuses_a_pattern_that_binds_more_than_one_name() {
let mut f = flow("FOR (NAME, URL) IN DOCS\n REQUEST A\nEND\n");
assert!(!set_loop_var(&mut f, &[0], "x"));
assert!(
f.to_text().contains("FOR (NAME, URL) IN"),
"the pattern is untouched: {}",
f.to_text()
);
let mut r = flow("FOR (HEAD, ...) IN DOCS\n REQUEST A\nEND\n");
assert!(!set_loop_var(&mut r, &[0], "x"));
let mut d = flow("FOR _ IN FILES \".\"\n REQUEST A\nEND\n");
assert!(!set_loop_var(&mut d, &[0], "x"));
}
#[test]
fn the_loop_folder_can_be_read_and_repointed_for_the_producers_that_have_one() {
let mut files = flow("FOR f IN FILES \"cases\" MATCH \"*.json\"\n REQUEST A\nEND\n");
assert_eq!(loop_dir(&files, &[0]).as_deref(), Some("cases"));
assert!(set_loop_dir(&mut files, &[0], "other/cases"));
assert!(files.to_text().contains("FILES \"other/cases\""));
assert!(
!set_loop_dir(&mut files, &[0], " "),
"clearing the folder would silently mean the working directory"
);
let mut folders = flow("FOR d IN FOLDERS \"envs\"\n REQUEST A\nEND\n");
assert_eq!(loop_dir(&folders, &[0]).as_deref(), Some("envs"));
assert!(set_loop_dir(&mut folders, &[0], "environments"));
assert!(folders.to_text().contains("FOLDERS \"environments\""));
let mut tuples = flow("FOR t IN TUPLES FROM \"rows.csv\"\n REQUEST A\nEND\n");
assert_eq!(loop_dir(&tuples, &[0]).as_deref(), Some("rows.csv"));
assert!(set_loop_dir(&mut tuples, &[0], "data/rows.csv"));
assert!(tuples.to_text().contains("TUPLES FROM \"data/rows.csv\""));
let list = flow("FOR x IN [\"a\", \"b\"]\n REQUEST A\nEND\n");
assert_eq!(loop_dir(&list, &[0]), None);
}
#[test]
fn the_loop_glob_can_be_set_and_cleared() {
let mut f = flow("FOR f IN FILES \"cases\"\n REQUEST A\nEND\n");
assert!(!f.to_text().contains("MATCH"));
assert!(set_loop_glob(&mut f, &[0], "*.json"));
assert!(f.to_text().contains("MATCH \"*.json\""));
assert!(set_loop_glob(&mut f, &[0], ""));
assert!(
!f.to_text().contains("MATCH"),
"clearing the box drops the clause: {}",
f.to_text()
);
}
#[test]
fn set_parallel_degree_edits_the_concurrency_limit_and_rejects_zero() {
let mut f = flow("PARALLEL FOR X IN FILES \"/d\"\n REQUEST A\nEND\n");
assert!(set_parallel_degree(&mut f, &[0], Some(4)));
assert!(f.to_text().contains("PARALLEL(4) FOR"));
assert!(set_parallel_degree(&mut f, &[0], None));
assert!(f.to_text().contains("PARALLEL FOR"));
assert!(set_parallel_degree(&mut f, &[0], Some(4)));
assert!(!set_parallel_degree(&mut f, &[0], Some(0)));
assert!(f.to_text().contains("PARALLEL(4) FOR"));
}
#[test]
fn set_header_adds_edits_and_removes_directives() {
let mut f = flow(
"# collection: api.hurl
REQUEST A
",
);
assert!(set_header_nth(&mut f, "collection", 0, Some("other.hurl")));
assert!(f.to_text().contains("# collection: other.hurl"));
assert_eq!(f.header.collection(), Some("other.hurl"));
assert!(set_header_nth(&mut f, "output", 0, Some("out.csv")));
assert_eq!(f.header.output(), Some("out.csv"));
let text = f.to_text();
assert!(
text.find("# collection:") < text.find("# output:"),
"new directives are appended after the existing ones: {text:?}"
);
assert!(!set_header_nth(&mut f, "output", 0, Some("out.csv")));
assert!(set_header_nth(&mut f, "output", 0, None));
assert_eq!(f.header.output(), None);
assert!(!f.to_text().contains("# output"));
assert!(!set_header_nth(&mut f, "output", 0, None));
assert!(!set_header_nth(&mut f, "root", 0, Some(" ")));
assert_eq!(f.header.root(), None);
}
#[test]
fn set_header_leaves_free_form_comments_alone() {
let mut f = flow(
"# collection: api.hurl
# a note to self
REQUEST A
",
);
assert!(set_header_nth(&mut f, "environment", 0, Some("dev")));
let text = f.to_text();
assert!(text.contains("# a note to self"), "{text:?}");
assert!(
text.find("# environment:") < text.find("# a note"),
"the new directive joins the directive block, above the notes: {text:?}"
);
}
#[test]
fn a_degree_cannot_be_set_on_a_loop_that_is_not_parallel() {
let mut f = flow("FOR X IN FILES \"/d\"\n REQUEST A\nEND\n");
assert!(!set_parallel_degree(&mut f, &[0], Some(2)));
assert!(!f.to_text().contains("PARALLEL"));
let mut g = flow("REQUEST A\n");
assert!(!set_parallel_degree(&mut g, &[0], Some(2)));
}
#[test]
fn parallel_modifier_toggles_a_loop() {
let mut f = flow("FOR X IN FILES \"/d\"\n REQUEST A\nEND\n");
assert!(Modifier::Parallel.applies_to(node_at(&f, &[0]).unwrap()));
assert!(attach_modifier(&mut f, &[0], Modifier::Parallel));
assert!(matches!(
node_at(&f, &[0]),
Some(FlowNode::ForEach {
parallel: Some(_),
..
})
));
assert!(!Modifier::Parallel.applies_to(node_at(&f, &[0]).unwrap()));
assert!(!detach_modifier(&mut f, &[0], DetachWhich::Parallel));
assert!(matches!(
node_at(&f, &[0]),
Some(FlowNode::ForEach { parallel: None, .. })
));
}
#[test]
fn with_modifier_adds_and_removes_a_report_request_field() {
let mut f = flow("REPORT REQUEST analyze\n");
assert!(Modifier::With.applies_to(node_at(&f, &[0]).unwrap()));
assert!(attach_modifier(&mut f, &[0], Modifier::With));
match node_at(&f, &[0]) {
Some(FlowNode::Report(ReportStmt::Request { with, .. })) => {
assert_eq!(with.len(), 1);
}
other => panic!("expected a report request with a field, got {other:?}"),
}
assert!(!detach_modifier(&mut f, &[0], DetachWhich::With(0)));
match node_at(&f, &[0]) {
Some(FlowNode::Report(ReportStmt::Request { with, .. })) => assert!(with.is_empty()),
other => panic!("expected an empty WITH, got {other:?}"),
}
}
#[test]
fn as_modifier_names_a_request_alias_and_a_variable_column() {
let mut f = flow("REPORT REQUEST analyze\n");
assert!(attach_modifier(&mut f, &[0], Modifier::As));
assert!(matches!(
node_at(&f, &[0]),
Some(FlowNode::Report(ReportStmt::Request { alias: Some(_), .. }))
));
assert!(!Modifier::As.applies_to(node_at(&f, &[0]).unwrap()));
let mut g = flow("REPORT userId\n");
assert!(Modifier::As.applies_to(node_at(&g, &[0]).unwrap()));
assert!(attach_modifier(&mut g, &[0], Modifier::As));
assert!(matches!(
node_at(&g, &[0]),
Some(FlowNode::Report(ReportStmt::VarAs { .. }))
));
assert!(!detach_modifier(&mut g, &[0], DetachWhich::As));
assert!(matches!(
node_at(&g, &[0]),
Some(FlowNode::Report(ReportStmt::Vars(_)))
));
}
#[test]
fn modifiers_do_not_apply_where_they_make_no_sense() {
let f = flow("k = v\n");
let n = node_at(&f, &[0]).unwrap();
assert!(Modifier::Report.applies_to(n));
assert!(!Modifier::Parallel.applies_to(n));
assert!(!Modifier::With.applies_to(n));
assert!(!Modifier::As.applies_to(n));
}
#[test]
fn report_is_load_bearing_on_a_column_but_not_on_a_request() {
let flow =
parse_flow("REPORT REQUEST A\nREPORT TIER AS Plan\nREPORT \"x\" AS c\nREPORT (A, B)\n")
.expect("fixture parses");
assert!(
detach_leaves_statement(&flow.nodes[0], DetachWhich::Report),
"a reported request downgrades to a plain REQUEST, so REPORT snaps off"
);
for (i, what) in [
(1, "REPORT … AS"),
(2, "a computed column"),
(3, "REPORT (…)"),
] {
assert!(
!detach_leaves_statement(&flow.nodes[i], DetachWhich::Report),
"nothing is left of {what} without REPORT, so it must move the whole row"
);
}
}
#[test]
fn statistics_attaches_to_a_named_column_and_only_once() {
let mut flow = parse_flow("REPORT TIER AS Plan\nREPORT (A, B)\n").expect("fixture parses");
assert!(
attach_modifier(&mut flow, &[0], Modifier::Statistics),
"a named column accepts STATISTICS"
);
assert!(
flow.to_text().contains("STATISTICS("),
"the clause is written out: {}",
flow.to_text()
);
assert!(
!attach_modifier(&mut flow, &[0], Modifier::Statistics),
"a column that already has STATISTICS refuses a second one"
);
assert!(
!attach_modifier(&mut flow, &[1], Modifier::Statistics),
"REPORT (A, B) names no single column"
);
detach_modifier(&mut flow, &[0], DetachWhich::Statistics);
assert!(
!flow.to_text().contains("STATISTICS(") && flow.to_text().contains("AS Plan"),
"detaching leaves the column itself alone: {}",
flow.to_text()
);
}
#[test]
fn statistics_attaches_to_a_with_field() {
let mut flow =
parse_flow("REPORT REQUEST svc WITH\n Elapsed: Time\n RESPONSE RAW\nEND\n")
.expect("fixture parses");
assert!(
!attach_modifier(&mut flow, &[0], Modifier::Statistics),
"a report request names no single column"
);
assert!(
with_stats_applies(with_of(&flow), 0),
"a named field takes it"
);
assert!(attach_with_stats(&mut flow, &[0], 0));
assert!(
flow.to_text().contains("Elapsed: Time STATISTICS(COUNT)"),
"the clause lands on the field: {}",
flow.to_text()
);
assert!(!with_stats_applies(with_of(&flow), 0), "already has one");
assert!(!attach_with_stats(&mut flow, &[0], 0));
assert!(
!with_stats_applies(with_of(&flow), 1),
"RESPONSE RAW is not a column"
);
assert!(!attach_with_stats(&mut flow, &[0], 1));
assert!(!attach_with_stats(&mut flow, &[0], 9), "no such field");
let again = parse_flow(&flow.to_text()).expect("reparses");
assert_eq!(again.to_text(), flow.to_text());
}
fn with_of(flow: &ReportFlow) -> &[WithItem] {
match &flow.nodes[0] {
FlowNode::Report(ReportStmt::Request { with, .. }) => with,
other => panic!("expected a report request, got {other:?}"),
}
}
#[test]
fn a_duplicate_modifier_is_refused_as_a_duplicate_not_as_a_wrong_block() {
let flow = parse_flow("REPORT REQUEST A\nREQUEST B\nREPORT TIER AS Plan\n")
.expect("fixture parses");
let s = Strings::english();
assert_eq!(
Modifier::Report.reject_reason(&flow.nodes[0], s),
Some(s.mod_reject_present),
"an already-reported request has REPORT, it isn't the wrong shape for it"
);
assert_eq!(
Modifier::Report.reject_reason(&flow.nodes[2], s),
Some(s.mod_reject_present),
"a reported column is a REPORT statement too"
);
assert_eq!(
Modifier::Report.reject_reason(&flow.nodes[1], s),
None,
"a plain request still takes REPORT"
);
let carried = carry_modifier(&flow.nodes[0], DetachWhich::Report).expect("carries REPORT");
assert_eq!(
carried.reject_reason(&flow.nodes[0], s),
Some(s.mod_reject_present)
);
}
#[test]
fn a_show_dragged_to_another_request_brings_its_columns_with_it() {
let mut flow = parse_flow("REPORT REQUEST A SHOW(Time, HttpStatus)\nREPORT REQUEST B\n")
.expect("fixture parses");
assert!(
transfer_modifier(&mut flow, &[0], DetachWhich::Show, &[1], false),
"an as-yet SHOW-less reported request accepts the clause"
);
let text = flow.to_text();
assert!(
text.contains("REQUEST B SHOW(Time, HttpStatus)"),
"the columns travel with the clause: {text}"
);
assert!(
!text.contains("REQUEST A SHOW"),
"a move leaves nothing behind on the source line: {text}"
);
assert!(
!transfer_modifier(&mut flow, &[1], DetachWhich::Show, &[1], false),
"a line never transfers a clause to itself"
);
}
#[test]
fn a_copied_parallel_clones_its_degree_and_leaves_the_original_alone() {
let mut flow = parse_flow(
"PARALLEL(4) FOR X IN FILES \"/a\"\n REQUEST A\nEND\nFOR Y IN FILES \"/b\"\n REQUEST B\nEND\n",
)
.expect("fixture parses");
assert!(
transfer_modifier(&mut flow, &[0], DetachWhich::Parallel, &[1], true),
"a plain loop accepts a copied PARALLEL"
);
let text = flow.to_text();
assert_eq!(
text.matches("PARALLEL(4)").count(),
2,
"a copy keeps the original and reproduces its degree: {text}"
);
assert!(
!transfer_modifier(&mut flow, &[0], DetachWhich::Parallel, &[1], false),
"a loop that is already parallel takes no second PARALLEL"
);
assert!(
flow.to_text().matches("PARALLEL(4)").count() == 2,
"a refused transfer is not half-applied: {}",
flow.to_text()
);
}
#[test]
fn a_carried_clause_is_refused_by_a_block_that_cannot_hold_it() {
let flow = parse_flow("REPORT REQUEST A SHOW(Time)\nK = \"v\"\n").expect("parses");
let carried =
carry_modifier(&flow.nodes[0], DetachWhich::Show).expect("the SHOW is really there");
let s = Strings::english();
assert!(!carried.applies_to(&flow.nodes[1]), "SET has no columns");
assert_eq!(
carried.reject_reason(&flow.nodes[1], s),
Some(s.mod_reject_request_only),
"the refusal names the kind of block that would take it"
);
assert!(
carry_modifier(&flow.nodes[1], DetachWhich::Show).is_none(),
"a node without the clause carries nothing"
);
}
#[test]
fn detaching_the_baseline_show_leaves_the_rest_of_the_compare_loop() {
let mut flow = parse_flow(
"FOR E IN ENVS BASELINE(\"prod\") SHOW(Time), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
)
.expect("fixture parses");
assert!(
!detach_modifier(&mut flow, &[0], DetachWhich::BaselineShow),
"clearing SHOW never removes the loop itself"
);
let text = flow.to_text();
assert!(
!text.contains("SHOW(") && text.contains("BASELINE(") && text.contains("COMPARISON("),
"only the SHOW clause goes: {text}"
);
}
#[test]
fn detaching_a_comparison_role_degrades_the_loop_to_a_plain_pass() {
let mut flow = parse_flow(
"FOR E IN ENVS BASELINE(\"prod\"), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
)
.expect("fixture parses");
detach_modifier(
&mut flow,
&[0],
DetachWhich::Role {
baseline: false,
index: 0,
},
);
let text = flow.to_text();
assert!(
!text.contains("COMPARISON(") && !text.contains("BASELINE(") && text.contains("prod"),
"the surviving environment is still iterated: {text}"
);
assert!(
parse_flow(&text).is_ok(),
"the degraded loop re-parses: {text}"
);
}
#[test]
fn a_refused_modifier_says_whether_the_block_is_wrong_or_the_clause_is_already_there() {
let s = crate::i18n::Strings::english();
let assign = flow("k = v\n");
assert_eq!(
Modifier::Parallel.reject_reason(node_at(&assign, &[0]).unwrap(), s),
Some(s.mod_reject_parallel),
"PARALLEL on an assignment should point at FOR loops"
);
let looped = flow(
"PARALLEL FOR E IN ENVS BASELINE(\"prod\"), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
);
assert_eq!(
Modifier::Parallel.reject_reason(node_at(&looped, &[0]).unwrap(), s),
Some(s.mod_reject_present),
"an already-parallel loop should say the clause is already there"
);
let plain =
flow("FOR E IN ENVS BASELINE(\"prod\"), COMPARISON(\"stage\")\n REQUEST A\nEND\n");
assert_eq!(
Modifier::Parallel.reject_reason(node_at(&plain, &[0]).unwrap(), s),
None,
"a modifier that applies should give no refusal reason"
);
}
#[test]
fn renaming_a_report_request_preserves_its_modifiers() {
let mut f = flow("REPORT REQUEST analyze AS proc WITH\n latency: Time\nEND\n");
assert!(set_request_name(&mut f, &[0], "verify"));
match node_at(&f, &[0]) {
Some(FlowNode::Report(ReportStmt::Request {
name, alias, with, ..
})) => {
assert_eq!(name, "verify");
assert_eq!(alias.as_deref(), Some("proc"));
assert_eq!(with.len(), 1);
}
other => panic!("expected the report request kept its modifiers, got {other:?}"),
}
}
#[test]
fn detaching_response_show_hide_clears_only_that_clause() {
let mut f =
flow("REPORT REQUEST analyze RESPONSE RAW SHOW(Time, HttpStatus) HIDE(Response)\n");
assert!(!detach_modifier(&mut f, &[0], DetachWhich::Response));
assert!(!detach_modifier(&mut f, &[0], DetachWhich::Show));
assert!(!detach_modifier(&mut f, &[0], DetachWhich::Hide));
match node_at(&f, &[0]) {
Some(FlowNode::Report(ReportStmt::Request {
name,
response_fmt,
show,
hide,
..
})) => {
assert_eq!(name, "analyze");
assert!(response_fmt.is_none());
assert!(show.is_empty());
assert!(hide.is_empty());
}
other => panic!("expected the request kept only its name, got {other:?}"),
}
}
#[test]
fn response_show_hide_modifiers_attach_defaults_and_round_trip() {
let mut f = flow("REPORT REQUEST analyze\n");
assert!(Modifier::Response.applies_to(node_at(&f, &[0]).unwrap()));
assert!(Modifier::Show.applies_to(node_at(&f, &[0]).unwrap()));
assert!(Modifier::Hide.applies_to(node_at(&f, &[0]).unwrap()));
assert!(attach_modifier(&mut f, &[0], Modifier::Response));
assert!(attach_modifier(&mut f, &[0], Modifier::Show));
assert!(attach_modifier(&mut f, &[0], Modifier::Hide));
assert!(!Modifier::Response.applies_to(node_at(&f, &[0]).unwrap()));
assert!(!Modifier::Show.applies_to(node_at(&f, &[0]).unwrap()));
assert!(!Modifier::Hide.applies_to(node_at(&f, &[0]).unwrap()));
let reparsed = flow(&f.to_text());
match node_at(&reparsed, &[0]) {
Some(FlowNode::Report(ReportStmt::Request {
response_fmt,
show,
hide,
..
})) => {
assert_eq!(*response_fmt, Some(ResponseFmt::Pretty));
assert_eq!(show, &vec!["HttpStatus".to_string()]);
assert_eq!(hide, &vec!["HttpStatus".to_string()]);
}
other => panic!("expected a decorated report request, got {other:?}"),
}
}
#[test]
fn report_computed_kind_template_round_trips() {
let node = NodeKind::ReportComputed
.template()
.expect("computed kind has a template");
let mut f = flow("REQUEST A\n");
insert_node(
&mut f,
&InsertPos {
parent: Vec::new(),
index: 1,
},
node,
);
let reparsed = flow(&f.to_text());
match node_at(&reparsed, &[1]) {
Some(FlowNode::Report(ReportStmt::Computed { template, name, .. })) => {
assert!(!template.is_empty());
assert!(!name.is_empty());
}
other => panic!("expected a computed column, got {other:?}"),
}
}
#[test]
fn move_node_to_reorders_within_a_body_and_adjusts_the_index() {
let mut f = flow("REQUEST A\nREQUEST B\nREQUEST C\n");
let pos = InsertPos {
parent: Vec::new(),
index: 2,
};
let new = move_node_to(&mut f, &[0], &pos).expect("move should succeed");
assert_eq!(new, vec![1]);
let names: Vec<String> = f
.nodes
.iter()
.map(|n| match n {
FlowNode::Request { name, .. } => name.clone(),
_ => String::new(),
})
.collect();
assert_eq!(names, vec!["B", "A", "C"]);
}
#[test]
fn move_node_to_can_nest_into_a_loop_body() {
let mut f = flow("REQUEST A\nFOR X IN FILES \"/d\"\n REQUEST B\nEND\n");
let pos = InsertPos {
parent: vec![1],
index: 0,
};
let new = move_node_to(&mut f, &[0], &pos).expect("move should succeed");
assert_eq!(new, vec![0, 0]);
match &f.nodes[0] {
FlowNode::ForEach { body, .. } => {
assert_eq!(body.len(), 2);
assert!(matches!(&body[0], FlowNode::Request { name, .. } if name == "A"));
}
other => panic!("expected the loop, got {other:?}"),
}
}
#[test]
fn move_node_to_refuses_to_drop_a_loop_into_itself() {
let mut f = flow("FOR X IN FILES \"/d\"\n REQUEST B\nEND\n");
let pos = InsertPos {
parent: vec![0],
index: 0,
};
assert!(move_node_to(&mut f, &[0], &pos).is_none());
assert!(matches!(&f.nodes[0], FlowNode::ForEach { .. }));
}
#[test]
fn report_assignment_inserts_a_sibling_report_after_the_set() {
let mut f = flow("TOKEN=abc\nREQUEST A\n");
let new = report_assignment(&mut f, &[0]).expect("assign is reportable");
assert_eq!(new, vec![1]);
assert!(matches!(&f.nodes[0], FlowNode::Assign { key, .. } if key == "TOKEN"));
match &f.nodes[1] {
FlowNode::Report(ReportStmt::Vars(vars)) => {
assert_eq!(vars, &vec!["TOKEN".to_string()])
}
other => panic!("expected REPORT (TOKEN), got {other:?}"),
}
assert!(matches!(&f.nodes[2], FlowNode::Request { .. }));
}
#[test]
fn report_assignment_is_a_no_op_on_a_non_assignment() {
let mut f = flow("REQUEST A\n");
assert!(report_assignment(&mut f, &[0]).is_none());
assert_eq!(f.nodes.len(), 1);
}
#[test]
fn report_assignment_is_idempotent_when_already_reported() {
let mut f = flow("TOKEN=abc\n");
let first = report_assignment(&mut f, &[0]).expect("assign is reportable");
assert_eq!(first, vec![1]);
assert_eq!(f.nodes.len(), 2);
let again = report_assignment(&mut f, &[0]).expect("still reportable");
assert_eq!(again, vec![1]);
assert_eq!(f.nodes.len(), 2);
}
#[test]
fn set_env_role_rewrites_one_live_environment_reference() {
let mut f =
flow("FOR E IN ENVS BASELINE(\"prod\"), COMPARISON(\"stage\")\n REQUEST A\nEND\n");
assert!(set_env_role(&mut f, &[0], false, 0, "canary"));
match &f.nodes[0] {
FlowNode::ForEnvs {
clause:
EnvClause::Roles {
baseline,
comparisons,
..
},
..
} => {
assert_eq!(baseline, &vec![RoleRef::Env("prod".into())]);
assert_eq!(comparisons, &vec![RoleRef::Env("canary".into())]);
}
other => panic!("expected an ENVS compare loop, got {other:?}"),
}
}
#[test]
fn set_env_role_leaves_file_snapshots_and_plain_loops_alone() {
let mut f = flow(
"FOR E IN ENVS BASELINE(FILE(\"snap.baseline\")), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
);
assert!(!set_env_role(&mut f, &[0], true, 0, "prod"));
let mut g = flow("FOR E IN ENVS \"dev\", \"prod\"\n REQUEST A\nEND\n");
assert!(!set_env_role(&mut g, &[0], false, 0, "stage"));
}
#[test]
fn set_report_alias_sets_clears_and_requires() {
let mut f = flow("REPORT REQUEST analyze AS Result\n");
assert!(set_report_alias(&mut f, &[0], "Renamed"));
assert!(matches!(
node_at(&f, &[0]),
Some(FlowNode::Report(ReportStmt::Request { alias: Some(a), .. })) if a == "Renamed"
));
assert!(set_report_alias(&mut f, &[0], " "));
assert!(matches!(
node_at(&f, &[0]),
Some(FlowNode::Report(ReportStmt::Request { alias: None, .. }))
));
let mut g = flow("REPORT userId AS Id\n");
assert!(set_report_alias(&mut g, &[0], "UserId"));
assert!(matches!(
node_at(&g, &[0]),
Some(FlowNode::Report(ReportStmt::VarAs { name, .. })) if name == "UserId"
));
assert!(!set_report_alias(&mut g, &[0], ""));
assert!(matches!(
node_at(&g, &[0]),
Some(FlowNode::Report(ReportStmt::VarAs { name, .. })) if name == "UserId"
));
}
#[test]
fn add_and_set_with_field_edit_the_with_block() {
let mut f = flow("REPORT REQUEST analyze RESPONSE PRETTY\n");
assert_eq!(
add_with_field(&mut f, &[0], "Status", "HttpStatus", Vec::new()),
Some(0)
);
assert_eq!(
add_with_field(&mut f, &[0], "Body", "jsonpath \"$.x\"", Vec::new()),
Some(1)
);
match node_at(&f, &[0]) {
Some(FlowNode::Report(ReportStmt::Request { with, .. })) => assert_eq!(with.len(), 2),
other => panic!("expected a report request, got {other:?}"),
}
assert!(set_with_field(
&mut f,
&[0],
0,
"Code",
"HttpStatus",
vec![StatKind::Count, StatKind::Mean],
&ClauseForm::default(),
));
match node_at(&f, &[0]) {
Some(FlowNode::Report(ReportStmt::Request { with, .. })) => {
assert!(matches!(
&with[0],
WithItem::Field {
name, query, stats, ..
}
if name == "Code"
&& query == "HttpStatus"
&& stats == &[StatKind::Count, StatKind::Mean]
));
}
other => panic!("expected a report request, got {other:?}"),
}
assert!(f.to_text().contains("STATISTICS(COUNT, MEAN)"));
assert!(set_with_field(
&mut f,
&[0],
0,
"Code",
"HttpStatus",
Vec::new(),
&ClauseForm::default(),
));
assert!(!f.to_text().contains("STATISTICS"));
let mut g = flow("REPORT userId\n");
assert_eq!(add_with_field(&mut g, &[0], "X", "Y", Vec::new()), None);
assert!(!set_with_field(
&mut g,
&[0],
0,
"X",
"Y",
Vec::new(),
&ClauseForm::default()
));
}
}
#[cfg(test)]
mod repeatable_header_tests {
use super::*;
use crate::report::parser::parse_flow;
fn flow(src: &str) -> ReportFlow {
parse_flow(src).expect("parses")
}
#[test]
fn editing_a_helper_leaves_the_primary_collection_alone() {
let mut f = flow("# collection: ./api.hurl\n# collection: ./a.hurl AS a\n\nREQUEST x\n");
assert!(set_header_nth(
&mut f,
"collection",
1,
Some("./b.hurl AS b")
));
let all = f.header.get_all("collection");
assert_eq!(all, vec!["./api.hurl", "./b.hurl AS b"]);
}
#[test]
fn clearing_one_helper_keeps_the_others() {
let mut f = flow(
"# collection: ./api.hurl\n# collection: ./a.hurl AS a\n# collection: ./b.hurl AS b\n\nREQUEST x\n",
);
assert!(set_header_nth(&mut f, "collection", 1, None));
assert_eq!(
f.header.get_all("collection"),
vec!["./api.hurl", "./b.hurl AS b"]
);
}
#[test]
fn setting_past_the_end_appends_another_directive() {
let mut f = flow("# collection: ./api.hurl\n\nREQUEST x\n");
assert!(set_header_nth(
&mut f,
"collection",
1,
Some("./h.hurl AS h")
));
assert_eq!(
f.header.get_all("collection"),
vec!["./api.hurl", "./h.hurl AS h"]
);
assert!(
f.to_text()
.starts_with("# collection: ./api.hurl\n# collection: ./h.hurl AS h\n"),
"{:?}",
f.to_text()
);
}
#[test]
fn set_header_still_edits_the_first_occurrence() {
let mut f = flow("# collection: ./api.hurl\n# collection: ./a.hurl AS a\n\nREQUEST x\n");
assert!(set_header_nth(&mut f, "collection", 0, Some("./new.hurl")));
assert_eq!(
f.header.get_all("collection"),
vec!["./new.hurl", "./a.hurl AS a"]
);
}
fn ov(target: &str, value: &str) -> UsingItem {
UsingItem::Override {
target: crate::report::flow::OverrideTarget::parse(target).unwrap(),
value: value.into(),
}
}
fn decl(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
#[test]
fn override_rows_round_trip_through_the_form() {
let using = vec![
UsingItem::Require("FILE".into()),
ov("multipart.document", "{{FILE}}"),
ov("header.X-Trace", "abc"),
];
let rows = override_rows(&using);
assert_eq!(
rows.iter()
.map(|r| (r.target.as_str(), r.value.as_str()))
.collect::<Vec<_>>(),
vec![
("multipart.document", "{{FILE}}"),
("header.X-Trace", "abc")
],
"the requirement is not an override"
);
assert_eq!(override_items(&rows), using[1..].to_vec());
}
#[test]
fn a_target_is_wrong_only_once_it_cannot_work() {
assert!(
override_target_valid(""),
"an empty box isn't filled in yet"
);
assert!(override_target_valid("multipart.document"));
assert!(override_target_valid("url"));
assert!(!override_target_valid("multipart"), "a section needs a key");
assert!(!override_target_valid("nonsense.x"));
}
#[test]
fn an_unusable_override_row_never_reaches_the_clause() {
let rows = vec![
OverrideRow {
target: "multipart.document".into(),
value: "{{FILE}}".into(),
},
OverrideRow::default(),
OverrideRow {
target: "nonsense.x".into(),
value: "v".into(),
},
];
assert_eq!(
override_items(&rows),
vec![ov("multipart.document", "{{FILE}}")]
);
}
#[test]
fn a_form_builds_requirements_and_overrides_into_one_clause() {
let params = param_rows(
&decl(&[("FILE", "./s.pdf")]),
&[UsingItem::Require("FILE".into())],
);
let rows = vec![OverrideRow {
target: "header.X-Trace".into(),
value: "{{ID}}".into(),
}];
let out = using_items(¶ms, &override_items(&rows));
assert_eq!(
out.iter().map(UsingItem::text).collect::<Vec<_>>(),
vec!["FILE", "header.X-Trace = \"{{ID}}\""]
);
}
#[test]
fn the_checklist_offers_every_declared_parameter_and_ticks_the_required_ones() {
let rows = param_rows(
&decl(&[("FILE", "./sample.pdf"), ("KIND", "invoice")]),
&[UsingItem::Require("FILE".into())],
);
let seen: Vec<(&str, bool, Option<&str>)> = rows
.iter()
.map(|r| (r.name.as_str(), r.required, r.default.as_deref()))
.collect();
assert_eq!(
seen,
vec![
("FILE", true, Some("./sample.pdf")),
("KIND", false, Some("invoice")),
]
);
}
#[test]
fn a_requirement_the_request_does_not_declare_is_kept_as_an_undeclared_row() {
let rows = param_rows(
&decl(&[("FILE", "./x")]),
&[UsingItem::Require("NOPE".into())],
);
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].name, "NOPE");
assert!(rows[1].required);
assert!(
rows[1].default.is_none(),
"no default is what marks the row as undeclared"
);
}
#[test]
fn rebuilding_the_clause_preserves_the_overrides_the_checklist_has_no_row_for() {
let carried = vec![
UsingItem::Require("FILE".into()),
ov("multipart.document", "{{FILE}}"),
ov("header.X-Run", "{{RUN}}"),
];
let rows = param_rows(&decl(&[("FILE", "./x")]), &carried);
let out = using_items(&rows, &carried);
assert_eq!(
out.iter().map(UsingItem::text).collect::<Vec<_>>(),
vec![
"FILE".to_string(),
"multipart.document = \"{{FILE}}\"".to_string(),
"header.X-Run = \"{{RUN}}\"".to_string(),
]
);
}
#[test]
fn ticking_and_unticking_only_moves_requirements() {
let carried = vec![UsingItem::Require("FILE".into()), ov("header.X-Run", "1")];
let mut rows = param_rows(&decl(&[("FILE", "./x"), ("KIND", "invoice")]), &carried);
rows[0].required = false;
rows[1].required = true;
assert_eq!(
using_items(&rows, &carried)
.iter()
.map(UsingItem::text)
.collect::<Vec<_>>(),
vec!["KIND".to_string(), "header.X-Run = \"1\"".to_string()]
);
}
#[test]
fn rebuilding_groups_the_requirements_before_the_overrides() {
let carried = vec![ov("header.X-Run", "1"), UsingItem::Require("FILE".into())];
let rows = param_rows(&decl(&[("FILE", "./x")]), &carried);
assert_eq!(
using_items(&rows, &carried)
.iter()
.map(UsingItem::text)
.collect::<Vec<_>>(),
vec!["FILE".to_string(), "header.X-Run = \"1\"".to_string()]
);
}
#[test]
fn an_ordinary_request_has_no_checklist() {
assert!(param_rows(&[], &[]).is_empty());
assert!(param_rows(&[], &[ov("url", "http://x")]).is_empty());
}
}