use std::collections::HashMap;
use super::flow::Header;
pub const TARGET_COLUMN: &str = "TARGET";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReportRow {
pub cells: HashMap<String, String>,
pub vars: HashMap<String, String>,
pub key: Vec<String>,
pub path: Vec<(usize, usize)>,
pub target: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReportResult {
pub rows: Vec<ReportRow>,
pub column_order: Vec<String>,
pub no_match_marker: String,
pub errors: Vec<String>,
pub column_stats: std::collections::HashMap<String, Vec<StatKind>>,
}
impl ReportResult {
pub fn note_column(&mut self, key: &str) {
if !self.column_order.iter().any(|c| c == key) {
self.column_order.push(key.to_string());
}
}
pub fn resolved_columns(&self, header: &Header) -> Vec<OutputColumn> {
let mut columns = match header.columns() {
Some(spec) => parse_columns(spec),
None => self
.column_order
.iter()
.map(|k| OutputColumn {
header: k.clone(),
sources: vec![k.clone()],
stats: Vec::new(),
})
.collect(),
};
if !self.column_stats.is_empty() {
for col in &mut columns {
if col.stats.is_empty()
&& let Some(stats) = self.column_stats.get(&col.header)
{
col.stats = stats.clone();
}
}
}
columns
}
pub fn summary_rows(&self, columns: &[OutputColumn]) -> Vec<SummaryRow> {
if columns.iter().all(|c| c.stats.is_empty()) {
return Vec::new();
}
let column_values = |col: &OutputColumn| -> Vec<String> {
self.rows
.iter()
.map(|row| col.value(row, &self.no_match_marker))
.filter(|v| !v.trim().is_empty() && *v != self.no_match_marker)
.collect()
};
let mut out = Vec::new();
for stat in StatKind::SUMMARY_ORDER {
let requested: Vec<usize> = columns
.iter()
.enumerate()
.filter(|(_, c)| c.stats.contains(&stat))
.map(|(i, _)| i)
.collect();
if requested.is_empty() {
continue;
}
let mut cells = vec![None; columns.len()];
for &ci in &requested {
let values = column_values(&columns[ci]);
let numeric = column_numeric(&values);
if let Some(text) = compute_stat(stat, &values) {
cells[ci] = Some(StatValue {
text,
stat,
numeric,
match_value: None,
});
}
}
if cells.iter().any(Option::is_some) {
out.push(SummaryRow {
label: stat.label().to_string(),
cells,
});
}
}
for (ci, col) in columns.iter().enumerate() {
if !col.stats.contains(&StatKind::Distribution) {
continue;
}
for (value, count) in distinct_counts(&column_values(col)) {
let mut cells = vec![None; columns.len()];
cells[ci] = Some(StatValue {
text: count.to_string(),
stat: StatKind::Distribution,
numeric: true,
match_value: Some(value.clone()),
});
out.push(SummaryRow {
label: format!("{} = {}", col.header, value),
cells,
});
}
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatKind {
Mean,
Median,
Mode,
Min,
Max,
Sum,
Count,
StdDev,
Distribution,
}
impl StatKind {
pub const SUMMARY_ORDER: [StatKind; 8] = [
StatKind::Count,
StatKind::Sum,
StatKind::Mean,
StatKind::Median,
StatKind::Mode,
StatKind::Min,
StatKind::Max,
StatKind::StdDev,
];
pub const CHOOSABLE: [StatKind; 9] = [
StatKind::Count,
StatKind::Sum,
StatKind::Mean,
StatKind::Median,
StatKind::Mode,
StatKind::Min,
StatKind::Max,
StatKind::StdDev,
StatKind::Distribution,
];
pub fn parse(s: &str) -> Option<StatKind> {
match s.trim().to_ascii_uppercase().as_str() {
"MEAN" | "AVG" | "AVERAGE" => Some(StatKind::Mean),
"MEDIAN" => Some(StatKind::Median),
"MODE" => Some(StatKind::Mode),
"MIN" | "MINIMUM" => Some(StatKind::Min),
"MAX" | "MAXIMUM" => Some(StatKind::Max),
"SUM" | "TOTAL" => Some(StatKind::Sum),
"COUNT" => Some(StatKind::Count),
"STDDEV" | "STDEV" | "STD" => Some(StatKind::StdDev),
"DISTRIBUTION" | "DIST" => Some(StatKind::Distribution),
_ => None,
}
}
pub fn keyword(self) -> &'static str {
match self {
StatKind::Mean => "MEAN",
StatKind::Median => "MEDIAN",
StatKind::Mode => "MODE",
StatKind::Min => "MIN",
StatKind::Max => "MAX",
StatKind::Sum => "SUM",
StatKind::Count => "COUNT",
StatKind::StdDev => "STDDEV",
StatKind::Distribution => "DISTRIBUTION",
}
}
pub fn label(self) -> &'static str {
match self {
StatKind::Mean => "Mean",
StatKind::Median => "Median",
StatKind::Mode => "Mode",
StatKind::Min => "Min",
StatKind::Max => "Max",
StatKind::Sum => "Sum",
StatKind::Count => "Count",
StatKind::StdDev => "Std dev",
StatKind::Distribution => "Distribution",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatValue {
pub text: String,
pub stat: StatKind,
pub numeric: bool,
pub match_value: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryRow {
pub label: String,
pub cells: Vec<Option<StatValue>>,
}
impl SummaryRow {
pub fn text_cell(&self, column: usize) -> String {
match self.cells.get(column).and_then(|c| c.as_ref()) {
Some(v) => v.text.clone(),
None if column == 0 => self.label.clone(),
None => String::new(),
}
}
}
fn column_numeric(values: &[String]) -> bool {
!values.is_empty()
&& values
.iter()
.all(|v| crate::report::writer::parse_report_number(v).is_some())
}
fn numeric_values(values: &[String]) -> Vec<f64> {
values
.iter()
.filter_map(|v| crate::report::writer::parse_report_number(v))
.collect()
}
fn compute_stat(stat: StatKind, values: &[String]) -> Option<String> {
match stat {
StatKind::Count => (!values.is_empty()).then(|| values.len().to_string()),
StatKind::Mode => mode(values),
StatKind::Distribution => None,
_ => {
let nums = numeric_values(values);
if nums.is_empty() {
return None;
}
let v = match stat {
StatKind::Mean => nums.iter().sum::<f64>() / nums.len() as f64,
StatKind::Sum => nums.iter().sum::<f64>(),
StatKind::Min => nums.iter().copied().fold(f64::INFINITY, f64::min),
StatKind::Max => nums.iter().copied().fold(f64::NEG_INFINITY, f64::max),
StatKind::Median => median(&nums),
StatKind::StdDev => std_dev(&nums),
_ => unreachable!(),
};
Some(format_number(v))
}
}
}
fn median(nums: &[f64]) -> f64 {
let mut sorted = nums.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2]
} else {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
}
}
fn std_dev(nums: &[f64]) -> f64 {
let mean = nums.iter().sum::<f64>() / nums.len() as f64;
let var = nums.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / nums.len() as f64;
var.sqrt()
}
fn mode(values: &[String]) -> Option<String> {
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut best: Option<(&str, usize, usize)> = None; for (i, v) in values.iter().enumerate() {
let c = counts.entry(v.as_str()).or_insert(0);
*c += 1;
let count = *c;
let better = match best {
None => true,
Some((_, bc, bi)) => count > bc || (count == bc && i < bi),
};
if better {
best = Some((v.as_str(), count, i));
}
}
best.map(|(v, _, _)| v.to_string())
}
fn distinct_counts(values: &[String]) -> Vec<(String, usize)> {
let mut order: Vec<String> = Vec::new();
let mut counts: HashMap<String, usize> = HashMap::new();
for v in values {
if !counts.contains_key(v) {
order.push(v.clone());
}
*counts.entry(v.clone()).or_insert(0) += 1;
}
order
.into_iter()
.map(|v| {
let c = counts[&v];
(v, c)
})
.collect()
}
pub(crate) fn format_number(n: f64) -> String {
if !n.is_finite() {
return n.to_string();
}
if n.fract() == 0.0 && n.abs() < 1e15 {
return format!("{}", n as i64);
}
let s = format!("{n:.6}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputColumn {
pub header: String,
pub sources: Vec<String>,
pub stats: Vec<StatKind>,
}
impl OutputColumn {
pub fn value(&self, row: &ReportRow, no_match: &str) -> String {
for src in &self.sources {
if let Some(v) = row.cells.get(src)
&& !v.is_empty()
{
return v.clone();
}
if let Some(v) = row.vars.get(src)
&& !v.is_empty()
{
return v.clone();
}
if src == TARGET_COLUMN
&& let Some(t) = &row.target
&& !t.is_empty()
{
return t.clone();
}
}
no_match.to_string()
}
}
pub fn parse_columns(spec: &str) -> Vec<OutputColumn> {
split_top_level(spec, ',')
.into_iter()
.filter_map(|part| {
let part = part.trim();
if part.is_empty() {
return None;
}
let (part, stats) = split_statistics(part);
let (sources_part, header) = split_as(part);
let sources: Vec<String> = sources_part
.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if sources.is_empty() {
return None;
}
let header = header.unwrap_or_else(|| sources[0].clone());
Some(OutputColumn {
header,
sources,
stats,
})
})
.collect()
}
pub(crate) fn split_statistics(part: &str) -> (&str, Vec<StatKind>) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == b's' || c == b'S')
&& (i == 0 || bytes[i - 1].is_ascii_whitespace())
&& part
.get(i..i + 10)
.is_some_and(|w| w.eq_ignore_ascii_case("statistics"))
{
let after = part[i + 10..].trim_start();
if let Some(inner) = after.strip_prefix('(')
&& let Some(close) = inner.find(')')
{
let stats = inner[..close]
.split(',')
.filter_map(StatKind::parse)
.collect();
return (part[..i].trim_end(), stats);
}
}
i += 1;
}
(part, Vec::new())
}
fn split_as(part: &str) -> (&str, Option<String>) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == 'A' || c == 'a')
&& bytes
.get(i + 1)
.is_some_and(|b| b.eq_ignore_ascii_case(&b's'))
&& i > 0
&& bytes[i - 1].is_ascii_whitespace()
&& bytes.get(i + 2).is_some_and(|b| b.is_ascii_whitespace())
{
let sources = part[..i].trim();
let header = unquote(part[i + 2..].trim());
return (sources, Some(header));
}
i += 1;
}
(part, None)
}
fn split_top_level(s: &str, sep: char) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quote = false;
let mut depth = 0usize;
for c in s.chars() {
match c {
'"' => {
in_quote = !in_quote;
cur.push(c);
}
'(' if !in_quote => {
depth += 1;
cur.push(c);
}
')' if !in_quote => {
depth = depth.saturating_sub(1);
cur.push(c);
}
_ if c == sep && !in_quote && depth == 0 => {
out.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
out.push(cur);
out
}
fn unquote(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(cells: &[(&str, &str)], vars: &[(&str, &str)], target: Option<&str>) -> ReportRow {
ReportRow {
cells: cells
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
vars: vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
key: vec![],
path: Vec::new(),
target: target.map(str::to_string),
}
}
#[test]
fn default_columns_follow_first_seen_order() {
let mut res = ReportResult::default();
res.note_column("proc.status");
res.note_column("proc.Time");
res.note_column("proc.status"); let cols = res.resolved_columns(&Header::default());
let headers: Vec<&str> = cols.iter().map(|c| c.header.as_str()).collect();
assert_eq!(headers, vec!["proc.status", "proc.Time"]);
}
#[test]
fn columns_directive_renames_and_reorders() {
let cols = parse_columns("FILE as Name, proc.status as Status, proc.Time as Time");
assert_eq!(cols.len(), 3);
assert_eq!(cols[0].header, "Name");
assert_eq!(cols[0].sources, vec!["FILE"]);
assert_eq!(cols[1].header, "Status");
assert_eq!(cols[2].header, "Time");
}
#[test]
fn columns_directive_supports_quoted_headers_with_spaces() {
let cols = parse_columns("proc.Response as \"Main Results\"");
assert_eq!(cols[0].header, "Main Results");
assert_eq!(cols[0].sources, vec!["proc.Response"]);
}
#[test]
fn columns_directive_with_non_ascii_does_not_panic() {
for spec in ["año", "naïve", "aé", "café as Name", "Naïve AS Rôle"] {
let _ = parse_columns(spec); }
let cols = parse_columns("café AS Rôle");
assert_eq!(cols[0].header, "Rôle");
assert_eq!(cols[0].sources, vec!["café"]);
}
#[test]
fn columns_directive_coalesces_sources() {
let cols = parse_columns("a.status | b.status as Status");
assert_eq!(cols[0].header, "Status");
assert_eq!(cols[0].sources, vec!["a.status", "b.status"]);
}
#[test]
fn coalesce_takes_first_non_empty_source() {
let col = OutputColumn {
header: "Status".into(),
sources: vec!["a.status".into(), "b.status".into()],
stats: Vec::new(),
};
let r = row(&[("a.status", ""), ("b.status", "ok")], &[], None);
assert_eq!(col.value(&r, "-"), "ok");
}
#[test]
fn value_falls_back_to_vars_then_no_match_marker() {
let col = OutputColumn {
header: "Name".into(),
sources: vec!["FILE".into()],
stats: Vec::new(),
};
let r = row(&[], &[("FILE", "a.jpg")], None);
assert_eq!(col.value(&r, "∅"), "a.jpg");
let empty = row(&[], &[], None);
assert_eq!(col.value(&empty, "∅"), "∅");
}
#[test]
fn target_is_available_as_a_column_source() {
let col = OutputColumn {
header: "Env".into(),
sources: vec![TARGET_COLUMN.to_string()],
stats: Vec::new(),
};
let r = row(&[], &[], Some("staging-au"));
assert_eq!(col.value(&r, "-"), "staging-au");
}
#[test]
fn parse_columns_reads_statistics_clause() {
let cols = parse_columns("Time STATISTICS(MEAN, MEDIAN), Overall STATISTICS(DISTRIBUTION)");
assert_eq!(cols[0].header, "Time");
assert_eq!(cols[0].stats, vec![StatKind::Mean, StatKind::Median]);
assert_eq!(cols[1].header, "Overall");
assert_eq!(cols[1].stats, vec![StatKind::Distribution]);
}
#[test]
fn parse_columns_statistics_after_as_rename() {
let cols = parse_columns("proc.Time AS \"Pretty time\" STATISTICS(MEAN)");
assert_eq!(cols[0].header, "Pretty time");
assert_eq!(cols[0].sources, vec!["proc.Time"]);
assert_eq!(cols[0].stats, vec![StatKind::Mean]);
}
#[test]
fn summary_rows_compute_numeric_stats() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("Time", "100")], &[], None),
row(&[("Time", "200")], &[], None),
row(&[("Time", "300")], &[], None),
];
let cols = parse_columns("Time STATISTICS(MEAN, MEDIAN, SUM, MIN, MAX, COUNT, STDDEV)");
let summary = res.summary_rows(&cols);
let get = |label: &str| {
summary
.iter()
.find(|r| r.label == label)
.map(|r| r.text_cell(0))
};
assert_eq!(get("Count").as_deref(), Some("3"));
assert_eq!(get("Sum").as_deref(), Some("600"));
assert_eq!(get("Mean").as_deref(), Some("200"));
assert_eq!(get("Median").as_deref(), Some("200"));
assert_eq!(get("Min").as_deref(), Some("100"));
assert_eq!(get("Max").as_deref(), Some("300"));
assert!(get("Std dev").unwrap().starts_with("81.6"));
}
#[test]
fn summary_rows_distribution_counts_each_value() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("File", "a"), ("Overall", "Low")], &[], None),
row(&[("File", "b"), ("Overall", "High")], &[], None),
row(&[("File", "c"), ("Overall", "Low")], &[], None),
];
let cols = parse_columns("File, Overall STATISTICS(DISTRIBUTION)");
let summary = res.summary_rows(&cols);
let low = summary
.iter()
.find(|r| r.label == "Overall = Low")
.expect("Low row");
assert_eq!(low.text_cell(0), "Overall = Low"); assert_eq!(low.text_cell(1), "2"); let high = summary
.iter()
.find(|r| r.label == "Overall = High")
.expect("High row");
assert_eq!(high.text_cell(1), "1");
}
#[test]
fn numeric_stats_skipped_on_non_numeric_column() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("V", "abc")], &[], None),
row(&[("V", "abc")], &[], None),
row(&[("V", "def")], &[], None),
];
let cols = parse_columns("V STATISTICS(MEAN, COUNT, MODE)");
let summary = res.summary_rows(&cols);
assert!(
!summary.iter().any(|r| r.label == "Mean"),
"a numeric stat on a text column produces no row"
);
assert_eq!(
summary
.iter()
.find(|r| r.label == "Count")
.unwrap()
.text_cell(0),
"3"
);
assert_eq!(
summary
.iter()
.find(|r| r.label == "Mode")
.unwrap()
.text_cell(0),
"abc" );
}
#[test]
fn report_statement_statistics_merge_into_resolved_columns() {
let mut res = ReportResult::default();
res.note_column("Time");
res.column_stats
.insert("Time".to_string(), vec![StatKind::Mean]);
let cols = res.resolved_columns(&Header::default());
assert_eq!(cols[0].header, "Time");
assert_eq!(cols[0].stats, vec![StatKind::Mean]);
}
}