use std::collections::{BTreeSet, HashMap};
use std::fmt;
use crate::model::{Briefing, ProductKind};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProductKey {
pub kind: ProductKind,
pub location: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourcedValue {
pub source: String,
pub raw_text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agreement {
Single,
Agree,
Disagree,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProductComparison {
pub key: ProductKey,
pub agreement: Agreement,
pub values: Vec<SourcedValue>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComparisonReport {
pub comparisons: Vec<ProductComparison>,
}
impl ComparisonReport {
pub fn disagreements(&self) -> impl Iterator<Item = &ProductComparison> {
self.comparisons
.iter()
.filter(|c| c.agreement == Agreement::Disagree)
}
}
pub fn compare(briefings: &[Briefing]) -> ComparisonReport {
let mut groups: HashMap<ProductKey, Vec<SourcedValue>> = HashMap::new();
for briefing in briefings {
for product in &briefing.products {
let key = ProductKey {
kind: product.kind.clone(),
location: product.location.clone(),
};
groups.entry(key).or_default().push(SourcedValue {
source: briefing.source.clone(),
raw_text: product.raw_text.clone(),
});
}
}
let mut comparisons: Vec<ProductComparison> = groups
.into_iter()
.map(|(key, mut values)| {
values.sort_by(|a, b| {
a.source
.cmp(&b.source)
.then_with(|| a.raw_text.cmp(&b.raw_text))
});
let agreement = classify(&values);
ProductComparison {
key,
agreement,
values,
}
})
.collect();
comparisons.sort_by_key(|c| sort_key(&c.key));
ComparisonReport { comparisons }
}
fn classify(values: &[SourcedValue]) -> Agreement {
let sources: BTreeSet<&str> = values.iter().map(|v| v.source.as_str()).collect();
if sources.len() < 2 {
return Agreement::Single;
}
let normalized: BTreeSet<String> = values.iter().map(|v| normalize(&v.raw_text)).collect();
if normalized.len() == 1 {
Agreement::Agree
} else {
Agreement::Disagree
}
}
fn normalize(raw: &str) -> String {
let body = raw.trim().trim_end_matches('=').trim();
let mut tokens = body.split_whitespace().peekable();
if tokens
.peek()
.is_some_and(|t| matches!(*t, "METAR" | "SPECI" | "TAF"))
{
tokens.next();
}
tokens.collect::<Vec<_>>().join(" ")
}
fn sort_key(key: &ProductKey) -> (String, String) {
(
key.location.clone().unwrap_or_default(),
format!("{:?}", key.kind),
)
}
impl fmt::Display for ComparisonReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for comparison in &self.comparisons {
let location = comparison.key.location.as_deref().unwrap_or("(area)");
let tag = match comparison.agreement {
Agreement::Agree => "AGREE".to_owned(),
Agreement::Disagree => "DISAGREE".to_owned(),
Agreement::Single => format!(
"only {}",
comparison.values.first().map_or("?", |v| v.source.as_str())
),
};
writeln!(f, "{location} {:?} [{tag}]", comparison.key.kind)?;
for value in &comparison.values {
writeln!(f, " {}: {}", value.source, value.raw_text)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests;