aerocontext_core/
compare.rs1use std::collections::{BTreeSet, HashMap};
10use std::fmt;
11
12use crate::model::{Briefing, ProductKind};
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct ProductKey {
18 pub kind: ProductKind,
20 pub location: Option<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SourcedValue {
27 pub source: String,
29 pub raw_text: String,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Agreement {
36 Single,
38 Agree,
41 Disagree,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ProductComparison {
48 pub key: ProductKey,
50 pub agreement: Agreement,
52 pub values: Vec<SourcedValue>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ComparisonReport {
60 pub comparisons: Vec<ProductComparison>,
62}
63
64impl ComparisonReport {
65 pub fn disagreements(&self) -> impl Iterator<Item = &ProductComparison> {
67 self.comparisons
68 .iter()
69 .filter(|c| c.agreement == Agreement::Disagree)
70 }
71}
72
73pub fn compare(briefings: &[Briefing]) -> ComparisonReport {
79 let mut groups: HashMap<ProductKey, Vec<SourcedValue>> = HashMap::new();
80 for briefing in briefings {
81 for product in &briefing.products {
82 let key = ProductKey {
83 kind: product.kind.clone(),
84 location: product.location.clone(),
85 };
86 groups.entry(key).or_default().push(SourcedValue {
87 source: briefing.source.clone(),
88 raw_text: product.raw_text.clone(),
89 });
90 }
91 }
92 let mut comparisons: Vec<ProductComparison> = groups
93 .into_iter()
94 .map(|(key, mut values)| {
95 values.sort_by(|a, b| {
96 a.source
97 .cmp(&b.source)
98 .then_with(|| a.raw_text.cmp(&b.raw_text))
99 });
100 let agreement = classify(&values);
101 ProductComparison {
102 key,
103 agreement,
104 values,
105 }
106 })
107 .collect();
108 comparisons.sort_by_key(|c| sort_key(&c.key));
109 ComparisonReport { comparisons }
110}
111
112fn classify(values: &[SourcedValue]) -> Agreement {
113 let sources: BTreeSet<&str> = values.iter().map(|v| v.source.as_str()).collect();
114 if sources.len() < 2 {
115 return Agreement::Single;
116 }
117 let normalized: BTreeSet<String> = values.iter().map(|v| normalize(&v.raw_text)).collect();
118 if normalized.len() == 1 {
119 Agreement::Agree
120 } else {
121 Agreement::Disagree
122 }
123}
124
125fn normalize(raw: &str) -> String {
130 let body = raw.trim().trim_end_matches('=').trim();
131 let mut tokens = body.split_whitespace().peekable();
132 if tokens
133 .peek()
134 .is_some_and(|t| matches!(*t, "METAR" | "SPECI" | "TAF"))
135 {
136 tokens.next();
137 }
138 tokens.collect::<Vec<_>>().join(" ")
139}
140
141fn sort_key(key: &ProductKey) -> (String, String) {
142 (
143 key.location.clone().unwrap_or_default(),
144 format!("{:?}", key.kind),
145 )
146}
147
148impl fmt::Display for ComparisonReport {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 for comparison in &self.comparisons {
151 let location = comparison.key.location.as_deref().unwrap_or("(area)");
152 let tag = match comparison.agreement {
153 Agreement::Agree => "AGREE".to_owned(),
154 Agreement::Disagree => "DISAGREE".to_owned(),
155 Agreement::Single => format!(
156 "only {}",
157 comparison.values.first().map_or("?", |v| v.source.as_str())
158 ),
159 };
160 writeln!(f, "{location} {:?} [{tag}]", comparison.key.kind)?;
161 for value in &comparison.values {
162 writeln!(f, " {}: {}", value.source, value.raw_text)?;
163 }
164 }
165 Ok(())
166 }
167}
168
169#[cfg(test)]
170mod tests;