use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use chrono::Datelike;
use crate::parse::{self, ParsedRate};
use crate::rates::Rates;
use crate::store::Entry;
use crate::types::{RateType, YearEnd, YearMonth};
const DEFAULT_BASE_URL: &str =
"https://www.trade-tariff.service.gov.uk/api/v2/exchange_rates/files";
const USER_AGENT: &str = concat!(
"hmrc-rates/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/velikodniy/hmrc-rates)"
);
const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FetchError {
#[error("HTTP request to HMRC failed: {0}")]
Http(#[from] Box<ureq::Error>),
#[error("HMRC returned malformed data from {url}: {reason}")]
BadData { url: String, reason: String },
}
pub struct Updater {
agent: ureq::Agent,
base_url: String,
cache_dir: Option<PathBuf>,
}
impl Default for Updater {
fn default() -> Updater {
Updater::new()
}
}
impl Updater {
pub fn new() -> Updater {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.user_agent(USER_AGENT)
.build()
.into();
Updater {
agent,
base_url: DEFAULT_BASE_URL.into(),
cache_dir: default_cache_dir(),
}
}
pub fn with_cache_dir(mut self, dir: impl Into<PathBuf>) -> Updater {
self.cache_dir = Some(dir.into());
self
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Updater {
self.base_url = url.into();
self
}
pub fn cached(&self) -> Rates {
let mut rates = Rates::new();
self.apply_cache(&mut rates);
rates
}
pub fn refreshed(&self) -> Result<Rates, FetchError> {
let mut rates = Rates::new();
self.apply_cache(&mut rates);
let today = chrono::Utc::now().date_naive();
let current = YearMonth::from(today);
let first_missing = first_gap(rates.months(), YearMonth::next).unwrap_or(current);
let mut candidate = first_missing.min(current);
while candidate <= current.next() {
let amendable = candidate >= current;
if !amendable && rates.monthly(candidate).is_ok() {
candidate = candidate.next();
continue; }
let name = format!("monthly_xml_{candidate}.xml");
let entries = self.obtain(&name, amendable, |bytes| {
validated_monthly(bytes, candidate)
})?;
if let Some(entries) = entries {
rates.set_period(RateType::Monthly, candidate.key(), entries);
}
candidate = candidate.next();
}
for (rate_type, prefix) in [(RateType::Spot, "spot"), (RateType::Average, "average")] {
let periods: Vec<YearEnd> = match rate_type {
RateType::Spot => rates.spot_periods().collect(),
_ => rates.average_periods().collect(),
};
let Some(first_missing) = first_gap(periods.iter().copied(), next_year_end) else {
continue;
};
let mut period = periods
.last()
.map_or(first_missing, |n| first_missing.min(*n));
loop {
let end = period.end_year_month();
if YearMonth::from(today) < end {
break; }
let name = format!("{prefix}_csv_{}-{:02}.csv", period.year(), end.month());
let days_past_end =
today.num_days_from_ce() - end_of_month(period).num_days_from_ce();
let amendable = (0..=60).contains(&days_past_end);
let have = periods.binary_search(&period).is_ok();
if !have || amendable {
let entries = self.obtain(&name, amendable, |bytes| {
dedup(parse::parse_rates_csv(bytes)?)
})?;
if let Some(entries) = entries {
rates.set_period(rate_type, period.key(), entries);
}
}
period = next_year_end(period);
}
}
Ok(rates)
}
fn obtain<T>(
&self,
name: &str,
amendable: bool,
validate: impl Fn(&[u8]) -> Result<T, parse::ParseError>,
) -> Result<Option<T>, FetchError> {
if let Some(bytes) = self.fresh_cache_bytes(name, amendable) {
if let Ok(value) = validate(&bytes) {
return Ok(Some(value));
}
}
let url = format!("{}/{}", self.base_url, name);
let bytes = match self.agent.get(&url).call() {
Ok(mut response) => response
.body_mut()
.read_to_vec()
.map_err(|e| FetchError::Http(Box::new(e)))?,
Err(ureq::Error::StatusCode(404)) => return Ok(None),
Err(e) => return Err(FetchError::Http(Box::new(e))),
};
let value = validate(&bytes).map_err(|e| self.bad_data(name, e))?;
self.store(name, &bytes);
Ok(Some(value))
}
fn fresh_cache_bytes(&self, name: &str, amendable: bool) -> Option<Vec<u8>> {
let path = self.cache_path(name)?;
let metadata = fs::metadata(&path).ok()?;
let fresh = !amendable
|| metadata
.modified()
.ok()
.and_then(|t| SystemTime::now().duration_since(t).ok())
.is_some_and(|age| age < CACHE_TTL);
if !fresh {
return None;
}
fs::read(&path).ok()
}
fn apply_cache(&self, rates: &mut Rates) {
let Some(dir) = self.cache_dir.as_deref() else {
return;
};
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut files: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
files.sort();
for path in files {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Ok(bytes) = fs::read(&path) else { continue };
self.apply_file(rates, name, &bytes);
}
}
fn apply_file(&self, rates: &mut Rates, name: &str, bytes: &[u8]) -> Option<()> {
if let Some(rest) = name
.strip_prefix("monthly_xml_")
.and_then(|r| r.strip_suffix(".xml"))
{
let year_month: YearMonth = rest.parse().ok()?;
let entries = validated_monthly(bytes, year_month).ok()?;
rates.set_period(RateType::Monthly, year_month.key(), entries);
return Some(());
}
for (rate_type, prefix) in [
(RateType::Spot, "spot_csv_"),
(RateType::Average, "average_csv_"),
] {
if let Some(rest) = name
.strip_prefix(prefix)
.and_then(|r| r.strip_suffix(".csv"))
{
let year_end = YearEnd::from_year_month(rest.parse().ok()?)?;
let entries = dedup(parse::parse_rates_csv(bytes).ok()?).ok()?;
rates.set_period(rate_type, year_end.key(), entries);
return Some(());
}
}
None
}
fn store(&self, name: &str, bytes: &[u8]) {
let Some(path) = self.cache_path(name) else {
return;
};
let Some(dir) = path.parent() else { return };
if fs::create_dir_all(dir).is_err() {
return;
}
static TMP_SEQ: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
let seq = TMP_SEQ.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
let tmp = dir.join(format!(".{name}.{}.{seq}.tmp", std::process::id()));
if fs::write(&tmp, bytes).is_ok() && fs::rename(&tmp, &path).is_err() {
let _ = fs::remove_file(&tmp);
}
}
fn cache_path(&self, name: &str) -> Option<PathBuf> {
Some(self.cache_dir.as_deref()?.join(name))
}
fn bad_data(&self, name: &str, e: parse::ParseError) -> FetchError {
FetchError::BadData {
url: format!("{}/{}", self.base_url, name),
reason: e.to_string(),
}
}
}
fn default_cache_dir() -> Option<PathBuf> {
use etcetera::BaseStrategy;
let strategy = etcetera::choose_base_strategy().ok()?;
Some(strategy.cache_dir().join("hmrc-rates").join("v1"))
}
fn dedup(raw: Vec<ParsedRate>) -> Result<Vec<Entry>, parse::ParseError> {
Ok(parse::dedup_majority(raw)?
.into_iter()
.map(|r| Entry {
mantissa: r.mantissa,
code: r.code,
scale: r.scale,
})
.collect())
}
fn validated_monthly(bytes: &[u8], expected: YearMonth) -> Result<Vec<Entry>, parse::ParseError> {
let ((y, m), raw) = parse::parse_monthly_xml(bytes)?;
if YearMonth::new(y, m) != Some(expected) {
return Err(parse::ParseError("period mismatch".into()));
}
dedup(raw)
}
fn first_gap<T: Copy + PartialEq>(
mut periods: impl Iterator<Item = T>,
next: impl Fn(T) -> T,
) -> Option<T> {
let mut expected = next(periods.next()?);
for period in periods {
if period != expected {
break;
}
expected = next(period);
}
Some(expected)
}
fn next_year_end(period: YearEnd) -> YearEnd {
if period.is_march() {
YearEnd::december(period.year())
} else {
YearEnd::march(period.year() + 1)
}
}
fn end_of_month(period: YearEnd) -> chrono::NaiveDate {
let year_month = period.end_year_month();
let last_day = parse::date::days_in_month(period.year(), year_month.month());
chrono::NaiveDate::from_ymd_opt(period.year(), year_month.month(), last_day).unwrap_or_default()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn first_gap_resumes_at_the_first_missing_period() {
let months = |keys: &[i32]| {
keys.iter()
.map(|k| YearMonth::from_key(*k))
.collect::<Vec<_>>()
};
let run = months(&[10, 11, 12]);
assert_eq!(
first_gap(run.into_iter(), YearMonth::next),
Some(YearMonth::from_key(13))
);
let holed = months(&[10, 11, 14]);
assert_eq!(
first_gap(holed.into_iter(), YearMonth::next),
Some(YearMonth::from_key(12))
);
assert_eq!(
first_gap(core::iter::empty::<YearMonth>(), YearMonth::next),
None
);
}
#[test]
fn year_end_sequence_alternates() {
let periods = [
YearEnd::march(2025),
YearEnd::december(2025),
YearEnd::march(2026),
];
assert_eq!(
first_gap(periods.into_iter(), next_year_end),
Some(YearEnd::december(2026))
);
let gapped = [YearEnd::march(2025), YearEnd::march(2026)];
assert_eq!(
first_gap(gapped.into_iter(), next_year_end),
Some(YearEnd::december(2025))
);
}
}