use std::fmt::Write as _;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReportFlow {
pub header: Header,
pub nodes: Vec<FlowNode>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Header {
pub lines: Vec<HeaderLine>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeaderLine {
Directive { key: String, value: String },
Comment(String),
}
impl Header {
pub fn get(&self, key: &str) -> Option<&str> {
self.lines.iter().find_map(|l| match l {
HeaderLine::Directive { key: k, value } if k.eq_ignore_ascii_case(key) => {
Some(value.as_str())
}
_ => None,
})
}
pub fn collection(&self) -> Option<&str> {
self.get("collection")
}
pub fn output(&self) -> Option<&str> {
self.get("output")
}
pub fn columns(&self) -> Option<&str> {
self.get("columns")
}
pub fn root(&self) -> Option<&str> {
self.get("root")
}
pub fn baseline(&self) -> Option<&str> {
self.get("baseline")
}
pub fn environment(&self) -> Option<&str> {
self.get("environment")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlowNode {
Assign { key: String, value: String },
ListDecl { name: String, producer: Producer },
Request { name: String },
Report(ReportStmt),
ForEach {
pattern: Pattern,
producer: Producer,
body: Vec<FlowNode>,
parallel: Option<ParallelSpec>,
},
ForEnvs {
var: String,
clause: EnvClause,
body: Vec<FlowNode>,
parallel: Option<ParallelSpec>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ParallelSpec {
pub degree: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReportStmt {
Request {
name: String,
alias: Option<String>,
response_fmt: Option<ResponseFmt>,
show: Vec<String>,
hide: Vec<String>,
with: Vec<WithItem>,
},
Vars(Vec<String>),
VarAs { var: String, name: String },
Computed { template: String, name: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WithItem {
ResponseFmt(ResponseFmt),
Field {
name: String,
query: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseFmt {
Raw,
Pretty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Producer {
List(Vec<Element>),
Files { dir: String, glob: Option<String> },
Folders {
dir: String,
roles: Vec<(String, String)>,
},
Tuples { path: String },
Zip(Vec<Producer>),
Concat(Vec<Producer>),
Named(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Element {
Scalar(String),
Tuple(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pattern {
pub binders: Vec<Binder>,
pub rest: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Binder {
Named(String),
Discard,
}
impl Pattern {
pub fn single(name: impl Into<String>) -> Self {
Pattern {
binders: vec![Binder::Named(name.into())],
rest: false,
}
}
pub fn is_single(&self) -> bool {
self.binders.len() == 1 && !self.rest
}
pub fn named(&self) -> impl Iterator<Item = &str> {
self.binders.iter().filter_map(|b| match b {
Binder::Named(n) => Some(n.as_str()),
Binder::Discard => None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvClause {
Plain(Vec<String>),
Roles {
baseline: Vec<String>,
comparisons: Vec<String>,
baseline_show: Vec<String>,
},
}
const INDENT: &str = " ";
impl ReportFlow {
pub fn to_text(&self) -> String {
let mut out = String::new();
for line in &self.header.lines {
match line {
HeaderLine::Directive { key, value } => {
let _ = writeln!(out, "# {key}: {value}");
}
HeaderLine::Comment(c) => {
let _ = writeln!(out, "# {c}");
}
}
}
if !self.header.lines.is_empty() && !self.nodes.is_empty() {
out.push('\n');
}
for node in &self.nodes {
write_node(&mut out, node, 0);
}
out
}
}
fn indent(out: &mut String, depth: usize) {
for _ in 0..depth {
out.push_str(INDENT);
}
}
fn write_node(out: &mut String, node: &FlowNode, depth: usize) {
indent(out, depth);
match node {
FlowNode::Assign { key, value } => {
let _ = writeln!(out, "{key}={value}");
}
FlowNode::ListDecl { name, producer } => {
let _ = writeln!(out, "LIST {name} = {}", producer_text(producer));
}
FlowNode::Request { name } => {
let _ = writeln!(out, "REQUEST {}", name_text(name));
}
FlowNode::Report(stmt) => write_report(out, stmt, depth),
FlowNode::ForEach {
pattern,
producer,
body,
parallel,
} => {
let _ = writeln!(
out,
"{}FOR {} IN {}",
parallel_prefix(parallel),
pattern_text(pattern),
producer_text(producer)
);
for n in body {
write_node(out, n, depth + 1);
}
indent(out, depth);
out.push_str("END\n");
}
FlowNode::ForEnvs {
var,
clause,
body,
parallel,
} => {
let _ = writeln!(
out,
"{}FOR {var} IN ENVS {}",
parallel_prefix(parallel),
env_clause_text(clause)
);
for n in body {
write_node(out, n, depth + 1);
}
indent(out, depth);
out.push_str("END\n");
}
}
}
fn parallel_prefix(p: &Option<ParallelSpec>) -> String {
match p {
None => String::new(),
Some(ParallelSpec { degree: None }) => "PARALLEL ".to_string(),
Some(ParallelSpec { degree: Some(n) }) => format!("PARALLEL({n}) "),
}
}
fn write_report(out: &mut String, stmt: &ReportStmt, depth: usize) {
match stmt {
ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
with,
} => {
let _ = write!(out, "REPORT REQUEST {}", name_text(name));
if let Some(a) = alias {
let _ = write!(out, " AS {}", name_text(a));
}
if let Some(fmt) = response_fmt {
let _ = write!(out, " RESPONSE {}", fmt_text(*fmt));
}
if !show.is_empty() {
let _ = write!(out, " SHOW({})", show.join(", "));
}
if !hide.is_empty() {
let _ = write!(out, " HIDE({})", hide.join(", "));
}
if with.is_empty() {
out.push('\n');
} else {
out.push_str(" WITH\n");
for item in with {
indent(out, depth + 1);
match item {
WithItem::ResponseFmt(fmt) => {
let _ = writeln!(out, "RESPONSE {}", fmt_text(*fmt));
}
WithItem::Field { name, query } => {
let _ = writeln!(out, "{name}: {query}");
}
}
}
indent(out, depth);
out.push_str("END\n");
}
}
ReportStmt::Vars(vars) => {
if vars.len() == 1 {
let _ = writeln!(out, "REPORT {}", vars[0]);
} else {
let _ = writeln!(out, "REPORT ({})", vars.join(", "));
}
}
ReportStmt::VarAs { var, name } => {
let _ = writeln!(out, "REPORT {var} AS {}", name_text(name));
}
ReportStmt::Computed { template, name } => {
let _ = writeln!(out, "REPORT {} AS {}", quote(template), name_text(name));
}
}
}
fn fmt_text(fmt: ResponseFmt) -> &'static str {
match fmt {
ResponseFmt::Raw => "RAW",
ResponseFmt::Pretty => "PRETTY",
}
}
fn pattern_text(p: &Pattern) -> String {
if p.is_single() {
return binder_text(&p.binders[0]);
}
let mut parts: Vec<String> = p.binders.iter().map(binder_text).collect();
if p.rest {
parts.push("...".to_string());
}
format!("({})", parts.join(", "))
}
fn binder_text(b: &Binder) -> String {
match b {
Binder::Named(n) => n.clone(),
Binder::Discard => "_".to_string(),
}
}
fn producer_text(p: &Producer) -> String {
match p {
Producer::List(elems) => {
let items: Vec<String> = elems.iter().map(element_text).collect();
format!("[{}]", items.join(", "))
}
Producer::Files { dir, glob } => match glob {
Some(g) => format!("FILES {} MATCH {}", quote(dir), quote(g)),
None => format!("FILES {}", quote(dir)),
},
Producer::Folders { dir, roles } => {
if roles.is_empty() {
format!("FOLDERS {}", quote(dir))
} else {
let rs: Vec<String> = roles
.iter()
.map(|(k, v)| format!("{k}={}", quote(v)))
.collect();
format!("FOLDERS {} WITH {}", quote(dir), rs.join(", "))
}
}
Producer::Tuples { path } => format!("TUPLES FROM {}", quote(path)),
Producer::Zip(ps) => {
let items: Vec<String> = ps.iter().map(producer_text).collect();
format!("ZIP({})", items.join(", "))
}
Producer::Concat(ps) => {
let items: Vec<String> = ps.iter().map(producer_text).collect();
format!("CONCAT({})", items.join(", "))
}
Producer::Named(n) => n.clone(),
}
}
fn element_text(e: &Element) -> String {
match e {
Element::Scalar(s) => quote(s),
Element::Tuple(items) => {
let parts: Vec<String> = items.iter().map(|s| quote(s)).collect();
format!("({})", parts.join(", "))
}
}
}
fn env_clause_text(c: &EnvClause) -> String {
match c {
EnvClause::Plain(names) => names
.iter()
.map(|s| quote(s))
.collect::<Vec<_>>()
.join(", "),
EnvClause::Roles {
baseline,
comparisons,
baseline_show,
} => {
let mut parts = Vec::new();
if !baseline.is_empty() {
let names: Vec<String> = baseline.iter().map(|s| quote(s)).collect();
let mut token = format!("BASELINE({})", names.join(", "));
if !baseline_show.is_empty() {
token.push_str(&format!(" SHOW({})", baseline_show.join(", ")));
}
parts.push(token);
}
if !comparisons.is_empty() {
let names: Vec<String> = comparisons.iter().map(|s| quote(s)).collect();
parts.push(format!("COMPARISON({})", names.join(", ")));
}
parts.join(", ")
}
}
}
fn name_text(name: &str) -> String {
if name.is_empty()
|| name
.chars()
.any(|c| c.is_whitespace() || "()[],=\"".contains(c))
{
quote(name)
} else {
name.to_string()
}
}
impl FlowNode {
pub fn label(&self) -> String {
match self {
FlowNode::Assign { key, value } => format!("{key} = {value}"),
FlowNode::ListDecl { name, producer } => {
format!("LIST {name} = {}", producer_text(producer))
}
FlowNode::Request { name } => format!("REQUEST {name}"),
FlowNode::Report(stmt) => report_label(stmt),
FlowNode::ForEach {
pattern,
producer,
parallel,
..
} => format!(
"{}FOR {} IN {}",
parallel_prefix(parallel),
pattern_text(pattern),
producer_text(producer)
),
FlowNode::ForEnvs {
var,
clause,
parallel,
..
} => format!(
"{}FOR {var} IN ENVS {}",
parallel_prefix(parallel),
env_clause_text(clause)
),
}
}
pub fn header_line(&self) -> String {
match self {
FlowNode::Report(ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
..
}) => {
let mut out = format!("REPORT REQUEST {}", name_text(name));
if let Some(a) = alias {
let _ = write!(out, " AS {}", name_text(a));
}
if let Some(fmt) = response_fmt {
let _ = write!(out, " RESPONSE {}", fmt_text(*fmt));
}
if !show.is_empty() {
let _ = write!(out, " SHOW({})", show.join(", "));
}
if !hide.is_empty() {
let _ = write!(out, " HIDE({})", hide.join(", "));
}
out
}
_ => self.label(),
}
}
pub fn request_name(&self) -> Option<&str> {
match self {
FlowNode::Request { name } => Some(name),
FlowNode::Report(ReportStmt::Request { name, .. }) => Some(name),
_ => None,
}
}
pub fn is_loop(&self) -> bool {
matches!(self, FlowNode::ForEach { .. } | FlowNode::ForEnvs { .. })
}
pub fn body_mut(&mut self) -> Option<&mut Vec<FlowNode>> {
match self {
FlowNode::ForEach { body, .. } | FlowNode::ForEnvs { body, .. } => Some(body),
_ => None,
}
}
}
fn report_label(stmt: &ReportStmt) -> String {
match stmt {
ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
with,
} => {
let mut out = format!("REPORT REQUEST {name}");
if let Some(a) = alias {
let _ = write!(out, " AS {a}");
}
if let Some(fmt) = response_fmt {
let _ = write!(out, " RESPONSE {}", fmt_text(*fmt));
}
if !show.is_empty() {
let _ = write!(out, " SHOW({})", show.join(", "));
}
if !hide.is_empty() {
let _ = write!(out, " HIDE({})", hide.join(", "));
}
if !with.is_empty() {
out.push_str(" WITH …");
}
out
}
ReportStmt::Vars(vars) => {
if vars.len() == 1 {
format!("REPORT {}", vars[0])
} else {
format!("REPORT ({})", vars.join(", "))
}
}
ReportStmt::VarAs { var, name } => {
format!("REPORT {var} AS {name}")
}
ReportStmt::Computed { template, name } => {
format!("REPORT {} AS {name}", quote(template))
}
}
}
pub(crate) fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
_ => out.push(c),
}
}
out.push('"');
out
}