use super::record::{F107Kind, F107Record, F107ValidationError};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use thiserror::Error;
pub const F107_STORE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct F107Store {
pub schema_version: u32,
pub dataset_id: String,
pub snapshot_id: String,
pub convention: String,
#[serde(default)]
pub convention_notes: String,
pub climatology_sfu: f64,
#[serde(default)]
pub climatology_notes: String,
#[serde(default)]
pub retrieved_at_utc: Option<String>,
pub records: Vec<F107Record>,
#[serde(skip)]
pub checksum_sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{0}")]
pub struct F107StoreError(String);
impl F107StoreError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl From<F107ValidationError> for F107StoreError {
fn from(value: F107ValidationError) -> Self {
Self(value.to_string())
}
}
impl F107Store {
pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, F107StoreError> {
let mut store: Self = serde_json::from_slice(bytes)
.map_err(|error| F107StoreError::new(format!("invalid F10.7 store JSON: {error}")))?;
store.checksum_sha256 = Some(hex_sha256(bytes));
store.validate()?;
Ok(store)
}
pub fn from_json_str(input: &str) -> Result<Self, F107StoreError> {
Self::from_json_bytes(input.as_bytes())
}
pub fn to_json_bytes(&self) -> Result<Vec<u8>, F107StoreError> {
let mut bytes = serde_json::to_vec_pretty(self)
.map_err(|error| F107StoreError::new(format!("serialize F10.7 store: {error}")))?;
bytes.push(b'\n');
Ok(bytes)
}
pub fn validate(&self) -> Result<(), F107StoreError> {
if self.schema_version != F107_STORE_SCHEMA_VERSION {
return Err(F107StoreError::new(format!(
"unsupported F10.7 store schema {}",
self.schema_version
)));
}
if self.dataset_id.trim().is_empty() {
return Err(F107StoreError::new("dataset_id must be non-empty"));
}
if self.snapshot_id.trim().is_empty() {
return Err(F107StoreError::new("snapshot_id must be non-empty"));
}
if self.convention.trim().is_empty() {
return Err(F107StoreError::new("convention must be non-empty"));
}
if !self.climatology_sfu.is_finite() || self.climatology_sfu <= 0.0 {
return Err(F107StoreError::new(
"climatology_sfu must be finite and positive",
));
}
for record in &self.records {
record.validate()?;
}
self.validate_no_conflicting_duplicates()?;
Ok(())
}
fn validate_no_conflicting_duplicates(&self) -> Result<(), F107StoreError> {
let mut by_key: BTreeMap<RecordKey, &F107Record> = BTreeMap::new();
for record in &self.records {
let key = RecordKey::from(record);
if let Some(prior) = by_key.insert(key.clone(), record) {
if prior.value_sfu.to_bits() != record.value_sfu.to_bits() {
return Err(F107StoreError::new(format!(
"conflicting F10.7 records for {} kind {} product {} with identical identity keys",
record.date,
record.kind.as_str(),
record.product
)));
}
}
}
Ok(())
}
pub fn observed_covering(
&self,
requested: NaiveDate,
) -> Result<Vec<&F107Record>, F107StoreError> {
let mut matches = Vec::new();
for record in &self.records {
if record.kind != F107Kind::Observed {
continue;
}
if record.covers(requested)? {
matches.push(record);
}
}
Ok(matches)
}
pub fn forecasts_covering(
&self,
requested: NaiveDate,
) -> Result<Vec<&F107Record>, F107StoreError> {
let mut matches = Vec::new();
for record in &self.records {
if record.kind != F107Kind::Forecast {
continue;
}
if record.covers(requested)? {
matches.push(record);
}
}
Ok(matches)
}
pub fn merge_with(
&self,
incoming: &[F107Record],
snapshot_id: impl Into<String>,
retrieved_at_utc: Option<String>,
) -> Result<Self, F107StoreError> {
let mut index: BTreeMap<RecordKey, F107Record> = BTreeMap::new();
for record in &self.records {
record.validate()?;
index.insert(RecordKey::from(record), record.clone());
}
for record in incoming {
record.validate()?;
index.insert(RecordKey::from(record), record.clone());
}
let mut records: Vec<F107Record> = index.into_values().collect();
records.sort_by(|a, b| {
(&a.date, a.kind.as_str(), &a.product).cmp(&(&b.date, b.kind.as_str(), &b.product))
});
let merged = Self {
schema_version: F107_STORE_SCHEMA_VERSION,
dataset_id: self.dataset_id.clone(),
snapshot_id: snapshot_id.into(),
convention: self.convention.clone(),
convention_notes: self.convention_notes.clone(),
climatology_sfu: self.climatology_sfu,
climatology_notes: self.climatology_notes.clone(),
retrieved_at_utc,
records,
checksum_sha256: None,
};
merged.validate()?;
Ok(merged)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct RecordKey {
kind: String,
date: String,
product: String,
valid_from: String,
valid_through: String,
}
impl RecordKey {
fn from(record: &F107Record) -> Self {
Self {
kind: record.kind.as_str().to_string(),
date: record.date.clone(),
product: record.product.clone(),
valid_from: record
.valid_from
.clone()
.unwrap_or_else(|| record.date.clone()),
valid_through: record
.valid_through
.clone()
.unwrap_or_else(|| record.date.clone()),
}
}
}
pub fn hex_sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}