use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::compare::{self, NO_CANDIDATE, RESULT_COLUMN};
use super::model::{ReportResult, ReportRow};
const BASELINE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub version: u32,
pub rows: Vec<BaselineRow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineRow {
pub key: Vec<String>,
pub cells: HashMap<String, String>,
#[serde(default)]
pub vars: HashMap<String, String>,
#[serde(default)]
pub target: Option<String>,
}
impl BaselineRow {
pub(crate) fn to_row(&self) -> ReportRow {
ReportRow {
cells: self.cells.clone(),
vars: self.vars.clone(),
key: self.key.clone(),
path: Vec::new(),
target: self.target.clone(),
}
}
}
impl Baseline {
pub fn from_result(result: &ReportResult) -> Self {
Baseline {
version: BASELINE_VERSION,
rows: result
.rows
.iter()
.map(|r| BaselineRow {
key: r.key.clone(),
cells: r.cells.clone(),
vars: r.vars.clone(),
target: r.target.clone(),
})
.collect(),
}
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
std::fs::write(path, self.to_json())
}
pub fn load(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let baseline: Baseline = serde_json::from_str(&text).map_err(|e| e.to_string())?;
if baseline.version != BASELINE_VERSION {
return Err(format!(
"unsupported baseline version {} (expected {BASELINE_VERSION})",
baseline.version
));
}
Ok(baseline)
}
}
pub fn apply(result: &mut ReportResult, baseline: &Baseline) {
if !result.column_order.iter().any(|c| c == RESULT_COLUMN) {
result.column_order.insert(0, RESULT_COLUMN.to_string());
}
let mut base_by_key: HashMap<&[String], ReportRow> = HashMap::new();
for br in &baseline.rows {
base_by_key
.entry(br.key.as_slice())
.or_insert_with(|| br.to_row());
}
let excluded = compare::excluded_keys(result);
let mut matched: HashSet<Vec<String>> = HashSet::new();
let mut baseline_rows: HashMap<usize, ReportRow> = HashMap::new();
for (i, row) in result.rows.iter_mut().enumerate() {
let base = base_by_key.get(row.key.as_slice());
if base.is_some() {
matched.insert(row.key.clone());
}
if result.track_baseline
&& let Some(base) = base
{
baseline_rows.insert(i, (*base).clone());
}
let verdict = compare::compute_result(base, row, &excluded);
row.cells.insert(RESULT_COLUMN.to_string(), verdict);
}
result.baseline_rows.extend(baseline_rows);
let mut emitted: HashSet<&[String]> = HashSet::new();
for br in &baseline.rows {
if matched.contains(&br.key) || !emitted.insert(br.key.as_slice()) {
continue;
}
let mut row = br.to_row();
row.cells
.insert(RESULT_COLUMN.to_string(), NO_CANDIDATE.to_string());
result.rows.push(row);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::compare::{MATCH, NO_BASELINE};
fn row(key: &[&str], cells: &[(&str, &str)]) -> ReportRow {
ReportRow {
cells: cells
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
vars: HashMap::new(),
key: key.iter().map(|k| k.to_string()).collect(),
path: Vec::new(),
target: None,
}
}
fn result(rows: Vec<ReportRow>) -> ReportResult {
ReportResult {
rows,
..Default::default()
}
}
#[test]
fn snapshot_round_trips_through_json() {
let res = result(vec![
row(&["a"], &[("proc.overall", "CLEAR")]),
row(&["b"], &[("proc.overall", "REVIEW")]),
]);
let snap = Baseline::from_result(&res);
let json = snap.to_json();
let back: Baseline = serde_json::from_str(&json).unwrap();
assert_eq!(back.version, BASELINE_VERSION);
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].key, vec!["a".to_string()]);
assert_eq!(
back.rows[1].cells.get("proc.overall"),
Some(&"REVIEW".to_string())
);
}
#[test]
fn load_rejects_unknown_version() {
let json = r#"{"version":999,"rows":[]}"#;
let dir = std::env::temp_dir();
let path = dir.join(format!("pb_baseline_ver_{}.baseline", std::process::id()));
std::fs::write(&path, json).unwrap();
let err = Baseline::load(&path).unwrap_err();
assert!(err.contains("unsupported baseline version 999"), "{err}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn matching_row_reports_ok_and_adds_result_column() {
let snap = Baseline::from_result(&result(vec![row(&["a"], &[("proc.overall", "CLEAR")])]));
let mut res = result(vec![row(&["a"], &[("proc.overall", "CLEAR")])]);
apply(&mut res, &snap);
assert_eq!(res.column_order.first(), Some(&RESULT_COLUMN.to_string()));
assert_eq!(res.rows.len(), 1);
assert_eq!(
res.rows[0].cells.get(RESULT_COLUMN),
Some(&MATCH.to_string())
);
}
#[test]
fn differing_field_is_summarised() {
let snap = Baseline::from_result(&result(vec![row(&["a"], &[("proc.overall", "CLEAR")])]));
let mut res = result(vec![row(&["a"], &[("proc.overall", "REVIEW")])]);
apply(&mut res, &snap);
let result_cell = res.rows[0].cells.get(RESULT_COLUMN).expect("Result column");
let parsed: serde_json::Value = serde_json::from_str(result_cell).expect("valid JSON");
let obj = parsed.as_object().expect("object");
assert!(obj.contains_key("baseline (baseline)"));
assert!(obj.contains_key("comparison"));
assert_eq!(obj["baseline (baseline)"]["overall"], "CLEAR");
assert_eq!(obj["comparison"]["overall"], "REVIEW");
}
#[test]
fn current_row_without_snapshot_reads_no_baseline() {
let snap = Baseline::from_result(&result(vec![]));
let mut res = result(vec![row(&["a"], &[("proc.overall", "CLEAR")])]);
apply(&mut res, &snap);
assert_eq!(
res.rows[0].cells.get(RESULT_COLUMN),
Some(&NO_BASELINE.to_string())
);
}
#[test]
fn snapshot_row_without_candidate_is_appended() {
let snap = Baseline::from_result(&result(vec![
row(&["a"], &[("proc.overall", "CLEAR")]),
row(&["b"], &[("proc.overall", "CLEAR")]),
]));
let mut res = result(vec![row(&["a"], &[("proc.overall", "CLEAR")])]);
apply(&mut res, &snap);
assert_eq!(res.rows.len(), 2, "matched row + appended no-candidate row");
assert_eq!(
res.rows[0].cells.get(RESULT_COLUMN),
Some(&MATCH.to_string())
);
let appended = &res.rows[1];
assert_eq!(appended.key, vec!["b".to_string()]);
assert_eq!(
appended.cells.get(RESULT_COLUMN),
Some(&NO_CANDIDATE.to_string())
);
assert_eq!(
appended.cells.get("proc.overall"),
Some(&"CLEAR".to_string())
);
}
#[test]
fn fieldless_request_falls_back_to_response() {
let snap = Baseline::from_result(&result(vec![row(
&["a"],
&[("proc.HttpStatus", "200"), ("proc.Response", "{\"x\":1}")],
)]));
let mut res = result(vec![row(
&["a"],
&[("proc.HttpStatus", "200"), ("proc.Response", "{\"x\":2}")],
)]);
apply(&mut res, &snap);
let result_cell = res.rows[0].cells.get(RESULT_COLUMN).expect("Result");
let parsed: serde_json::Value = serde_json::from_str(result_cell).expect("valid JSON");
let obj = parsed.as_object().expect("object");
assert_eq!(obj["baseline (baseline)"]["Response"]["x"], 1);
assert_eq!(obj["comparison"]["Response"]["x"], 2);
}
}