use crate::error::Result;
use crate::types::EventTiming;
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, error, info, warn};
pub const CALENDAR_URL: &str = "https://nfs.faireconomy.media/ff_calendar_thisweek.json";
pub const DEFAULT_CACHE_FILENAME: &str = "economic_calendar.json";
pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
pub const DEFAULT_MAX_EVENT_COUNT: usize = 50_000;
pub const DEFAULT_USER_AGENT: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
fn compute_events_sha256(events: &[RawCalendarEvent]) -> String {
let mut hasher = Sha256::new();
if let Ok(bytes) = serde_json::to_vec(events) {
hasher.update(&bytes);
}
let result = hasher.finalize();
format!("{result:x}")
}
pub type CalendarIntegrityValidator = Arc<dyn Fn(&[RawCalendarEvent]) -> Result<()> + Send + Sync>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CacheMetadata {
pub version: u32,
pub fetched_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub event_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CachedCalendarData {
pub metadata: CacheMetadata,
pub events: Vec<RawCalendarEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RawCalendarEvent {
#[serde(default)]
pub title: String,
#[serde(default)]
pub country: String,
#[serde(default)]
pub date: String,
#[serde(default)]
pub time: String,
#[serde(default)]
pub impact: String,
}
impl RawCalendarEvent {
#[must_use]
pub fn is_all_day(&self) -> bool {
let t = self.time.trim();
t.eq_ignore_ascii_case("all day")
|| t.to_lowercase().starts_with("day ")
|| self.date.to_lowercase().contains("all day")
}
#[must_use]
pub fn is_tentative(&self) -> bool {
self.time.trim().eq_ignore_ascii_case("tentative")
}
}
#[derive(Clone)]
pub struct CalendarClient {
client: Client,
calendar_url: String,
fallback_urls: Vec<String>,
cache_path: Option<PathBuf>,
request_timeout: Duration,
cache_ttl: Option<Duration>,
calendar_timezone: Option<chrono_tz::Tz>,
max_stale_cache_age: Option<Duration>,
max_response_bytes: usize,
max_event_count: usize,
max_retries: usize,
backoff_initial_delay: Duration,
max_retry_after: Duration,
overall_timeout: Option<Duration>,
integrity_validator: Option<CalendarIntegrityValidator>,
}
impl std::fmt::Debug for CalendarClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CalendarClient")
.field("calendar_url", &self.calendar_url)
.field("fallback_urls", &self.fallback_urls)
.field("cache_path", &self.cache_path)
.field("request_timeout", &self.request_timeout)
.field("cache_ttl", &self.cache_ttl)
.field("calendar_timezone", &self.calendar_timezone)
.field("max_stale_cache_age", &self.max_stale_cache_age)
.field("max_response_bytes", &self.max_response_bytes)
.field("max_event_count", &self.max_event_count)
.field("max_retries", &self.max_retries)
.field("backoff_initial_delay", &self.backoff_initial_delay)
.field("max_retry_after", &self.max_retry_after)
.field("overall_timeout", &self.overall_timeout)
.field(
"integrity_validator",
&self
.integrity_validator
.as_ref()
.map(|_| "<custom_validator>"),
)
.finish()
}
}
impl Default for CalendarClient {
fn default() -> Self {
Self::new(None)
}
}
fn fallback_client() -> Client {
Client::builder()
.user_agent(DEFAULT_USER_AGENT)
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|err| {
warn!(
err = %err,
"failed to build reqwest::Client with custom User-Agent; falling back to Client::default()"
);
Client::default()
})
}
impl CalendarClient {
#[must_use]
pub fn default_cache_dir() -> PathBuf {
#[cfg(windows)]
{
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
return PathBuf::from(local_app_data)
.join("redfolder")
.join("cache");
}
if let Ok(app_data) = std::env::var("APPDATA") {
return PathBuf::from(app_data).join("redfolder").join("cache");
}
if let Ok(user_profile) = std::env::var("USERPROFILE") {
return PathBuf::from(user_profile).join(".cache").join("redfolder");
}
}
#[cfg(not(windows))]
{
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
return PathBuf::from(xdg).join("redfolder");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".cache").join("redfolder");
}
}
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
return PathBuf::from(xdg).join("redfolder");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".cache").join("redfolder");
}
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
return PathBuf::from(local_app_data)
.join("redfolder")
.join("cache");
}
if let Ok(user_profile) = std::env::var("USERPROFILE") {
return PathBuf::from(user_profile).join(".cache").join("redfolder");
}
warn!(
"falling back to system temp directory for calendar cache. On shared/multi-user systems, consider configuring an explicit cache path or XDG_CACHE_HOME/LOCALAPPDATA to prevent cache tampering"
);
std::env::temp_dir().join("redfolder_cache")
}
pub fn try_new(cache_dir: Option<PathBuf>) -> Result<Self> {
let dir = cache_dir.or_else(|| Some(Self::default_cache_dir()));
let client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(DEFAULT_USER_AGENT)
.build()?;
Ok(Self::with_options(
client,
CALENDAR_URL,
dir,
Duration::from_secs(30),
))
}
#[must_use]
pub fn new(cache_dir: Option<PathBuf>) -> Self {
Self::try_new(cache_dir.clone()).unwrap_or_else(|err| {
error!(err=%err, "failed to build configured HTTP client; falling back to default with User-Agent");
let dir = cache_dir.or_else(|| Some(Self::default_cache_dir()));
Self::with_options(fallback_client(), CALENDAR_URL, dir, Duration::from_secs(30))
})
}
pub fn try_without_cache() -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(DEFAULT_USER_AGENT)
.build()?;
Ok(Self::with_options(
client,
CALENDAR_URL,
None,
Duration::from_secs(30),
))
}
#[must_use]
pub fn without_cache() -> Self {
Self::try_without_cache().unwrap_or_else(|err| {
error!(err=%err, "failed to build configured HTTP client; falling back to default with User-Agent");
Self::with_options(fallback_client(), CALENDAR_URL, None, Duration::from_secs(30))
})
}
pub fn with_user_agent(cache_dir: Option<PathBuf>, user_agent: &str) -> Result<Self> {
let dir = cache_dir.or_else(|| Some(Self::default_cache_dir()));
let client = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(user_agent)
.build()?;
Ok(Self::with_options(
client,
CALENDAR_URL,
dir,
Duration::from_secs(30),
))
}
pub fn with_options(
client: Client,
calendar_url: impl Into<String>,
cache_dir: Option<PathBuf>,
request_timeout: Duration,
) -> Self {
let cache_path = cache_dir.map(|dir| dir.join(DEFAULT_CACHE_FILENAME));
Self {
client,
calendar_url: calendar_url.into(),
fallback_urls: Vec::new(),
cache_path,
request_timeout,
cache_ttl: None,
calendar_timezone: None,
max_stale_cache_age: Some(Duration::from_secs(36 * 3600)),
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
max_event_count: DEFAULT_MAX_EVENT_COUNT,
max_retries: 2,
backoff_initial_delay: Duration::from_millis(500),
max_retry_after: Duration::from_secs(10),
overall_timeout: None,
integrity_validator: None,
}
}
#[must_use]
pub fn with_fallback_url(mut self, url: impl Into<String>) -> Self {
self.fallback_urls.push(url.into());
self
}
#[must_use]
pub fn with_fallback_urls<I, S>(mut self, urls: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.fallback_urls.extend(urls.into_iter().map(Into::into));
self
}
#[must_use]
pub fn fallback_urls(&self) -> &[String] {
&self.fallback_urls
}
#[must_use]
pub fn calendar_url(&self) -> &str {
&self.calendar_url
}
pub fn set_calendar_url(&mut self, url: impl Into<String>) {
self.calendar_url = url.into();
}
pub fn add_fallback_url(&mut self, url: impl Into<String>) {
self.fallback_urls.push(url.into());
}
#[must_use]
pub fn with_max_response_bytes(mut self, max_bytes: usize) -> Self {
self.max_response_bytes = max_bytes;
self
}
pub fn set_max_response_bytes(&mut self, max_bytes: usize) {
self.max_response_bytes = max_bytes;
}
#[must_use]
pub fn max_response_bytes(&self) -> usize {
self.max_response_bytes
}
#[must_use]
pub fn with_max_event_count(mut self, max_events: usize) -> Self {
self.max_event_count = max_events;
self
}
pub fn set_max_event_count(&mut self, max_events: usize) {
self.max_event_count = max_events;
}
#[must_use]
pub fn max_event_count(&self) -> usize {
self.max_event_count
}
#[must_use]
pub fn with_max_retries(mut self, max_retries: usize) -> Self {
self.max_retries = max_retries;
self
}
#[must_use]
pub fn max_retries(&self) -> usize {
self.max_retries
}
#[must_use]
pub fn with_backoff(mut self, initial_delay: Duration, max_retry_after: Duration) -> Self {
self.backoff_initial_delay = initial_delay;
self.max_retry_after = max_retry_after;
self
}
#[must_use]
pub fn backoff_initial_delay(&self) -> Duration {
self.backoff_initial_delay
}
#[must_use]
pub fn max_retry_after(&self) -> Duration {
self.max_retry_after
}
#[must_use]
pub fn with_overall_timeout(mut self, timeout: Duration) -> Self {
self.overall_timeout = Some(timeout);
self
}
#[must_use]
pub fn overall_timeout(&self) -> Option<Duration> {
self.overall_timeout
}
#[must_use]
pub fn with_integrity_validator(
mut self,
validator: impl Fn(&[RawCalendarEvent]) -> Result<()> + Send + Sync + 'static,
) -> Self {
self.integrity_validator = Some(Arc::new(validator));
self
}
#[must_use]
pub fn with_timezone(mut self, tz: chrono_tz::Tz) -> Self {
self.calendar_timezone = Some(tz);
self
}
pub fn set_calendar_timezone(&mut self, tz: Option<chrono_tz::Tz>) {
self.calendar_timezone = tz;
}
#[must_use]
pub fn calendar_timezone(&self) -> Option<chrono_tz::Tz> {
self.calendar_timezone
}
#[must_use]
pub fn with_max_stale_age(mut self, max_age: Option<Duration>) -> Self {
self.max_stale_cache_age = max_age;
self
}
pub fn set_max_stale_cache_age(&mut self, max_age: Option<Duration>) {
self.max_stale_cache_age = max_age;
}
#[must_use]
pub fn max_stale_cache_age(&self) -> Option<Duration> {
self.max_stale_cache_age
}
#[must_use]
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.cache_ttl = Some(ttl);
self
}
pub fn set_cache_ttl(&mut self, ttl: Option<Duration>) {
self.cache_ttl = ttl;
}
#[must_use]
pub fn cache_ttl(&self) -> Option<Duration> {
self.cache_ttl
}
pub fn set_cache_path(&mut self, path: impl AsRef<Path>) {
self.cache_path = Some(path.as_ref().to_path_buf());
}
#[must_use]
pub fn cache_path(&self) -> Option<&Path> {
self.cache_path.as_deref()
}
pub async fn fetch_remote(&self) -> Result<Vec<RawCalendarEvent>> {
if let Some(timeout) = self.overall_timeout {
tokio::time::timeout(timeout, self.fetch_remote_candidates())
.await
.map_err(|_| {
crate::error::RedFolderError::Calendar(format!(
"calendar fetch exceeded overall timeout ceiling of {:?}",
timeout
))
})?
} else {
self.fetch_remote_candidates().await
}
}
async fn fetch_remote_candidates(&self) -> Result<Vec<RawCalendarEvent>> {
let mut candidate_urls: Vec<&str> = Vec::with_capacity(1 + self.fallback_urls.len());
candidate_urls.push(&self.calendar_url);
for fb in &self.fallback_urls {
candidate_urls.push(fb);
}
let mut last_error = None;
for (url_idx, &url) in candidate_urls.iter().enumerate() {
debug!(url=%url, url_idx, "fetching economic calendar");
let mut url_error = None;
for attempt in 0..=self.max_retries {
match self
.client
.get(url)
.timeout(self.request_timeout)
.send()
.await
{
Ok(mut resp) => {
let status = resp.status();
if status.is_success() {
if let Some(len) = resp.content_length() {
if len > self.max_response_bytes as u64 {
url_error = Some(crate::error::RedFolderError::Calendar(format!(
"upstream response Content-Length ({len} bytes) exceeds maximum limit of {} bytes",
self.max_response_bytes
)));
break;
}
}
let mut body_bytes = Vec::new();
let mut stream_err = None;
while let Some(chunk_res) = resp.chunk().await.transpose() {
match chunk_res {
Ok(chunk) => {
if body_bytes.len() + chunk.len() > self.max_response_bytes
{
stream_err = Some(crate::error::RedFolderError::Calendar(format!(
"upstream response body exceeded maximum limit of {} bytes",
self.max_response_bytes
)));
break;
}
body_bytes.extend_from_slice(&chunk);
}
Err(e) => {
stream_err = Some(crate::error::RedFolderError::Http(e));
break;
}
}
}
if let Some(e) = stream_err {
warn!(err = %e, attempt, url = %url, "failed streaming response body");
url_error = Some(e);
if attempt < self.max_retries {
let delay = self.backoff_initial_delay * (1 << attempt);
tokio::time::sleep(delay).await;
continue;
}
break;
}
match serde_json::from_slice::<Vec<serde_json::Value>>(&body_bytes) {
Ok(raw_items) => {
let total_count = raw_items.len();
if total_count > self.max_event_count {
return Err(crate::error::RedFolderError::Calendar(format!(
"upstream response contained {total_count} events, exceeding limit of {}",
self.max_event_count
)));
}
let mut events = Vec::with_capacity(total_count);
let mut malformed_count = 0;
for item in raw_items {
match serde_json::from_value::<RawCalendarEvent>(item) {
Ok(ev) => {
let trimmed_date = ev.date.trim();
let is_length_valid =
ev.title.len() <= 500 && ev.country.len() <= 10;
if trimmed_date.is_empty() || !is_length_valid {
malformed_count += 1;
} else {
events.push(ev);
}
}
Err(_) => {
malformed_count += 1;
}
}
}
if total_count > 0 && malformed_count == total_count {
return Err(crate::error::RedFolderError::Calendar(
"all upstream calendar events were malformed"
.to_string(),
));
}
if malformed_count > 0 {
warn!(
malformed = %malformed_count,
total = %total_count,
"some upstream events failed validation"
);
if malformed_count * 5 > total_count {
return Err(crate::error::RedFolderError::Calendar(format!(
"upstream response corrupted: {malformed_count}/{total_count} events malformed"
)));
}
}
if let Some(ref validator) = self.integrity_validator {
validator(&events)?;
}
info!(url=%url, count = %events.len(), "downloaded calendar events successfully");
return Ok(events);
}
Err(e) => {
warn!(err = %e, attempt, url = %url, "failed to parse calendar response JSON");
url_error = Some(crate::error::RedFolderError::Json(e));
if attempt < self.max_retries {
let delay = self.backoff_initial_delay * (1 << attempt);
tokio::time::sleep(delay).await;
continue;
}
}
}
} else {
let is_rate_limited = status == reqwest::StatusCode::TOO_MANY_REQUESTS;
let is_timeout = status == reqwest::StatusCode::REQUEST_TIMEOUT;
let is_server_err = status.is_server_error();
let is_retryable = is_rate_limited || is_timeout || is_server_err;
let retry_after_duration = if is_rate_limited {
resp.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|val| val.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok())
.map(|secs| Duration::from_secs(secs).min(self.max_retry_after))
} else {
None
};
warn!(
status = %status,
retryable = is_retryable,
retry_after = ?retry_after_duration,
attempt,
url = %url,
"calendar HTTP fetch returned non-success status"
);
if let Err(e) = resp.error_for_status() {
url_error = Some(crate::error::RedFolderError::Http(e));
}
if !is_retryable || attempt == self.max_retries {
break;
}
let delay = retry_after_duration
.unwrap_or_else(|| self.backoff_initial_delay * (1 << attempt));
debug!(delay = ?delay, "sleeping before retrying rate-limited or transient failure");
tokio::time::sleep(delay).await;
continue;
}
}
Err(e) => {
warn!(err = %e, attempt, url = %url, "calendar HTTP transport request failed");
url_error = Some(crate::error::RedFolderError::Http(e));
if attempt < self.max_retries {
let delay = self.backoff_initial_delay * (1 << attempt);
tokio::time::sleep(delay).await;
continue;
}
}
}
}
if let Some(err) = url_error {
warn!(url=%url, err=%err, "calendar candidate endpoint failed; trying next fallback if available");
last_error = Some(err);
}
}
Err(last_error.unwrap_or_else(|| {
crate::error::RedFolderError::Calendar(
"fetch failed after retries on all candidate endpoints".into(),
)
}))
}
pub fn save_cache(&self, events: &[RawCalendarEvent]) -> Result<()> {
let Some(path) = &self.cache_path else {
return Ok(());
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let now = Utc::now();
let expires_at = self
.cache_ttl
.and_then(|ttl| chrono::Duration::from_std(ttl).ok().map(|d| now + d));
let events_sha256 = compute_events_sha256(events);
let cached_data = CachedCalendarData {
metadata: CacheMetadata {
version: 1,
fetched_at: now,
expires_at,
event_count: events.len(),
sha256: Some(events_sha256),
},
events: events.to_vec(),
};
let json = serde_json::to_string_pretty(&cached_data)?;
static CACHE_WRITE_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let counter = CACHE_WRITE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let temp_path = path.with_extension(format!("tmp.{pid}.{nanos}.{counter}"));
let write_result = (|| -> std::io::Result<()> {
use std::io::Write;
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&temp_path)?
};
#[cfg(not(unix))]
let mut file = std::fs::File::create(&temp_path)?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
std::fs::rename(&temp_path, path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&temp_path);
return Err(crate::error::RedFolderError::Io(e));
}
debug!(path=%path.display(), count=%events.len(), "saved calendar cache atomically with metadata and checksum");
Ok(())
}
pub fn load_cache_data(&self) -> Option<CachedCalendarData> {
let path = self.cache_path.as_ref()?;
if !path.exists() {
return None;
}
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(e) => {
warn!(path=%path.display(), err=%e, "failed to read calendar cache file");
return None;
}
};
if let Ok(cached) = serde_json::from_str::<CachedCalendarData>(&data) {
if cached.metadata.version == 1 {
if cached.metadata.event_count != cached.events.len() {
warn!(
path = %path.display(),
expected = cached.metadata.event_count,
actual = cached.events.len(),
"calendar cache event count mismatch; rejecting corrupted cache"
);
return None;
}
if let Some(ref expected_sha) = cached.metadata.sha256 {
let actual_sha = compute_events_sha256(&cached.events);
if actual_sha != *expected_sha {
warn!(
path = %path.display(),
expected = %expected_sha,
actual = %actual_sha,
"calendar cache sha256 checksum mismatch; rejecting tampered or corrupted cache"
);
return None;
}
}
debug!(path=%path.display(), version=%cached.metadata.version, count=%cached.events.len(), "loaded structured calendar cache v1");
return Some(cached);
} else {
warn!(path=%path.display(), version=%cached.metadata.version, "unsupported cache version; ignoring");
return None;
}
}
if let Ok(events) = serde_json::from_str::<Vec<RawCalendarEvent>>(&data) {
debug!(path=%path.display(), count=%events.len(), "loaded legacy calendar cache");
return Some(CachedCalendarData {
metadata: CacheMetadata {
version: 0,
fetched_at: DateTime::<Utc>::UNIX_EPOCH,
expires_at: None,
event_count: events.len(),
sha256: None,
},
events,
});
}
warn!(path=%path.display(), "corrupted calendar cache file; ignoring and falling back");
None
}
#[must_use]
pub fn load_cache(&self) -> Option<Vec<RawCalendarEvent>> {
self.load_cache_data().map(|c| c.events)
}
#[must_use]
pub fn is_cache_valid(&self) -> bool {
let Some(data) = self.load_cache_data() else {
return false;
};
if data.metadata.version == 0 {
return false;
}
if let Some(expires_at) = data.metadata.expires_at {
Utc::now() <= expires_at
} else {
true
}
}
pub async fn force_fetch(&self) -> Result<Vec<RawCalendarEvent>> {
let events = self.fetch_remote().await?;
if events.is_empty() {
warn!("remote calendar returned 0 events during forced fetch; checking disk cache to protect existing data");
if let Some(cached) = self.load_cache() {
if !cached.is_empty() {
warn!(count=%cached.len(), "preserved valid disk cache instead of overwriting with empty remote response");
return Ok(cached);
}
}
Ok(events)
} else {
if let Err(e) = self.save_cache(&events) {
warn!(err=%e, "failed to persist calendar cache");
}
Ok(events)
}
}
pub async fn fetch_or_cached(&self) -> Result<Vec<RawCalendarEvent>> {
if self.cache_ttl.is_some() && self.is_cache_valid() {
if let Some(cached) = self.load_cache() {
if !cached.is_empty() {
debug!(count=%cached.len(), "serving calendar from valid local cache (TTL active)");
return Ok(cached);
}
}
}
match self.fetch_remote().await {
Ok(events) => {
if events.is_empty() {
warn!("remote calendar returned 0 events; checking disk cache to protect existing data");
if let Some(cached) = self.load_cache() {
if !cached.is_empty() {
warn!(count=%cached.len(), "preserved valid disk cache instead of overwriting with empty remote response");
return Ok(cached);
}
}
Ok(events)
} else {
if let Err(e) = self.save_cache(&events) {
warn!(err=%e, "failed to persist calendar cache");
}
Ok(events)
}
}
Err(e) => {
error!(err=%e, "failed to download calendar; checking disk cache fallback");
if let Some(cached_data) = self.load_cache_data() {
if !cached_data.events.is_empty() {
if cached_data.metadata.version == 0 {
warn!(
path = %self.cache_path.as_deref().unwrap_or(Path::new("")).display(),
"legacy cache lacks fetch timestamp; rejecting as stale fallback"
);
return Err(e);
}
let is_acceptable = if let Some(max_stale) = self.max_stale_cache_age {
let max_stale_chrono = chrono::Duration::from_std(max_stale)
.unwrap_or_else(|_| chrono::Duration::hours(36));
let age = Utc::now() - cached_data.metadata.fetched_at;
age <= max_stale_chrono
} else {
false
};
if is_acceptable {
warn!(count=%cached_data.events.len(), "using fallback cached calendar data");
return Ok(cached_data.events);
} else {
warn!(
fetched_at = %cached_data.metadata.fetched_at,
"cached calendar data is too stale or stale fallback disabled; rejecting fallback"
);
}
}
}
Err(e)
}
}
}
}
pub fn parse_event_timing(
raw: &RawCalendarEvent,
default_tz: Option<chrono_tz::Tz>,
) -> Option<EventTiming> {
let date_trimmed = raw.date.trim();
if date_trimmed.is_empty() {
return None;
}
if raw.is_all_day() {
let date_part = date_trimmed
.split_whitespace()
.next()
.unwrap_or(date_trimmed);
let date_formats = ["%Y-%m-%d", "%m-%d-%Y", "%m/%d/%Y", "%Y/%m/%d"];
for fmt in date_formats {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(date_part, fmt) {
return Some(EventTiming::AllDay(naive_date));
}
}
}
if raw.is_tentative() {
let date_part = date_trimmed
.split_whitespace()
.next()
.unwrap_or(date_trimmed);
let date_formats = ["%Y-%m-%d", "%m-%d-%Y", "%m/%d/%Y", "%Y/%m/%d"];
for fmt in date_formats {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(date_part, fmt) {
return Some(EventTiming::TentativeDate(naive_date));
}
}
}
if let Ok(dt) = DateTime::parse_from_rfc3339(date_trimmed) {
return Some(EventTiming::Exact(dt.with_timezone(&Utc)));
}
if let Ok(dt) = DateTime::parse_from_str(date_trimmed, "%+") {
return Some(EventTiming::Exact(dt.with_timezone(&Utc)));
}
if raw.time.trim().is_empty() {
let date_formats = ["%Y-%m-%d", "%m-%d-%Y", "%m/%d/%Y", "%Y/%m/%d"];
for fmt in date_formats {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(date_trimmed, fmt) {
warn!(
title = %raw.title,
country = %raw.country,
date = %date_trimmed,
"event has date but missing release time; classifying as tentative date-only event"
);
return Some(EventTiming::TentativeDate(naive_date));
}
}
}
let dt_str = if raw.time.trim().is_empty() {
date_trimmed.to_string()
} else {
format!("{date_trimmed} {}", raw.time.trim())
};
let formats = [
"%m-%d-%Y %I:%M%p",
"%Y-%m-%d %I:%M%p",
"%m/%d/%Y %I:%M%p",
"%Y/%m/%d %I:%M%p",
"%m-%d-%Y %H:%M",
"%Y-%m-%d %H:%M",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S",
];
for fmt in formats {
if let Ok(naive) = NaiveDateTime::parse_from_str(&dt_str, fmt) {
let utc_dt = if let Some(tz) = default_tz {
match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&Utc),
chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&Utc),
chrono::LocalResult::None => {
let shifted = naive + chrono::Duration::hours(1);
match tz.from_local_datetime(&shifted) {
chrono::LocalResult::Single(dt)
| chrono::LocalResult::Ambiguous(dt, _) => {
warn!(
local_time = %naive,
timezone = %tz.name(),
shifted_time = %shifted,
"local time falls in DST spring-forward gap; shifted forward 1h to valid instant"
);
dt.with_timezone(&Utc)
}
chrono::LocalResult::None => {
warn!(
local_time = %naive,
timezone = %tz.name(),
"local time in DST gap could not be resolved; rejecting invalid timestamp"
);
return None;
}
}
}
}
} else {
DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
};
return Some(EventTiming::Exact(utc_dt));
}
}
None
}
pub fn date_to_utc_start(
date: chrono::NaiveDate,
default_tz: Option<chrono_tz::Tz>,
) -> Option<DateTime<Utc>> {
let naive = date.and_hms_opt(0, 0, 0)?;
if let Some(tz) = default_tz {
match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
chrono::LocalResult::Ambiguous(earliest, _) => Some(earliest.with_timezone(&Utc)),
chrono::LocalResult::None => {
let shifted = naive + chrono::Duration::hours(1);
match tz.from_local_datetime(&shifted) {
chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
warn!(
local_date = %date,
timezone = %tz.name(),
"midnight falls in DST spring-forward gap; using 01:00:00 local time"
);
Some(dt.with_timezone(&Utc))
}
chrono::LocalResult::None => {
warn!(
local_date = %date,
timezone = %tz.name(),
"midnight in DST gap could not be resolved; rejecting invalid date"
);
None
}
}
}
}
} else {
Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
}
}
pub fn parse_event_datetime_with_tz(
raw: &RawCalendarEvent,
default_tz: Option<chrono_tz::Tz>,
) -> Option<DateTime<Utc>> {
let timing = parse_event_timing(raw, default_tz)?;
match timing {
EventTiming::Exact(dt) => Some(dt),
EventTiming::AllDay(d) | EventTiming::TentativeDate(d) => date_to_utc_start(d, default_tz),
}
}
pub fn parse_event_datetime(raw: &RawCalendarEvent) -> Option<DateTime<Utc>> {
parse_event_datetime_with_tz(raw, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_rfc3339_datetime() {
let raw = RawCalendarEvent {
title: "US Non-Farm Payrolls".into(),
country: "USD".into(),
date: "2026-06-05T12:30:00-04:00".into(),
time: String::new(),
impact: "High".into(),
};
let parsed = parse_event_datetime(&raw).expect("should parse rfc3339");
assert_eq!(parsed.to_rfc3339(), "2026-06-05T16:30:00+00:00");
}
#[test]
fn test_parse_ampm_datetime() {
let raw = RawCalendarEvent {
title: "US CPI".into(),
country: "USD".into(),
date: "06-10-2026".into(),
time: "8:30am".into(),
impact: "High".into(),
};
let parsed = parse_event_datetime(&raw).expect("should parse date + ampm");
assert_eq!(parsed.to_rfc3339(), "2026-06-10T08:30:00+00:00");
}
#[test]
fn test_cache_save_and_load() {
let temp_dir = tempfile::tempdir().unwrap();
let client = CalendarClient::new(Some(temp_dir.path().to_path_buf()));
let events = vec![RawCalendarEvent {
title: "FOMC Rate Decision".into(),
country: "USD".into(),
date: "2026-06-10T18:00:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
client.save_cache(&events).unwrap();
let loaded = client.load_cache().expect("cache should load");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].title, "FOMC Rate Decision");
let structured = client
.load_cache_data()
.expect("structured cache should load");
assert_eq!(structured.metadata.version, 1);
assert_eq!(structured.metadata.event_count, 1);
assert!(client.is_cache_valid());
}
#[test]
fn test_corrupted_cache_recovery() {
let temp_dir = tempfile::tempdir().unwrap();
let client = CalendarClient::new(Some(temp_dir.path().to_path_buf()));
let cache_file = temp_dir.path().join(DEFAULT_CACHE_FILENAME);
std::fs::write(&cache_file, "INVALID JSON { [[[[ }").unwrap();
assert!(client.load_cache().is_none());
assert!(!client.is_cache_valid());
}
#[test]
fn test_legacy_cache_fallback() {
let temp_dir = tempfile::tempdir().unwrap();
let client = CalendarClient::new(Some(temp_dir.path().to_path_buf()));
let cache_file = temp_dir.path().join(DEFAULT_CACHE_FILENAME);
let legacy_json = r#"[
{
"title": "Legacy CPI",
"country": "USD",
"date": "2026-06-10T12:00:00Z",
"time": "",
"impact": "High"
}
]"#;
std::fs::write(&cache_file, legacy_json).unwrap();
let loaded = client
.load_cache()
.expect("legacy cache should be supported");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].title, "Legacy CPI");
}
#[test]
fn test_all_day_and_tentative_parsing() {
let all_day = RawCalendarEvent {
title: "OPEC-JMMC Meetings".into(),
country: "ALL".into(),
date: "2026-06-15".into(),
time: "All Day".into(),
impact: "High".into(),
};
assert!(all_day.is_all_day());
let parsed_all_day = parse_event_datetime(&all_day).expect("should parse all-day event");
assert_eq!(parsed_all_day.to_rfc3339(), "2026-06-15T00:00:00+00:00");
let tentative = RawCalendarEvent {
title: "Chinese Trade Balance".into(),
country: "CNY".into(),
date: "2026-07-10".into(),
time: "Tentative".into(),
impact: "Medium".into(),
};
assert!(tentative.is_tentative());
let parsed_tentative =
parse_event_datetime(&tentative).expect("should parse tentative event");
assert_eq!(parsed_tentative.to_rfc3339(), "2026-07-10T00:00:00+00:00");
}
#[test]
fn test_dst_boundary_parsing() {
let winter = RawCalendarEvent {
title: "US CPI (Winter)".into(),
country: "USD".into(),
date: "2026-01-15T08:30:00-05:00".into(),
time: "".into(),
impact: "High".into(),
};
let parsed_winter = parse_event_datetime(&winter).unwrap();
assert_eq!(parsed_winter.to_rfc3339(), "2026-01-15T13:30:00+00:00");
let summer = RawCalendarEvent {
title: "US CPI (Summer)".into(),
country: "USD".into(),
date: "2026-07-15T08:30:00-04:00".into(),
time: "".into(),
impact: "High".into(),
};
let parsed_summer = parse_event_datetime(&summer).unwrap();
assert_eq!(parsed_summer.to_rfc3339(), "2026-07-15T12:30:00+00:00");
}
#[test]
fn test_calendar_client_custom_user_agent() {
let client = CalendarClient::with_user_agent(None, "custom-agent/1.0.0");
assert!(client.is_ok());
}
#[test]
fn test_dst_spring_forward_gap_shift() {
let tz = chrono_tz::America::New_York;
let event = RawCalendarEvent {
title: "Sunday Special Announcement".into(),
country: "USD".into(),
date: "03-08-2026".into(),
time: "2:30am".into(),
impact: "High".into(),
};
let timing =
parse_event_timing(&event, Some(tz)).expect("should resolve DST gap by shifting");
let dt = timing.exact_time().expect("should be exact time");
assert_eq!(
dt.format("%Y-%m-%d %H:%M UTC").to_string(),
"2026-03-08 07:30 UTC"
);
}
#[test]
fn test_dst_fall_back_ambiguous() {
let tz = chrono_tz::America::New_York;
let event = RawCalendarEvent {
title: "Fall-Back Release".into(),
country: "USD".into(),
date: "11-01-2026".into(),
time: "1:30am".into(),
impact: "High".into(),
};
let timing = parse_event_timing(&event, Some(tz)).expect("should resolve ambiguous time");
let dt = timing.exact_time().expect("should be exact time");
assert_eq!(
dt.format("%Y-%m-%d %H:%M UTC").to_string(),
"2026-11-01 05:30 UTC"
);
}
#[test]
fn test_date_only_event_without_time_is_tentative() {
let event = RawCalendarEvent {
title: "G7 Summit".into(),
country: "ALL".into(),
date: "2026-08-15".into(),
time: "".into(),
impact: "High".into(),
};
let timing = parse_event_timing(&event, None).expect("should parse date-only event");
assert!(
timing.is_tentative(),
"date-only event without time must be tentative, not exact midnight"
);
assert!(
!timing.is_exact(),
"date-only event must not fabricate an exact midnight timestamp"
);
assert_eq!(
timing,
EventTiming::TentativeDate(chrono::NaiveDate::from_ymd_opt(2026, 8, 15).unwrap())
);
}
#[test]
fn test_date_with_embedded_time_and_empty_time_field() {
let event = RawCalendarEvent {
title: "Embedded Datetime".into(),
country: "USD".into(),
date: "2026-08-15 14:30".into(),
time: "".into(),
impact: "High".into(),
};
let timing = parse_event_timing(&event, None).expect("should parse embedded datetime");
assert!(
timing.is_exact(),
"date containing explicit time must be parsed as exact"
);
let dt = timing.exact_time().unwrap();
assert_eq!(
dt.format("%Y-%m-%d %H:%M UTC").to_string(),
"2026-08-15 14:30 UTC"
);
}
#[test]
fn test_default_cache_dir() {
let dir = CalendarClient::default_cache_dir();
assert!(!dir.as_os_str().is_empty());
assert!(dir.to_string_lossy().contains("redfolder"));
}
#[test]
fn test_cache_sha256_checksum_and_tampering() {
let dir = tempfile::tempdir().unwrap();
let client = CalendarClient::new(Some(dir.path().to_path_buf()));
let events = vec![RawCalendarEvent {
title: "US CPI Release".into(),
country: "USD".into(),
date: "2026-06-05".into(),
time: "12:30".into(),
impact: "High".into(),
}];
client.save_cache(&events).expect("cache save must succeed");
let loaded = client.load_cache_data().expect("cache should load cleanly");
assert_eq!(loaded.events.len(), 1);
assert!(loaded.metadata.sha256.is_some());
let cache_file = dir.path().join(DEFAULT_CACHE_FILENAME);
let content = std::fs::read_to_string(&cache_file).unwrap();
let tampered = content.replace("US CPI Release", "Tampered Event");
std::fs::write(&cache_file, tampered).unwrap();
let result = client.load_cache_data();
assert!(
result.is_none(),
"load_cache_data must reject cache with modified content due to sha256 checksum mismatch"
);
}
#[cfg(unix)]
#[test]
fn test_cache_file_permissions_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let client = CalendarClient::new(Some(dir.path().to_path_buf()));
let events = vec![RawCalendarEvent {
title: "Permission Test Event".into(),
country: "USD".into(),
date: "2026-06-05".into(),
time: "12:30".into(),
impact: "High".into(),
}];
client.save_cache(&events).unwrap();
let cache_file = dir.path().join(DEFAULT_CACHE_FILENAME);
let metadata = std::fs::metadata(&cache_file).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"cache file must have restrictive 0600 permissions on Unix"
);
}
#[test]
fn test_client_builder_options() {
let client = CalendarClient::without_cache()
.with_fallback_url("https://fallback1.internal.org/calendar.json")
.with_fallback_urls(vec!["https://fallback2.internal.org/calendar.json"])
.with_max_response_bytes(5 * 1024 * 1024)
.with_max_event_count(10_000)
.with_max_retries(4)
.with_backoff(Duration::from_millis(200), Duration::from_secs(5))
.with_overall_timeout(Duration::from_secs(15));
assert_eq!(client.fallback_urls().len(), 2);
assert_eq!(
client.fallback_urls()[0],
"https://fallback1.internal.org/calendar.json"
);
assert_eq!(client.max_response_bytes(), 5 * 1024 * 1024);
assert_eq!(client.max_event_count(), 10_000);
assert_eq!(client.max_retries(), 4);
assert_eq!(client.backoff_initial_delay(), Duration::from_millis(200));
assert_eq!(client.max_retry_after(), Duration::from_secs(5));
assert_eq!(client.overall_timeout(), Some(Duration::from_secs(15)));
}
}