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