use crate::i18n::Strings;
use crate::report::flow::{
EnvClause, FlowNode, HeaderLine, ParallelSpec, Pattern, Producer, ReportFlow, ReportStmt,
ResponseFmt, RoleRef, WithItem,
};
use crate::report::model::StatKind;
use crate::report::parse_flow;
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,
}
pub(crate) fn flatten(flow: &ReportFlow, resolves: &impl Fn(&str) -> 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, &mut rows);
rows
}
fn push_nodes(
nodes: &[FlowNode],
prefix: &mut Vec<usize>,
depth: usize,
resolves: &impl Fn(&str) -> 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, rows);
rows.push(NodeRow {
depth,
label: String::new(),
kind: RowKind::LoopEnd,
path: prefix.clone(),
req_ok: None,
});
} else {
rows.push(NodeRow {
depth,
label: node.label(),
kind: RowKind::Leaf,
path: prefix.clone(),
req_ok,
});
}
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::LoopEnd => {
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(),
}),
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(),
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,
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
})
} else {
FlowNode::Request {
name: name.to_string(),
}
}
}
#[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,
}
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, &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: &[String],
) -> 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, &mut names);
}
names
.iter()
.map(|n| (n.clone(), selected.iter().any(|sel| sel == 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 } = node {
let name = std::mem::take(name);
*node = FlowNode::Report(ReportStmt::Request {
name,
alias: None,
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(),
});
}
}
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(),
});
}
_ => {}
},
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 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_header(flow: &mut ReportFlow, key: &str, value: Option<&str>) -> bool {
let value = value.map(str::trim).filter(|v| !v.is_empty());
let existing = flow.header.lines.iter().position(
|l| matches!(l, HeaderLine::Directive { key: k, .. } if k.eq_ignore_ascii_case(key)),
);
match (existing, 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)) => {
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: v.to_string(),
},
);
true
}
(None, None) => false,
}
}
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,
});
Some(with.len() - 1)
} else {
None
}
}
pub(crate) fn set_with_field(
flow: &mut ReportFlow,
path: &[usize],
index: usize,
name: &str,
query: &str,
stats: Vec<StatKind>,
) -> bool {
if let Some(FlowNode::Report(ReportStmt::Request { with, .. })) = node_at_mut(flow, path)
&& let Some(WithItem::Field {
name: n,
query: q,
stats: st,
}) = with.get_mut(index)
{
*n = name.to_string();
*q = query.to_string();
*st = stats;
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<String>),
Hide(Vec<String>),
BaselineShow(Vec<String>),
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 } = node {
let name = std::mem::take(name);
*node = FlowNode::Report(ReportStmt::Request {
name,
alias: None,
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(),
});
}
_ => {}
},
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, .. }) => {
let name = std::mem::take(name);
*node = FlowNode::Request { name };
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
}
}
}
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 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_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(&mut f, "collection", Some("other.hurl")));
assert!(f.to_text().contains("# collection: other.hurl"));
assert_eq!(f.header.collection(), Some("other.hurl"));
assert!(set_header(&mut f, "output", 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(&mut f, "output", Some("out.csv")));
assert!(set_header(&mut f, "output", None));
assert_eq!(f.header.output(), None);
assert!(!f.to_text().contains("# output"));
assert!(!set_header(&mut f, "output", None));
assert!(!set_header(&mut f, "root", 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(&mut f, "environment", 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 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],
));
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()
));
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()));
}
}