|
use anyhow::{Context, Result}; |
|
use serde::{Deserialize, Serialize}; |
|
use std::env; |
|
use std::fs; |
|
use std::path::PathBuf; |
|
use std::time::{Duration, SystemTime, UNIX_EPOCH}; |
|
|
|
const CACHE_DURATION_HOURS: u64 = 12; |
|
const CACHE_FILENAME: &str = ".currency_cache.json"; |
|
|
|
#[derive(Deserialize, Serialize)] |
|
struct CacheEntry { |
|
base_currency: String, |
|
php_rate: f64, |
|
timestamp: u64, |
|
} |
|
|
|
#[derive(Deserialize)] |
|
struct ApiResponse { |
|
data: CurrencyData, |
|
} |
|
|
|
#[derive(Deserialize)] |
|
struct CurrencyData { |
|
#[serde(rename = "PHP")] |
|
php: CurrencyInfo, |
|
} |
|
|
|
#[derive(Deserialize)] |
|
struct CurrencyInfo { |
|
value: f64, |
|
} |
|
|
|
fn get_cache_dir() -> PathBuf { |
|
env::var("CURRENCY_CACHE_DIR") |
|
.map(PathBuf::from) |
|
.unwrap_or_else(|_| { |
|
env::var("HOME") |
|
.map(PathBuf::from) |
|
.unwrap_or_default() |
|
.join(".cache") |
|
}) |
|
} |
|
|
|
fn get_cache_path(base_currency: &str) -> PathBuf { |
|
get_cache_dir().join(format!("{}_{}", base_currency, CACHE_FILENAME)) |
|
} |
|
|
|
fn is_cache_valid(entry: &CacheEntry) -> bool { |
|
let now = SystemTime::now() |
|
.duration_since(UNIX_EPOCH) |
|
.unwrap_or_default() |
|
.as_secs(); |
|
|
|
let age = now.saturating_sub(entry.timestamp); |
|
age < Duration::from_secs(CACHE_DURATION_HOURS * 3600).as_secs() |
|
} |
|
|
|
async fn fetch_rate(base_currency: &str, api_key: &str) -> Result<f64> { |
|
let url = format!( |
|
"https://api.freecurrencyapi.com/v1/latest?apikey={}&base_currency={}¤cies=PHP", |
|
api_key, base_currency |
|
); |
|
|
|
let client = reqwest::Client::new(); |
|
let response = client |
|
.get(&url) |
|
.send() |
|
.await |
|
.with_context(|| "Failed to fetch exchange rate")?; |
|
|
|
let api_response: ApiResponse = response |
|
.json() |
|
.await |
|
.with_context(|| "Failed to parse API response")?; |
|
|
|
Ok(api_response.data.php.value) |
|
} |
|
|
|
fn load_cached_rate(base_currency: &str) -> Option<f64> { |
|
let cache_path = get_cache_path(base_currency); |
|
let contents = fs::read_to_string(&cache_path).ok()?; |
|
let entry: CacheEntry = serde_json::from_str(&contents).ok()?; |
|
|
|
if is_cache_valid(&entry) { |
|
println!("Using cached rate ({} hours max)", CACHE_DURATION_HOURS); |
|
Some(entry.php_rate) |
|
} else { |
|
None |
|
} |
|
} |
|
|
|
fn save_cached_rate(base_currency: &str, rate: f64) -> Result<()> { |
|
let cache_dir = get_cache_dir(); |
|
if !cache_dir.exists() { |
|
fs::create_dir_all(&cache_dir) |
|
.with_context(|| format!("Failed to create cache dir: {:?}", cache_dir))?; |
|
} |
|
|
|
let timestamp = SystemTime::now() |
|
.duration_since(UNIX_EPOCH) |
|
.unwrap_or_default() |
|
.as_secs(); |
|
|
|
let entry = CacheEntry { |
|
base_currency: base_currency.to_string(), |
|
php_rate: rate, |
|
timestamp, |
|
}; |
|
|
|
let cache_path = get_cache_path(base_currency); |
|
let contents = serde_json::to_string(&entry) |
|
.with_context(|| "Failed to serialize cache entry")?; |
|
|
|
fs::write(&cache_path, contents) |
|
.with_context(|| format!("Failed to write cache: {:?}", cache_path))?; |
|
|
|
Ok(()) |
|
} |
|
|
|
#[tokio::main] |
|
async fn main() -> Result<()> { |
|
let args: Vec<String> = env::args().collect(); |
|
|
|
if args.len() != 3 { |
|
eprintln!("Usage: {} <amount> <FROM_CURRENCY>", args[0]); |
|
eprintln!("Example: {} 100 USD", args[0]); |
|
eprintln!("Cache location: ~/.cache/<CURRENCY>_{}", CACHE_FILENAME); |
|
eprintln!("Set CURRENCY_CACHE_DIR to override cache directory"); |
|
std::process::exit(1); |
|
} |
|
|
|
let amount: f64 = args[1] |
|
.parse() |
|
.with_context(|| format!("Invalid amount: {}", args[1]))?; |
|
|
|
let from_currency = &args[2].to_uppercase(); |
|
|
|
// Try cache first |
|
let rate = match load_cached_rate(from_currency) { |
|
Some(rate) => rate, |
|
None => { |
|
println!("Fetching fresh rate from API..."); |
|
let api_key = env::var("FREE_CURRENCY_API_KEY") |
|
.with_context(|| "FREE_CURRENCY_API_KEY not set")?; |
|
|
|
let rate = fetch_rate(from_currency, &api_key).await?; |
|
|
|
// Cache the result (best effort - don't fail on cache errors) |
|
if let Err(e) = save_cached_rate(from_currency, rate) { |
|
eprintln!("Warning: Failed to cache rate: {}", e); |
|
} |
|
|
|
rate |
|
} |
|
}; |
|
|
|
let result = amount * rate; |
|
|
|
println!("{:.2} {} = {:.2} PHP", amount, from_currency, result); |
|
println!("Rate: 1 {} = {:.4} PHP", from_currency, rate); |
|
|
|
Ok(()) |
|
} |