use std::{ fs, io, ops::Sub as _, path::{Path, PathBuf}, }; use thiserror::Error; use time::format_description::well_known::Iso8601; use tracing::{Level, event}; const CACHE_AGE: time::Duration = time::Duration::hours(1); const CACHE_DELIMITER: char = '|'; use super::{ Exec, cli::{self, CliCommand}, cmd, dirs, systemd, wire::{WireCommand, server}, }; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] Cmd(#[from] cmd::Error), #[error(transparent)] Cli(#[from] cli::ParseError), #[error(transparent)] Systemd(#[from] systemd::Error), #[error(transparent)] Http(#[from] ureq::Error), #[error(transparent)] Dirs(#[from] dirs::Error), #[error(transparent)] Io(#[from] io::Error), #[error("delimiter not found in cache file")] CacheDelimitedNotFound, #[error("invalid timestamp \"{input}\" in cache file: {error}")] CacheTimestampParse { input: String, error: time::error::Parse, }, #[error("cache timestamp ({cache_timestamp}) is from the future (now: {now})")] CacheTimestampOverflow { now: time::UtcDateTime, cache_timestamp: time::UtcDateTime, }, #[error("formatting cache timestamp failed: {error}")] CacheTimestampFormat { error: time::error::Format }, } #[derive(Debug, Clone, Copy)] pub enum Action { Get, } impl WireCommand for Action { fn parse_wire(mut input: impl Iterator) -> Result { match input.next().ok_or(server::ParseError::Eof)? { 0x01 => Ok(Self::Get), byte => Err(server::ParseError::Unknown(byte)), } } fn to_wire(&self) -> Vec { match *self { Self::Get => vec![0x01], } } } impl Exec for Action { type ExecErr = Error; fn execute(&self) -> Result, Self::ExecErr> { match *self { Self::Get => Ok(Some(get()?)), } } } impl CliCommand for Action { type ExecErr = Error; fn parse_str(input: &str, rest: impl Iterator) -> Result where Self: Sized, { let result = match input { "get" => Self::Get, s => { return Err(cli::ParseError::UnknownAction { action: s.to_owned(), }); } }; let rest = rest.collect::>(); if rest.is_empty() { Ok(result) } else { Err(cli::ParseError::UnexpectedInput { rest }) } } } #[derive(Debug)] struct Cache { timestamp: time::UtcDateTime, value: String, } fn cache_file() -> Result { Ok(dirs::xdg_cache_dir()?.join("workstation-mgr.wttr.cache")) } fn store_cache(path: &Path, timestamp: &time::UtcDateTime, value: &str) -> Result<(), Error> { event!(Level::DEBUG, "storing in cache: {timestamp} {value}"); Ok(fs::write( path, format!( "{timestamp}{CACHE_DELIMITER}{value}", timestamp = timestamp .format(&Iso8601::DEFAULT) .map_err(|error| Error::CacheTimestampFormat { error })?, ), )?) } fn get_cache(path: &Path) -> Result, Error> { match fs::read_to_string(path) { Ok(content) => { let (timestamp, value) = content .split_once(CACHE_DELIMITER) .ok_or(Error::CacheDelimitedNotFound)?; let cache_timestamp = time::UtcDateTime::parse(timestamp, &Iso8601::DEFAULT).map_err(|error| { Error::CacheTimestampParse { input: timestamp.to_owned(), error, } })?; Ok(Some(Cache { timestamp: cache_timestamp, value: value.to_owned(), })) } Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } fn request() -> Result { Ok(ureq::get("https://wttr.in/Ansbach?m&T&format=%c%t") .call()? .body_mut() .read_to_string()?) } fn get_and_update_cache(cache_file: &Path, now: &time::UtcDateTime) -> Result { event!(Level::DEBUG, "refreshing cache"); let value = request()?; store_cache(cache_file, now, &value)?; Ok(value) } fn get() -> Result { let cache_file = cache_file()?; event!(Level::DEBUG, "using cache file {cache_file:?}"); let cache = get_cache(&cache_file)?; event!(Level::DEBUG, "read from cache: {cache:?}"); let now = time::UtcDateTime::now(); match cache { Some(cache) => { let cache_age = now.sub(cache.timestamp); event!(Level::DEBUG, "cache age: {cache_age}"); if cache_age.is_negative() { return Err(Error::CacheTimestampOverflow { now, cache_timestamp: cache.timestamp, }); } if cache_age <= CACHE_AGE { event!(Level::DEBUG, "reusing cache"); Ok(cache.value) } else { get_and_update_cache(&cache_file, &now) } } None => get_and_update_cache(&cache_file, &now), } }