use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PriceSettings {
#[serde(default = "default_update_interval_seconds")]
pub update_interval_seconds: u64,
#[serde(default = "default_max_price_staleness_seconds")]
pub max_price_staleness_seconds: i64,
#[serde(default = "default_outlier_threshold_pct")]
pub outlier_threshold_pct: f64,
#[serde(default = "default_provider_timeout_seconds")]
pub provider_timeout_seconds: u64,
#[serde(default = "default_provider_failure_threshold")]
pub provider_failure_threshold: u32,
#[serde(default = "default_provider_failure_cooldown_seconds")]
pub provider_failure_cooldown_seconds: u64,
#[serde(default = "default_publish_to_nostr")]
pub publish_to_nostr: bool,
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
#[serde(default)]
pub enabled: bool,
pub url: String,
#[serde(default)]
pub fallback_urls: Vec<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub only: Option<Vec<String>>,
#[serde(default)]
pub except: Option<Vec<String>>,
}
impl ProviderConfig {
pub fn validate(&self, id: &str) -> Result<(), String> {
if self.only.is_some() && self.except.is_some() {
return Err(format!(
"price provider '{id}': `only` and `except` are mutually exclusive \
(see docs/PRICE_PROVIDERS.md §7)"
));
}
if self.enabled && self.url.trim().is_empty() {
return Err(format!(
"price provider '{id}': enabled provider must have a non-empty `url` \
(see docs/PRICE_PROVIDERS.md §7)"
));
}
Ok(())
}
pub fn allows_currency(&self, currency: &str) -> bool {
let c = currency.to_uppercase();
if let Some(only) = &self.only {
return only.iter().any(|x| x.to_uppercase() == c);
}
if let Some(except) = &self.except {
return !except.iter().any(|x| x.to_uppercase() == c);
}
true
}
}
impl PriceSettings {
pub fn validate(&self) -> Result<(), String> {
if self.update_interval_seconds == 0 {
return Err(format!(
"price: update_interval_seconds must be > 0, got {}",
self.update_interval_seconds
));
}
if self.max_price_staleness_seconds <= 0 {
return Err(format!(
"price: max_price_staleness_seconds must be > 0, got {}",
self.max_price_staleness_seconds
));
}
if !(self.outlier_threshold_pct.is_finite()
&& self.outlier_threshold_pct > 0.0
&& self.outlier_threshold_pct <= 100.0)
{
return Err(format!(
"price: outlier_threshold_pct must be in (0, 100], got {}",
self.outlier_threshold_pct
));
}
for (id, p) in &self.providers {
p.validate(id)?;
}
Ok(())
}
}
fn default_update_interval_seconds() -> u64 {
300
}
fn default_max_price_staleness_seconds() -> i64 {
1800
}
fn default_outlier_threshold_pct() -> f64 {
5.0
}
fn default_provider_timeout_seconds() -> u64 {
10
}
fn default_provider_failure_threshold() -> u32 {
3
}
fn default_provider_failure_cooldown_seconds() -> u64 {
120
}
fn default_publish_to_nostr() -> bool {
true
}
impl Default for PriceSettings {
fn default() -> Self {
Self {
update_interval_seconds: default_update_interval_seconds(),
max_price_staleness_seconds: default_max_price_staleness_seconds(),
outlier_threshold_pct: default_outlier_threshold_pct(),
provider_timeout_seconds: default_provider_timeout_seconds(),
provider_failure_threshold: default_provider_failure_threshold(),
provider_failure_cooldown_seconds: default_provider_failure_cooldown_seconds(),
publish_to_nostr: default_publish_to_nostr(),
providers: HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_spec() {
let cfg = PriceSettings::default();
assert_eq!(cfg.update_interval_seconds, 300);
assert_eq!(cfg.max_price_staleness_seconds, 1800);
assert_eq!(cfg.outlier_threshold_pct, 5.0);
assert_eq!(cfg.provider_timeout_seconds, 10);
assert_eq!(cfg.provider_failure_threshold, 3);
assert_eq!(cfg.provider_failure_cooldown_seconds, 120);
assert!(cfg.publish_to_nostr);
assert!(cfg.providers.is_empty());
cfg.validate().unwrap();
}
#[test]
fn toml_parses_providers_and_applies_defaults() {
#[derive(Deserialize)]
struct Stub {
price: PriceSettings,
}
let toml_str = r#"
[price]
update_interval_seconds = 60
[price.providers.yadio]
enabled = true
url = "https://api.yadio.io"
[price.providers.currency_api]
enabled = true
url = "https://currency-api.pages.dev/v1"
fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"]
except = ["CUP", "MLC"]
[price.providers.eltoque]
enabled = false
url = "https://tasas.eltoque.com"
token = "secret"
only = ["CUP", "MLC"]
"#;
let parsed: Stub = toml::from_str(toml_str).unwrap();
let p = parsed.price;
assert_eq!(p.update_interval_seconds, 60);
assert_eq!(p.max_price_staleness_seconds, 1800);
assert_eq!(p.providers.len(), 3);
let ca = &p.providers["currency_api"];
assert!(ca.enabled);
assert_eq!(ca.fallback_urls.len(), 1);
assert_eq!(ca.except.as_ref().unwrap(), &["CUP", "MLC"]);
assert!(!ca.allows_currency("cup"));
assert!(ca.allows_currency("USD"));
let et = &p.providers["eltoque"];
assert_eq!(et.token.as_deref(), Some("secret"));
assert!(et.allows_currency("CUP"));
assert!(!et.allows_currency("USD"));
p.validate().unwrap();
}
#[test]
fn only_and_except_together_is_rejected() {
let cfg = ProviderConfig {
enabled: true,
url: "http://x".into(),
fallback_urls: vec![],
api_key: None,
token: None,
only: Some(vec!["CUP".into()]),
except: Some(vec!["MLC".into()]),
};
assert!(cfg.validate("eltoque").is_err());
}
#[test]
fn out_of_range_outlier_threshold_is_rejected() {
let with_pct = |pct: f64| PriceSettings {
outlier_threshold_pct: pct,
..Default::default()
};
assert!(with_pct(0.0).validate().is_err());
assert!(with_pct(150.0).validate().is_err());
with_pct(5.0).validate().unwrap();
}
#[test]
fn zero_update_interval_is_rejected() {
let cfg = PriceSettings {
update_interval_seconds: 0,
..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn non_positive_staleness_is_rejected() {
let with_ttl = |ttl: i64| PriceSettings {
max_price_staleness_seconds: ttl,
..Default::default()
};
assert!(with_ttl(-1).validate().is_err());
assert!(with_ttl(0).validate().is_err());
with_ttl(1800).validate().unwrap();
}
#[test]
fn enabled_provider_with_blank_url_is_rejected() {
let blank = ProviderConfig {
enabled: true,
url: " ".into(),
fallback_urls: vec![],
api_key: None,
token: None,
only: None,
except: None,
};
assert!(blank.validate("yadio").is_err());
let disabled = ProviderConfig {
enabled: false,
..blank.clone()
};
disabled.validate("yadio").unwrap();
}
#[test]
fn no_scoping_allows_everything() {
let cfg = ProviderConfig {
enabled: true,
url: "http://x".into(),
fallback_urls: vec![],
api_key: None,
token: None,
only: None,
except: None,
};
assert!(cfg.allows_currency("USD"));
assert!(cfg.allows_currency("CUP"));
}
}