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>,
}
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> {
match header.columns() {
Some(spec) => parse_columns(spec),
None => self
.column_order
.iter()
.map(|k| OutputColumn {
header: k.clone(),
sources: vec![k.clone()],
})
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputColumn {
pub header: String,
pub sources: Vec<String>,
}
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 (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 })
})
.collect()
}
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;
for c in s.chars() {
match c {
'"' => {
in_quote = !in_quote;
cur.push(c);
}
_ if c == sep && !in_quote => {
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()],
};
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()],
};
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()],
};
let r = row(&[], &[], Some("staging-au"));
assert_eq!(col.value(&r, "-"), "staging-au");
}
}