aerocontext-core 0.3.0

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! Cross-source reconciliation of briefings with per-source attribution.
//!
//! [`compare`] groups the products of several [`Briefing`]s by
//! (kind, location) and reports, for each group, what every source said
//! and whether the sources agree. Every value keeps its source name, so a
//! reader can always tell which source produced which text — including
//! when two sources disagree.

use std::collections::{BTreeSet, HashMap};
use std::fmt;

use crate::model::{Briefing, ProductKind};

/// Identifies products that should be compared against each other:
/// same product kind, same location.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProductKey {
    /// Product kind, e.g. METAR.
    pub kind: ProductKind,
    /// Station/location identifier, when the product is tied to one.
    pub location: Option<String>,
}

/// One source's text for a product.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourcedValue {
    /// [`crate::ContextProvider::name`] of the source that reported this.
    pub source: String,
    /// Verbatim text exactly as that source published it.
    pub raw_text: String,
}

/// Whether the sources agree on a product.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Agreement {
    /// Only one source reported this product.
    Single,
    /// Two or more sources reported it and, after normalization, their
    /// text matches.
    Agree,
    /// Two or more sources reported it and their text differs.
    Disagree,
}

/// A per-product comparison across all sources.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProductComparison {
    /// What product this compares.
    pub key: ProductKey,
    /// Agreement across the reporting sources.
    pub agreement: Agreement,
    /// Each source's value, sorted by source name then text.
    pub values: Vec<SourcedValue>,
}

/// The full cross-source comparison, ordered deterministically by
/// location then kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComparisonReport {
    /// One entry per distinct (kind, location).
    pub comparisons: Vec<ProductComparison>,
}

impl ComparisonReport {
    /// Comparisons where the reporting sources disagreed.
    pub fn disagreements(&self) -> impl Iterator<Item = &ProductComparison> {
        self.comparisons
            .iter()
            .filter(|c| c.agreement == Agreement::Disagree)
    }
}

/// Reconcile several briefings into one attributed comparison.
///
/// Each [`Briefing`] contributes its [`crate::Product`]s under its own
/// `source` name; a document-only source contributes nothing unless its
/// adapter also fills `products`.
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
    }
}

/// Canonical form for agreement testing only — the displayed value stays
/// verbatim. Strips a leading product keyword (one source labels the
/// report, another does not), a trailing `=` separator, and collapses
/// runs of whitespace.
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;