use std::collections::{HashMap, HashSet};
use super::flow::{FlowNode, ReportFlow, ReportStmt, UsingItem};
use super::run::{HelperCollection, resolve_qualified};
use crate::hurl::HurlEntry;
use crate::i18n::{Strings, fill};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EdgeKind {
Data(String),
Declared,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edge {
pub from: usize,
pub to: usize,
pub kind: EdgeKind,
}
#[derive(Debug, Clone)]
pub struct Step {
pub name: String,
pub request: String,
pub written: usize,
}
#[derive(Debug, Clone)]
pub struct Plan {
pub steps: Vec<Step>,
pub edges: Vec<Edge>,
pub order: Vec<usize>,
pub waves: Vec<Vec<usize>>,
}
impl Plan {
pub fn ancestors(&self, idx: usize) -> Vec<usize> {
let mut seen = HashSet::new();
let mut stack = vec![idx];
while let Some(n) = stack.pop() {
for e in self.edges.iter().filter(|e| e.to == n) {
if seen.insert(e.from) {
stack.push(e.from);
}
}
}
self.order
.iter()
.copied()
.filter(|i| seen.contains(i))
.collect()
}
pub fn incoming(&self, idx: usize) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.to == idx).collect()
}
}
pub fn step_name(node: &FlowNode) -> Option<(String, &str)> {
let (name, alias) = match node {
FlowNode::Request { name, alias, .. } => (name, alias),
FlowNode::Report(ReportStmt::Request { name, alias, .. }) => (name, alias),
_ => return None,
};
let step = match alias {
Some(a) => a.clone(),
None => name.rsplit('/').next().unwrap_or(name).to_string(),
};
Some((step, name.as_str()))
}
pub fn declared_deps(node: &FlowNode) -> &[String] {
match node {
FlowNode::Request { depends, .. } => depends,
FlowNode::Report(ReportStmt::Request { depends, .. }) => depends,
_ => &[],
}
}
fn effective_entry(
entries: &[HurlEntry],
helpers: &[HelperCollection],
name: &str,
using: &[UsingItem],
) -> Option<HurlEntry> {
let mut entry = resolve_qualified(entries, helpers, name)?.clone();
for item in using {
if let UsingItem::Override { target, value } = item {
let _ = crate::report::run::apply_override(&mut entry, target, value.clone());
}
}
Some(entry)
}
fn produced_names(entry: &HurlEntry) -> Vec<String> {
let mut out: Vec<String> = entry
.captures
.iter()
.map(|(c, _)| c.clone())
.chain(entry.generators.iter().map(|(g, _)| g.clone()))
.collect();
out.sort();
out.dedup();
out
}
fn using_values(node: &FlowNode) -> &[UsingItem] {
match node {
FlowNode::Request { using, .. } => using,
FlowNode::Report(ReportStmt::Request { using, .. }) => using,
FlowNode::Cleanup { using, .. } => using,
_ => &[],
}
}
pub fn build(
body: &[FlowNode],
entries: &[HurlEntry],
helpers: &[HelperCollection],
strings: &Strings,
) -> Result<Plan, Vec<String>> {
let mut steps = Vec::new();
for (written, node) in body.iter().enumerate() {
if let Some((name, request)) = step_name(node) {
steps.push(Step {
name,
request: request.to_string(),
written,
});
}
}
let by_name: HashMap<&str, usize> = steps
.iter()
.enumerate()
.map(|(i, s)| (s.name.as_str(), i))
.collect();
let mut producers: HashMap<String, Vec<usize>> = HashMap::new();
for (i, step) in steps.iter().enumerate() {
let Some(entry) = resolve_qualified(entries, helpers, &step.request) else {
continue; };
for name in produced_names(entry) {
producers.entry(name).or_default().push(i);
}
}
let mut errors = Vec::new();
let mut edges: Vec<Edge> = Vec::new();
for (i, step) in steps.iter().enumerate() {
for dep in declared_deps(&body[step.written]) {
match by_name.get(dep.as_str()) {
Some(&j) if j == i => {
errors.push(fill(strings.diag_graph_depends_self, &[&step.name]))
}
Some(&j) => add_edge(&mut edges, j, i, EdgeKind::Declared),
None => errors.push(fill(strings.diag_graph_depends_unknown, &[&step.name, dep])),
}
}
}
for (i, step) in steps.iter().enumerate() {
let node = &body[step.written];
let mut refs: Vec<String> = Vec::new();
if let Some(entry) = effective_entry(entries, helpers, &step.request, using_values(node)) {
let mut own: Vec<String> = crate::request::entry_referenced_keys(&entry)
.into_iter()
.collect();
own.sort();
refs.extend(own);
}
for item in using_values(node) {
if let UsingItem::Override { value, .. } = item {
refs.extend(crate::environment::referenced_keys(value));
}
}
for r in refs {
if let Some((qual, var)) = r.split_once('.') {
if let Some(&j) = by_name.get(qual)
&& j != i
{
add_edge(&mut edges, j, i, EdgeKind::Data(var.to_string()));
}
continue;
}
let Some(from) = producers.get(&r) else {
continue; };
let candidates: Vec<usize> = from.iter().copied().filter(|&j| j != i).collect();
match candidates.len() {
0 => {}
1 => add_edge(&mut edges, candidates[0], i, EdgeKind::Data(r.clone())),
n => {
let names: Vec<&str> =
candidates.iter().map(|&j| steps[j].name.as_str()).collect();
errors.push(fill(
strings.diag_graph_ambiguous_capture,
&[&step.name, &r, &n.to_string(), &names.join(", "), &r],
));
}
}
}
}
if !errors.is_empty() {
return Err(errors);
}
match toposort(&steps, &edges) {
Ok((order, waves)) => Ok(Plan {
steps,
edges,
order,
waves,
}),
Err(cycle) => {
let names: Vec<&str> = cycle.iter().map(|&i| steps[i].name.as_str()).collect();
Err(vec![fill(strings.diag_graph_cycle, &[&names.join(" → ")])])
}
}
}
fn add_edge(edges: &mut Vec<Edge>, from: usize, to: usize, kind: EdgeKind) {
if !edges.iter().any(|e| e.from == from && e.to == to) {
edges.push(Edge { from, to, kind });
}
}
type Sorted = (Vec<usize>, Vec<Vec<usize>>);
fn toposort(steps: &[Step], edges: &[Edge]) -> Result<Sorted, Vec<usize>> {
let n = steps.len();
let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
for e in edges {
preds[e.to].push(e.from);
}
let mut done = vec![false; n];
let mut order = Vec::with_capacity(n);
while order.len() < n {
let next = (0..n).find(|&i| !done[i] && preds[i].iter().all(|&p| done[p]));
match next {
Some(i) => {
done[i] = true;
order.push(i);
}
None => return Err((0..n).filter(|&i| !done[i]).collect()),
}
}
let mut depth = vec![0usize; n];
for &i in &order {
depth[i] = preds[i].iter().map(|&p| depth[p] + 1).max().unwrap_or(0);
}
let mut waves: Vec<Vec<usize>> = vec![Vec::new(); depth.iter().copied().max().unwrap_or(0) + 1];
if n > 0 {
let mut by_written: Vec<usize> = (0..n).collect();
by_written.sort_by_key(|&i| steps[i].written);
for i in by_written {
waves[depth[i]].push(i);
}
} else {
waves.clear();
}
Ok((order, waves))
}
pub fn explain(
flow: &ReportFlow,
entries: &[HurlEntry],
helpers: &[HelperCollection],
strings: &Strings,
) -> Vec<String> {
let mut out = Vec::new();
for node in &flow.nodes {
let FlowNode::Graph {
name,
body,
parallel,
} = node
else {
continue;
};
let title = match name {
Some(n) => format!("GRAPH {n}"),
None => "GRAPH".to_string(),
};
let concurrency = match parallel {
Some(p) => match p.degree {
Some(d) => format!(" · concurrency {d}"),
None => " · concurrency default".to_string(),
},
None => String::new(),
};
let plan = match build(body, entries, helpers, strings) {
Ok(p) => p,
Err(errs) => {
out.push(format!("{title} — cannot be ordered"));
out.extend(errs.into_iter().map(|e| format!(" {e}")));
continue;
}
};
out.push(format!("{title} · {} steps{concurrency}", plan.steps.len()));
for (w, wave) in plan.waves.iter().enumerate() {
for (row, &idx) in wave.iter().enumerate() {
let label = if row == 0 {
format!("wave {w}")
} else {
String::new()
};
let mut why: Vec<String> = plan
.incoming(idx)
.iter()
.map(|e| match &e.kind {
EdgeKind::Data(var) => {
format!("data: {}.{var}", plan.steps[e.from].name)
}
EdgeKind::Declared => {
format!("declared: {}", plan.steps[e.from].name)
}
})
.collect();
why.sort();
let why = if why.is_empty() {
String::new()
} else {
format!(" ({})", why.join(", "))
};
out.push(format!(" {label:<8} {:<34}{why}", plan.steps[idx].name));
}
}
out.push(" within a wave, order and completion are not guaranteed".into());
}
out
}
fn scope_captures(
nodes: &[FlowNode],
entries: &[HurlEntry],
helpers: &[HelperCollection],
cleanups: bool,
) -> HashSet<String> {
let mut out = HashSet::new();
let mut visit = |nodes: &[FlowNode]| {
for n in nodes {
let request = match n {
FlowNode::Assign { key, .. } => {
out.insert(key.clone());
continue;
}
FlowNode::Param(p) => {
out.insert(p.name.clone());
continue;
}
FlowNode::Cleanup { name, .. } => cleanups.then_some(name.as_str()),
_ => step_name(n).map(|(_, r)| r),
};
let self_read_only = matches!(n, FlowNode::Cleanup { .. });
if let Some(request) = request
&& let Some(e) = effective_entry(entries, helpers, request, using_values(n))
{
let reads = crate::request::entry_referenced_keys(&e);
out.extend(
produced_names(&e)
.into_iter()
.filter(|c| !self_read_only || !reads.contains(c.as_str())),
);
}
}
};
visit(nodes);
for n in nodes {
if let FlowNode::Graph { body, .. } = n {
visit(body);
}
}
out
}
fn declared_truths(flow: &ReportFlow) -> Vec<(String, Option<usize>)> {
let from_flow = flow.column_truths();
let mut origin: HashMap<String, usize> = HashMap::new();
let mut ord = 0usize;
for node in &flow.nodes {
if matches!(node, FlowNode::Cleanup { .. }) {
continue;
}
let mut here = crate::report::flow::FlowColumnMeta::default();
crate::report::flow::collect_column_meta(std::slice::from_ref(node), &mut here);
for header in here.truths.into_keys() {
origin.insert(header, ord);
}
ord += 1;
}
if let Some(spec) = flow.header.columns() {
let mut columns = crate::report::model::parse_columns(spec);
let inline: Vec<bool> = columns.iter().map(|c| c.truth.is_some()).collect();
crate::report::model::apply_column_meta(
&mut columns,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&from_flow,
&std::collections::HashSet::new(),
);
return columns
.into_iter()
.zip(inline)
.filter_map(|(c, was_inline)| {
let t = c.truth?;
let at = if was_inline {
None
} else {
origin.get(&c.header).copied()
};
Some((t, at))
})
.collect();
}
let mut from_flow: Vec<(String, String)> = from_flow.into_iter().collect();
from_flow.sort();
from_flow
.into_iter()
.map(|(header, t)| (t, origin.get(&header).copied()))
.collect()
}
fn count_cleanups(nodes: &[FlowNode]) -> usize {
nodes
.iter()
.map(|n| match n {
FlowNode::Cleanup { .. } => 1,
FlowNode::ForEach { body, .. }
| FlowNode::ForEnvs { body, .. }
| FlowNode::Graph { body, .. } => count_cleanups(body),
_ => 0,
})
.sum()
}
fn retain_cleanups(
nodes: &mut Vec<FlowNode>,
visible: &HashSet<String>,
entries: &[HurlEntry],
helpers: &[HelperCollection],
dropped: &DroppedNames,
keep: &mut impl FnMut(&str, &str, &[String], &[UsingItem], &HashSet<String>, &DroppedNames) -> bool,
) {
let mut here = visible.clone();
here.extend(scope_captures(nodes, entries, helpers, true));
let mut here_dropped = dropped.clone();
loop {
let before = nodes.len();
nodes.retain(|n| match n {
FlowNode::Cleanup {
name,
alias,
depends,
using,
} => {
let step = alias
.clone()
.unwrap_or_else(|| crate::report::run::leaf(name).to_string());
let k = keep(name, &step, depends, using, &here, &here_dropped);
if !k {
here_dropped.insert_anywhere(step);
}
k
}
_ => true,
});
if nodes.len() == before {
break;
}
}
let mut ord = 0usize;
for i in 0..nodes.len() {
let (above, rest) = nodes.split_at_mut(i);
let here_ord = ord;
if !matches!(rest[0], FlowNode::Cleanup { .. }) {
ord += 1;
}
match &mut rest[0] {
FlowNode::ForEach { body, .. } | FlowNode::ForEnvs { body, .. } => {
let mut body_visible = visible.clone();
body_visible.extend(scope_captures(above, entries, helpers, false));
let body_dropped = here_dropped
.visible_strictly_before(here_ord)
.positionless();
retain_cleanups(body, &body_visible, entries, helpers, &body_dropped, keep)
}
FlowNode::Graph { body, .. } => retain_cleanups(
body,
&here,
entries,
helpers,
&here_dropped.positionless(),
keep,
),
_ => {}
}
}
}
fn scan_strands_top(nodes: &[FlowNode], dropped: &DroppedNames, out: &mut Vec<String>) {
let mut ord = 0usize;
for node in nodes {
let visible = if matches!(node, FlowNode::Cleanup { .. }) {
dropped.clone()
} else {
let here = ord;
ord += 1;
dropped.visible_at(here)
};
scan_strands(std::slice::from_ref(node), &visible, out);
}
}
fn top_ordinal(nodes: &[FlowNode], upto: usize) -> usize {
nodes[..upto]
.iter()
.filter(|n| !matches!(n, FlowNode::Cleanup { .. }))
.count()
}
#[derive(Clone, Default)]
struct DroppedNames {
at: HashMap<String, Option<usize>>,
}
impl DroppedNames {
fn insert_at(&mut self, name: String, ord: usize) {
self.at.insert(name, Some(ord));
}
fn insert_anywhere(&mut self, name: String) {
self.at.insert(name, None);
}
fn contains(&self, name: &str) -> bool {
self.at.contains_key(name)
}
fn legible_to(&self, name: &str, at: Option<usize>) -> bool {
match self.at.get(name) {
None => false,
Some(None) => true,
Some(&Some(bound)) => at.is_none_or(|here| bound <= here),
}
}
fn visible_at(&self, ord: usize) -> Self {
self.filtered(|bound| bound <= ord)
}
fn visible_strictly_before(&self, ord: usize) -> Self {
self.filtered(|bound| bound < ord)
}
fn filtered(&self, keep: impl Fn(usize) -> bool) -> Self {
Self {
at: self
.at
.iter()
.filter(|(_, pos)| pos.is_none_or(|bound| keep(bound)))
.map(|(n, pos)| (n.clone(), *pos))
.collect(),
}
}
fn positionless(&self) -> Self {
Self {
at: self.at.keys().map(|n| (n.clone(), None)).collect(),
}
}
}
fn scan_strands(nodes: &[FlowNode], dropped_names: &DroppedNames, out: &mut Vec<String>) {
for node in nodes {
for text in crate::report::validate::interpolated_source(node) {
for key in crate::environment::referenced_keys(text) {
if let Some((step, _)) = key.split_once('.')
&& dropped_names.contains(step)
&& !out.contains(&key)
{
out.push(key.clone());
}
}
}
match node {
FlowNode::ForEach { body, .. }
| FlowNode::ForEnvs { body, .. }
| FlowNode::Graph { body, .. } => scan_strands(body, dropped_names, out),
_ => {}
}
}
}
pub fn prune_to_targets(
flow: &mut ReportFlow,
targets: &[String],
entries: &[HurlEntry],
helpers: &[HelperCollection],
strings: &Strings,
) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let mut matched: HashSet<String> = HashSet::new();
let mut dropped: DroppedNames = DroppedNames::default();
let mut dropped_captures: HashSet<String> = HashSet::new();
let ordinals: Vec<usize> = (0..flow.nodes.len())
.map(|i| top_ordinal(&flow.nodes, i))
.collect();
for (node_index, node) in flow.nodes.iter_mut().enumerate() {
let FlowNode::Graph { body, .. } = node else {
continue;
};
let plan = match build(body, entries, helpers, strings) {
Ok(p) => p,
Err(errs) => {
errors.extend(errs);
continue;
}
};
let wanted: Vec<usize> = plan
.steps
.iter()
.enumerate()
.filter(|(_, s)| targets.iter().any(|t| t == &s.name))
.map(|(i, s)| {
matched.insert(s.name.clone());
i
})
.collect();
let mut keep_written: HashSet<usize> = HashSet::new();
if !wanted.is_empty() {
let mut keep: HashSet<usize> = wanted.iter().copied().collect();
for &w in &wanted {
keep.extend(plan.ancestors(w));
}
keep_written = keep.iter().map(|&i| plan.steps[i].written).collect();
}
for step in &plan.steps {
let caps = resolve_qualified(entries, helpers, &step.request)
.map(produced_names)
.unwrap_or_default();
if !keep_written.contains(&step.written) {
dropped.insert_at(step.name.clone(), ordinals[node_index]);
dropped_captures.extend(caps);
}
}
let mut at = 0usize;
body.retain(|n| {
let idx = at;
at += 1;
if step_name(n).is_none() {
return true; }
keep_written.contains(&idx)
});
}
let outer = HashSet::new();
loop {
let before = count_cleanups(&flow.nodes);
retain_cleanups(
&mut flow.nodes,
&outer,
entries,
helpers,
&dropped,
&mut |name, _step, depends, using, visible, dropped| {
if depends.iter().any(|d| dropped.contains(d.as_str())) {
return false;
}
let Some(effective) = effective_entry(entries, helpers, name, using) else {
return true;
};
!crate::request::entry_referenced_keys(&effective)
.iter()
.any(|r| dropped_captures.contains(r.as_str()) && !visible.contains(r.as_str()))
},
);
if count_cleanups(&flow.nodes) == before {
break;
}
}
let mut stranded: Vec<String> = Vec::new();
scan_strands_top(&flow.nodes, &dropped, &mut stranded);
for (text, at) in declared_truths(flow) {
for key in crate::environment::referenced_keys(&text) {
if let Some((step, _)) = key.split_once('.')
&& dropped.legible_to(step, at)
&& !stranded.contains(&key)
{
stranded.push(key.clone());
}
}
}
for key in stranded {
let step = key.split_once('.').map(|(s, _)| s).unwrap_or(&key);
errors.push(fill(strings.diag_graph_target_strands, &[&key, step]));
}
for t in targets {
if !matched.contains(t.as_str()) {
errors.push(fill(strings.diag_graph_unknown_target, &[t]));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::parser::parse_flow;
fn entry(title: &str, captures: &[&str], url_vars: &[&str]) -> HurlEntry {
HurlEntry {
title: title.into(),
method: "GET".into(),
url: format!(
"http://x/{}",
url_vars
.iter()
.map(|v| format!("{{{{{v}}}}}"))
.collect::<Vec<_>>()
.join("/")
),
captures: captures
.iter()
.map(|c| ((*c).to_string(), "jsonpath \"$.t\"".to_string()))
.collect(),
..Default::default()
}
}
#[test]
fn a_reference_only_an_assert_makes_still_orders_the_step() {
let mut consumer = entry("consumer", &[], &[]);
consumer
.asserts
.push("jsonpath \"$.id\" == {{token}}".into());
let entries = [consumer, entry("producer", &["token"], &[])];
let p = plan(
"GRAPH\n REQUEST consumer\n REQUEST producer\nEND\n",
&entries,
)
.unwrap();
assert_eq!(
names(&p, &p.order),
["producer", "consumer"],
"{:?}",
p.order
);
}
#[test]
fn a_reference_only_an_option_makes_still_orders_the_step() {
let mut consumer = entry("consumer", &[], &[]);
consumer
.options
.push(crate::hurl::KvRow::new("retry", "{{attempts}}"));
let entries = [consumer, entry("producer", &["attempts"], &[])];
let p = plan(
"GRAPH\n REQUEST consumer\n REQUEST producer\nEND\n",
&entries,
)
.unwrap();
assert_eq!(names(&p, &p.order), ["producer", "consumer"]);
}
#[test]
fn a_generator_reading_a_capture_still_orders_the_step() {
let mut consumer = entry("consumer", &[], &["sig"]);
consumer
.generators
.push(("sig".into(), "sha256(token)".into()));
let entries = [consumer, entry("producer", &["token"], &[])];
let p = plan(
"GRAPH\n REQUEST consumer\n REQUEST producer\nEND\n",
&entries,
)
.unwrap();
assert_eq!(names(&p, &p.order), ["producer", "consumer"]);
}
#[test]
fn a_generated_value_orders_the_step_that_reads_it() {
let mut create = entry("create", &[], &[]);
create.generators.push(("sid".into(), "uuid".into()));
let entries = [entry("fetch", &[], &["sid"]), create];
let p = plan(
"GRAPH\n REQUEST fetch\n REQUEST create\nEND\n",
&entries,
)
.unwrap();
assert_eq!(
names(&p, &p.order),
["create", "fetch"],
"edges={:?}",
p.edges
);
}
#[test]
fn two_steps_generating_one_name_are_ambiguous_like_two_captures() {
let mut a = entry("a", &[], &[]);
a.generators.push(("sid".into(), "uuid".into()));
let mut b = entry("b", &[], &[]);
b.generators.push(("sid".into(), "uuid".into()));
let entries = [a, b, entry("fetch", &[], &["sid"])];
let errs = plan(
"GRAPH\n REQUEST a\n REQUEST b\n REQUEST fetch\nEND\n",
&entries,
)
.expect_err("two producers of `sid` is ambiguous");
assert!(errs.iter().any(|e| e.contains("sid")), "{errs:?}");
}
#[test]
fn a_step_reading_the_value_it_generates_itself_gets_no_edge() {
let mut solo = entry("solo", &[], &["sid"]);
solo.generators.push(("sid".into(), "uuid".into()));
let p = plan("GRAPH\n REQUEST solo\nEND\n", &[solo]).unwrap();
assert!(p.edges.is_empty(), "{:?}", p.edges);
}
#[test]
fn an_override_that_replaces_the_url_takes_its_references_with_it() {
let entries = [entry("a", &["id"], &["tok"]), entry("b", &["tok"], &["id"])];
let p = plan(
"GRAPH\n REQUEST a USING(url = \"https://static/1\")\n REQUEST b\nEND\n",
&entries,
)
.expect("nothing `a` sends reads `tok`, so there is no cycle");
assert_eq!(names(&p, &p.order), ["a", "b"], "edges={:?}", p.edges);
}
#[test]
fn an_override_can_introduce_a_reference_the_entry_never_held() {
let entries = [entry("a", &[], &[]), entry("b", &["tok"], &[])];
let p = plan(
"GRAPH\n REQUEST a USING(url = \"https://x/{{tok}}\")\n REQUEST b\nEND\n",
&entries,
)
.unwrap();
assert_eq!(names(&p, &p.order), ["b", "a"], "edges={:?}", p.edges);
}
#[test]
fn a_generators_own_row_and_its_functions_are_not_dependencies() {
let mut consumer = entry("consumer", &[], &[]);
consumer.generators.push(("seed".into(), "uuid".into()));
consumer
.generators
.push(("sig".into(), "sha256(seed)".into()));
let entries = [consumer, entry("producer", &["seed"], &[])];
let p = plan(
"GRAPH\n REQUEST consumer\n REQUEST producer\nEND\n",
&entries,
)
.unwrap();
assert!(p.edges.is_empty(), "{:?}", p.edges);
}
#[test]
fn a_placeholder_in_a_reports_field_does_not_invent_a_dependency() {
let mut consumer = entry("consumer", &[], &[]);
consumer
.reports
.push(("selected".into(), "jsonpath \"{{path}}\"".into()));
let entries = [consumer, entry("producer", &["path"], &[])];
let p = plan(
"GRAPH\n REQUEST consumer\n REQUEST producer\nEND\n",
&entries,
)
.unwrap();
assert!(p.edges.is_empty(), "{:?}", p.edges);
assert_eq!(names(&p, &p.order), ["consumer", "producer"]);
}
#[test]
fn a_disabled_row_does_not_invent_a_dependency() {
let mut consumer = entry("consumer", &[], &[]);
let mut row = crate::hurl::KvRow::new("X-Disabled", "{{token}}");
row.enabled = false;
consumer.headers.push(row);
let entries = [consumer, entry("producer", &["token"], &[])];
let p = plan(
"GRAPH\n REQUEST consumer\n REQUEST producer\nEND\n",
&entries,
)
.unwrap();
assert!(p.edges.is_empty(), "{:?}", p.edges);
assert_eq!(names(&p, &p.order), ["consumer", "producer"]);
}
fn plan(src: &str, entries: &[HurlEntry]) -> Result<Plan, Vec<String>> {
let flow = parse_flow(&format!("# collection: c\n\n{src}")).expect("parses");
let FlowNode::Graph { body, .. } = &flow.nodes[0] else {
panic!("expected a region, got {:?}", flow.nodes[0]);
};
build(body, entries, &[], &Strings::english())
}
fn names(p: &Plan, idxs: &[usize]) -> Vec<String> {
idxs.iter().map(|&i| p.steps[i].name.clone()).collect()
}
#[test]
fn a_region_with_no_edges_runs_in_written_order() {
let entries = [
entry("a", &[], &[]),
entry("b", &[], &[]),
entry("c", &[], &[]),
];
let p = plan(
"GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\nEND\n",
&entries,
)
.unwrap();
assert!(p.edges.is_empty());
assert_eq!(names(&p, &p.order), ["a", "b", "c"]);
assert_eq!(p.waves.len(), 1, "no edges = one wave");
}
#[test]
fn a_capture_someone_reads_is_an_edge() {
let entries = [
entry("login", &["token"], &[]),
entry("api", &[], &["token"]),
];
let p = plan("GRAPH\n REQUEST login\n REQUEST api\nEND\n", &entries).unwrap();
assert_eq!(p.edges.len(), 1);
assert_eq!(p.edges[0].kind, EdgeKind::Data("token".into()));
assert_eq!(
names(&p, &[p.edges[0].from, p.edges[0].to]),
["login", "api"]
);
}
#[test]
fn an_edge_may_point_forward_against_written_order() {
let entries = [
entry("api", &[], &["token"]),
entry("login", &["token"], &[]),
];
let p = plan("GRAPH\n REQUEST api\n REQUEST login\nEND\n", &entries).unwrap();
assert_eq!(names(&p, &p.order), ["login", "api"]);
}
#[test]
fn a_cycle_is_an_error_naming_the_steps_in_it() {
let entries = [entry("a", &["x"], &["y"]), entry("b", &["y"], &["x"])];
let errs = plan("GRAPH\n REQUEST a\n REQUEST b\nEND\n", &entries).unwrap_err();
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("a") && errs[0].contains("b"), "{errs:?}");
}
#[test]
fn a_flat_name_with_two_producers_in_the_region_is_ambiguous() {
let entries = [
entry("login", &["token"], &[]),
entry("api", &[], &["token"]),
];
let src = concat!(
"GRAPH\n",
" REQUEST login AS first\n",
" REQUEST login AS second\n",
" REQUEST api\n",
"END\n",
);
let errs = plan(src, &entries).unwrap_err();
assert!(
errs.iter()
.any(|e| e.contains("token") && e.contains("first")),
"{errs:?}"
);
}
#[test]
fn qualifying_the_reference_resolves_the_ambiguity() {
let entries = [entry("login", &["token"], &[]), entry("api", &[], &[])];
let src = concat!(
"GRAPH\n",
" REQUEST login AS first\n",
" REQUEST login AS second\n",
" REQUEST api USING(header.X = \"{{second.token}}\")\n",
"END\n",
);
let p = plan(src, &entries).unwrap();
assert_eq!(p.edges.len(), 1);
assert_eq!(names(&p, &[p.edges[0].from]), ["second"]);
}
#[test]
fn waves_group_by_depth_while_order_stays_greedy() {
let entries = [
entry("a", &["t"], &[]),
entry("b", &[], &["t"]),
entry("c", &[], &[]),
];
let p = plan(
"GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\nEND\n",
&entries,
)
.unwrap();
assert_eq!(names(&p, &p.order), ["a", "b", "c"]);
assert_eq!(names(&p, &p.waves[0]), ["a", "c"]);
assert_eq!(names(&p, &p.waves[1]), ["b"]);
}
#[test]
fn a_step_does_not_depend_on_itself_for_what_it_captures() {
let entries = [entry("refresh", &["token"], &["token"])];
let p = plan("GRAPH\n REQUEST refresh\nEND\n", &entries).unwrap();
assert!(p.edges.is_empty());
}
fn pruned(src: &str, targets: &[&str], entries: &[HurlEntry]) -> Result<String, Vec<String>> {
let mut flow = parse_flow(&format!("# collection: c\n\n{src}")).expect("parses");
let targets: Vec<String> = targets.iter().map(|t| (*t).to_string()).collect();
prune_to_targets(&mut flow, &targets, entries, &[], &Strings::english())?;
Ok(flow.to_text())
}
#[test]
fn an_override_decides_whether_a_cleanup_is_stranded() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("purge", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP purge USING(url = \"http://x/fixed\")\n",
&["target"],
&entries,
)
.unwrap();
assert!(text.contains("CLEANUP purge"), "{text}");
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("purge", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP purge USING(url = \"http://x/{{sid}}\")\n",
&["target"],
&entries,
)
.unwrap();
assert!(!text.contains("CLEANUP purge"), "{text}");
}
#[test]
fn a_cleanup_cannot_be_vouched_for_by_one_that_is_itself_dropped() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("rotate", &["sid"], &[]),
entry("purge", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP rotate DEPENDS create\nCLEANUP purge\n",
&["target"],
&entries,
)
.unwrap();
assert!(!text.contains("CLEANUP rotate"), "{text}");
assert!(
!text.contains("CLEANUP purge"),
"purge was kept on a name only the dropped rotate wrote: {text}"
);
}
#[test]
fn a_truth_is_checked_wherever_it_is_attached() {
let entries = [entry("create", &[], &[]), entry("target", &[], &[])];
let body = "GRAPH\n REPORT REQUEST create SHOW(HttpStatus)\n REQUEST target\nEND\n";
for tail in [
"REPORT V AS C TRUTH \"{{create.HttpStatus}}\"\n",
"REPORT REQUEST target WITH\n f: jsonpath \"$.x\" TRUTH \"{{create.HttpStatus}}\"\nEND\n",
] {
let errs = pruned(&format!("{body}{tail}"), &["target"], &entries)
.expect_err("a stranded TRUTH must be refused");
assert!(
errs.iter().any(|e| e.contains("create.HttpStatus")),
"{tail} => {errs:?}"
);
}
let mut flow = crate::report::parser::parse_flow(&format!(
"# collection: c\n# columns: C TRUTH \"{{{{create.HttpStatus}}}}\"\n\n{body}"
))
.expect("parses");
let errs = prune_to_targets(
&mut flow,
&["target".to_string()],
&entries,
&[],
&Strings::english(),
)
.expect_err("a stranded header TRUTH must be refused");
assert!(
errs.iter().any(|e| e.contains("create.HttpStatus")),
"{errs:?}"
);
}
#[test]
fn a_request_that_reads_a_name_does_not_produce_it() {
let mut twice = entry("rotate", &["sid"], &["sid"]);
twice
.captures
.push(("sid".into(), "jsonpath \"$.u\"".into()));
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
twice,
entry("swap", &["sid"], &["sid"]),
entry("purge", &[], &["sid"]),
];
let region = "GRAPH\n REQUEST create\n REQUEST target\nEND\n";
for tail in [
"CLEANUP rotate\n",
"CLEANUP rotate\nCLEANUP swap\nCLEANUP purge\n",
] {
let text = pruned(&format!("{region}{tail}"), &["target"], &entries).unwrap();
assert!(
!text.contains("CLEANUP"),
"nothing left in the run writes sid: {tail} => {text}"
);
}
}
#[test]
fn a_flow_truth_the_header_overrides_is_not_checked() {
let entries = [entry("create", &[], &[]), entry("target", &[], &[])];
let mut flow = crate::report::parser::parse_flow(
"# collection: c\n# columns: C TRUTH \"{{target.HttpStatus}}\"\n\n\
GRAPH\n REPORT REQUEST create SHOW(HttpStatus)\n\
\x20 REPORT REQUEST target SHOW(HttpStatus)\nEND\n\
REPORT \"x\" AS C TRUTH \"{{create.HttpStatus}}\"\n",
)
.expect("parses");
prune_to_targets(
&mut flow,
&["target".to_string()],
&entries,
&[],
&Strings::english(),
)
.expect("the flow's truth is overridden and never evaluated");
}
#[test]
fn an_ordinary_step_that_refreshes_a_name_produces_it_whatever_bound_it_first() {
let entries = [
entry("provision", &["sid"], &[]),
entry("target", &[], &[]),
entry("rotate", &["sid"], &["sid"]),
entry("purge", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST provision\n REQUEST target\nEND\n\
REQUEST rotate\nCLEANUP purge\n",
&["target"],
&entries,
)
.unwrap();
assert!(
text.contains("CLEANUP purge"),
"rotate runs and writes sid: {text}"
);
}
#[test]
fn a_binding_written_below_a_loop_does_not_vouch_for_a_cleanup_inside_it() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("work", &[], &[]),
entry("purge", &[], &["sid"]),
entry("later", &["sid"], &[]),
];
for tail in ["sid=later\n", "REQUEST later\n"] {
let text = pruned(
&format!(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
FOR x IN [\"1\"]\n REQUEST work\n CLEANUP purge\nEND\n{tail}"
),
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP purge"),
"{tail} is bound after the loop has finished: {text}"
);
}
}
#[test]
fn a_cleanup_depending_on_a_dropped_cleanup_is_dropped_too() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("purge", &[], &["sid"]),
entry("close", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP purge\nCLEANUP close DEPENDS purge\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP"),
"close depends on purge, which is gone: {text}"
);
}
#[test]
fn a_dropped_cleanup_does_not_drop_a_same_named_one_in_a_sibling_scope() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("doomed", &[], &["sid"]),
entry("prepare", &[], &[]),
entry("release", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
FOR x IN [\"a\"]\n CLEANUP doomed AS gate\nEND\n\
FOR y IN [\"b\"]\n CLEANUP prepare AS gate\n \
CLEANUP release DEPENDS gate\nEND\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP doomed"),
"the first loop's gate reads a capture nothing produces now: {text}"
);
assert!(
text.contains("CLEANUP prepare"),
"the second loop's gate is untouched: {text}"
);
assert!(
text.contains("CLEANUP release"),
"and so is what depends on it: {text}"
);
}
#[test]
fn a_cleanup_written_above_the_one_it_depends_on_is_dropped_with_it() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("shut", &[], &[]),
entry("doomed", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP shut AS close DEPENDS purge\n\
CLEANUP doomed AS purge\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP doomed"),
"purge reads a capture nothing produces now: {text}"
);
assert!(
!text.contains("CLEANUP shut"),
"and close depends on purge, wherever it is written: {text}"
);
}
#[test]
fn a_pruned_step_does_not_drop_a_teardown_in_a_loop_written_above_it() {
let entries = [
entry("prepare", &[], &[]),
entry("release", &[], &[]),
entry("gated", &[], &[]),
entry("target", &[], &[]),
];
let text = pruned(
"FOR y IN [\"b\"]\n CLEANUP prepare AS gate\n \
CLEANUP release DEPENDS gate\nEND\n\
GRAPH\n REQUEST gated AS gate\n REQUEST target\nEND\n",
&["target"],
&entries,
)
.unwrap();
assert!(
text.contains("CLEANUP prepare"),
"the loop's own gate is untouched by pruning: {text}"
);
assert!(
text.contains("CLEANUP release"),
"so nothing licenses dropping what depends on it: {text}"
);
}
#[test]
fn a_pruned_step_does_not_strand_a_reference_in_a_loop_written_above_it() {
let entries = [
entry("mint", &["v"], &[]),
entry("gated", &[], &[]),
entry("target", &[], &[]),
];
let text = pruned(
"FOR y IN [\"b\"]\n REQUEST mint AS gate\n \
REPORT \"{{gate.v}}\" AS Seen\nEND\n\
GRAPH\n REQUEST gated AS gate\n REQUEST target\nEND\n",
&["target"],
&entries,
)
.expect("the loop's own gate resolves, so nothing is stranded");
assert!(text.contains("{{gate.v}}"), "{text}");
}
#[test]
fn a_cleanup_removed_from_above_a_region_does_not_move_it() {
let entries = [
entry("p1", &[], &[]),
entry("p2", &[], &[]),
entry("gated", &["v"], &[]),
entry("target", &[], &[]),
];
let errs = pruned(
"CLEANUP p1 DEPENDS gate
CLEANUP p2 DEPENDS gate
GRAPH
REQUEST gated AS gate
REQUEST target
END
FOR y IN [\"b\"]\n REPORT \"{{gate.v}}\" AS Seen\nEND\n",
&["target"],
&entries,
)
.expect_err("the loop is below the region, so its {{gate.v}} is stranded");
assert!(
errs.iter().any(|e| e.contains("gate.v")),
"expected the strand to be reported: {errs:?}"
);
}
#[test]
fn a_cleanup_removed_from_above_a_loop_does_not_move_it() {
let entries = [
entry("purge", &[], &["sid"]),
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("release", &[], &[]),
];
let text = pruned(
"CLEANUP purge
GRAPH
REQUEST create
REQUEST target
END
FOR y IN [\"b\"]\n CLEANUP release DEPENDS create\nEND\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP release"),
"create was pruned, so its teardown goes with it: {text}"
);
}
#[test]
fn a_name_inherited_into_a_loop_body_is_legible_throughout_it() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("release", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
FOR y IN [\"b\"]\n FOR z IN [\"c\"]\n \
CLEANUP release DEPENDS create\n END\nEND\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP release"),
"create was pruned above the loop, so its teardown goes: {text}"
);
}
#[test]
fn a_truth_in_a_loop_above_a_region_reads_the_loops_own_step() {
let entries = [
entry("mint", &["v"], &[]),
entry("gated", &[], &[]),
entry("target", &[], &[]),
];
let text = pruned(
"FOR y IN [\"b\"]\n REQUEST mint AS gate\n \
REPORT \"x\" AS C TRUTH \"{{gate.v}}\"\nEND\n\
GRAPH\n REQUEST gated AS gate\n REQUEST target\nEND\n",
&["target"],
&entries,
)
.expect("the loop's own gate answers its truth");
assert!(text.contains("TRUTH"), "{text}");
}
#[test]
fn a_truth_a_later_statement_overwrites_is_not_checked() {
let entries = [
entry("dead", &["v"], &[]),
entry("gated", &[], &[]),
entry("target", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST dead\n REQUEST target\nEND\n\
FOR y IN [\"b\"]\n REPORT \"x\" AS C TRUTH \"{{dead.v}}\"\nEND\n\
REPORT \"ok\" AS C TRUTH \"static\"\n",
&["target"],
&entries,
)
.expect("the surviving truth for C is the static one");
assert!(text.contains("static"), "{text}");
}
#[test]
fn a_flow_truth_for_a_column_the_header_never_resolves_is_not_checked() {
let entries = [entry("create", &[], &[]), entry("target", &[], &[])];
for columns in ["D", "C AS Pretty"] {
let mut flow = crate::report::parser::parse_flow(&format!(
"# collection: c\n# columns: {columns}\n\n\
GRAPH\n REPORT REQUEST create SHOW(HttpStatus)\n\
\x20 REPORT REQUEST target SHOW(HttpStatus)\nEND\n\
REPORT \"x\" AS C TRUTH \"{{{{create.HttpStatus}}}}\"\n"
))
.expect("parses");
prune_to_targets(
&mut flow,
&["target".to_string()],
&entries,
&[],
&Strings::english(),
)
.unwrap_or_else(|e| panic!("columns: {columns} never resolves column C: {e:?}"));
}
}
#[test]
fn a_step_that_refreshes_a_name_it_was_given_still_produces_it() {
let entries = [
entry("provision", &["sid"], &[]),
entry("target", &[], &[]),
entry("rotate", &["sid"], &["sid"]),
entry("purge", &[], &["sid"]),
];
let text = pruned(
"sid=seed\nGRAPH\n REQUEST provision\n REQUEST target\nEND\n\
REQUEST rotate\nCLEANUP purge\n",
&["target"],
&entries,
)
.unwrap();
assert!(
text.contains("CLEANUP purge"),
"rotate runs and writes sid: {text}"
);
}
#[test]
fn a_cleanup_outside_a_loop_does_not_vouch_for_one_inside_it() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("work", &[], &[]),
entry("purge", &[], &["sid"]),
entry("rotate", &["sid"], &[]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
FOR x IN [\"1\"]\n REQUEST work\n CLEANUP purge\nEND\n\
CLEANUP rotate\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP purge"),
"rotate runs after the loop, so nothing has written sid yet: {text}"
);
}
#[test]
fn a_cleanup_cannot_vouch_for_a_name_only_it_writes() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("rotate", &["sid"], &["sid"]),
entry("purge", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP rotate\nCLEANUP purge\n",
&["target"],
&entries,
)
.unwrap();
assert!(
!text.contains("CLEANUP rotate"),
"rotate vouched for itself: {text}"
);
assert!(
!text.contains("CLEANUP purge"),
"purge was propped up by the self-vouching rotate: {text}"
);
}
#[test]
fn a_truth_template_naming_a_pruned_step_is_refused() {
let entries = [entry("create", &[], &[]), entry("target", &[], &[])];
let errs = pruned(
"GRAPH\n REPORT REQUEST create SHOW(HttpStatus)\n REQUEST target\nEND\n\
REPORT \"x\" AS C TRUTH \"{{create.HttpStatus}}\"\n",
&["target"],
&entries,
)
.expect_err("a stranded TRUTH must be refused");
assert!(
errs.iter().any(|e| e.contains("create.HttpStatus")),
"{errs:?}"
);
}
#[test]
fn a_target_keeps_itself_and_everything_it_depends_on() {
let entries = [
entry("login", &["token"], &[]),
entry("api", &[], &["token"]),
entry("unrelated", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST login\n REQUEST api\n REQUEST unrelated\nEND\n",
&["api"],
&entries,
)
.unwrap();
assert!(text.contains("REQUEST login"), "{text}");
assert!(text.contains("REQUEST api"), "{text}");
assert!(!text.contains("unrelated"), "{text}");
}
#[test]
fn a_comment_above_a_step_does_not_shift_what_pruning_keeps() {
let entries = [
entry("login", &["token"], &[]),
entry("api", &[], &["token"]),
];
let text = pruned(
"GRAPH\n # a comment shifts nothing\n REQUEST login\n REQUEST api\nEND\n",
&["api"],
&entries,
)
.unwrap();
assert!(text.contains("REQUEST login"), "{text}");
assert!(text.contains("REQUEST api"), "{text}");
}
#[test]
fn a_cleanup_goes_with_the_step_it_was_undoing() {
let entries = [
entry("a", &[], &[]),
entry("b", &[], &[]),
entry("teardown", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST a\n REQUEST b\nEND\nCLEANUP teardown DEPENDS a\n",
&["b"],
&entries,
)
.unwrap();
assert!(!text.contains("CLEANUP"), "{text}");
}
#[test]
fn a_cleanup_whose_producer_survives_is_kept() {
let entries = [
entry("a", &[], &[]),
entry("b", &[], &[]),
entry("teardown", &[], &[]),
];
let text = pruned(
"GRAPH\n REQUEST a\n REQUEST b\nEND\nCLEANUP teardown DEPENDS b\n",
&["b"],
&entries,
)
.unwrap();
assert!(text.contains("CLEANUP teardown"), "{text}");
}
#[test]
fn a_cleanup_that_reads_a_pruned_capture_goes_too() {
let entries = [
entry("create", &["sid"], &[]),
entry("b", &[], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST b\nEND\nCLEANUP teardown\n",
&["b"],
&entries,
)
.unwrap();
assert!(!text.contains("CLEANUP"), "{text}");
}
#[test]
fn a_cleanup_keeps_a_capture_a_surviving_step_still_makes() {
let entries = [
entry("outside", &["sid"], &[]),
entry("discarded", &["sid"], &[]),
entry("target", &[], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"REQUEST outside\n\
GRAPH\n REQUEST discarded\n REQUEST target\nEND\n\
CLEANUP teardown\n",
&["target"],
&entries,
)
.unwrap();
assert!(text.contains("CLEANUP teardown"), "{text}");
}
#[test]
fn a_cleanup_keeps_a_capture_another_cleanup_still_makes() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("make", &["sid"], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
CLEANUP make\nCLEANUP teardown\n",
&["target"],
&entries,
)
.unwrap();
assert!(text.contains("CLEANUP teardown"), "{text}");
}
#[test]
fn a_cleanup_in_a_region_sees_what_is_written_beside_the_region() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("make", &["sid"], &[]),
entry("target2", &[], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
REQUEST make\n\
GRAPH\n REQUEST target2\n CLEANUP teardown\nEND\n",
&["target", "target2"],
&entries,
)
.unwrap();
assert!(text.contains("CLEANUP teardown"), "{text}");
}
#[test]
fn a_capture_made_only_inside_a_loop_does_not_save_an_outer_cleanup() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("make", &["sid"], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
FOR ITEM IN [1]\n REQUEST make\nEND\n\
CLEANUP teardown\n",
&["target"],
&entries,
)
.unwrap();
assert!(!text.contains("CLEANUP"), "{text}");
}
#[test]
fn a_cleanup_inside_the_loop_that_still_makes_its_value_is_kept() {
let entries = [
entry("create", &["sid"], &[]),
entry("target", &[], &[]),
entry("make", &["sid"], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST target\nEND\n\
FOR ITEM IN [1]\n REQUEST make\n CLEANUP teardown\nEND\n",
&["target"],
&entries,
)
.unwrap();
assert!(text.contains("CLEANUP teardown"), "{text}");
}
#[test]
fn pruning_refuses_to_strand_a_reference_written_in_a_list() {
let entries = [entry("create", &["sid"], &[]), entry("health", &[], &[])];
let errs = pruned(
"GRAPH\n REQUEST create\n REQUEST health\nEND\n\
FOR ITEM IN [\"{{create.sid}}\"]\n REPORT ITEM AS S\nEND\n",
&["health"],
&entries,
)
.unwrap_err();
assert!(errs.iter().any(|e| e.contains("create.sid")), "{errs:?}");
}
#[test]
fn a_cleanup_inside_a_loop_is_pruned_like_any_other() {
let entries = [
entry("create", &["sid"], &[]),
entry("b", &[], &[]),
entry("teardown", &[], &["sid"]),
];
let text = pruned(
"GRAPH\n REQUEST create\n REQUEST b\nEND\n\
FOR ITEM IN [1, 2]\n CLEANUP teardown\nEND\n",
&["b"],
&entries,
)
.unwrap();
assert!(!text.contains("CLEANUP"), "{text}");
}
#[test]
fn pruning_refuses_to_strand_a_reference_to_the_step_it_removed() {
let entries = [entry("create", &["sid"], &[]), entry("health", &[], &[])];
let errs = pruned(
"GRAPH\n REQUEST create\n REQUEST health\nEND\n\
REPORT \"{{create.sid}}\" AS Session\n",
&["health"],
&entries,
)
.unwrap_err();
assert!(errs.iter().any(|e| e.contains("create.sid")), "{errs:?}");
}
#[test]
fn a_region_holding_no_target_is_emptied_not_left_whole() {
let entries = [entry("a", &[], &[]), entry("b", &[], &[])];
let text = pruned(
"GRAPH first\n REQUEST a\nEND\nGRAPH second\n REQUEST b\nEND\n",
&["a"],
&entries,
)
.unwrap();
assert!(text.contains("REQUEST a"), "{text}");
assert!(!text.contains("REQUEST b"), "{text}");
}
#[test]
fn a_target_outside_any_region_is_refused() {
let entries = [entry("login", &[], &[]), entry("api", &[], &[])];
let errs = pruned(
"REQUEST login\nGRAPH\n REQUEST api\nEND\n",
&["login"],
&entries,
)
.unwrap_err();
assert!(errs.iter().any(|e| e.contains("login")), "{errs:?}");
}
#[test]
fn pruning_leaves_statements_outside_the_region_alone() {
let entries = [entry("setup", &[], &[]), entry("api", &[], &[])];
let text = pruned(
"REQUEST setup\nGRAPH\n REQUEST api\nEND\n",
&["api"],
&entries,
)
.unwrap();
assert!(text.contains("REQUEST setup"), "{text}");
}
#[test]
fn the_wave_listing_names_the_edge_behind_each_step() {
let entries = [
entry("login", &["token"], &[]),
entry("api", &[], &["token"]),
];
let flow =
parse_flow("# collection: c\n\nGRAPH\n REQUEST login\n REQUEST api\nEND\n")
.expect("parses");
let lines = explain(&flow, &entries, &[], &Strings::english());
let text = lines.join("\n");
assert!(text.contains("wave 0") && text.contains("login"), "{text}");
assert!(text.contains("data: login.token"), "{text}");
assert!(text.contains("not guaranteed"), "{text}");
}
#[test]
fn ancestors_are_transitive_and_in_execution_order() {
let entries = [
entry("a", &["x"], &[]),
entry("b", &["y"], &["x"]),
entry("c", &[], &["y"]),
];
let p = plan(
"GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\nEND\n",
&entries,
)
.unwrap();
let c = p.steps.iter().position(|s| s.name == "c").unwrap();
assert_eq!(names(&p, &p.ancestors(c)), ["a", "b"]);
}
}