use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::text_safety::sanitize_for_terminal;
pub const DEFAULT_ELECTRICITY_MAPS_ENDPOINT: &str = "https://api.electricitymaps.com/v4";
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ApiVersion {
V3,
#[default]
V4,
Custom,
}
impl ApiVersion {
#[must_use]
pub fn from_endpoint(endpoint: &str) -> Self {
if endpoint.ends_with("/v3") || endpoint.contains("/v3/") {
Self::V3
} else if endpoint.ends_with("/v4") || endpoint.contains("/v4/") {
Self::V4
} else {
Self::Custom
}
}
#[must_use]
pub const fn as_chip_label(self) -> &'static str {
match self {
Self::V3 => "v3",
Self::V4 => "v4",
Self::Custom => "custom",
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EmissionFactorType {
#[default]
Lifecycle,
Direct,
}
impl EmissionFactorType {
#[must_use]
pub fn from_config(value: Option<&str>) -> Self {
match value {
None => Self::default(),
Some(s) if s.eq_ignore_ascii_case("lifecycle") => Self::Lifecycle,
Some(s) if s.eq_ignore_ascii_case("direct") => Self::Direct,
Some(other) => {
let safe = sanitize_for_terminal(other);
tracing::warn!(
value = %safe,
"unknown [green.electricity_maps] emission_factor_type, \
falling back to lifecycle. Accepted values: lifecycle, direct"
);
Self::default()
}
}
}
#[must_use]
pub const fn as_query_value(self) -> &'static str {
match self {
Self::Lifecycle => "lifecycle",
Self::Direct => "direct",
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum TemporalGranularity {
#[default]
#[serde(rename = "hourly")]
Hourly,
#[serde(rename = "5_minutes")]
FiveMinutes,
#[serde(rename = "15_minutes")]
FifteenMinutes,
}
impl TemporalGranularity {
#[must_use]
pub fn from_config(value: Option<&str>) -> Self {
match value {
None => Self::default(),
Some(s) if s.eq_ignore_ascii_case("hourly") => Self::Hourly,
Some(s) if s.eq_ignore_ascii_case("5_minutes") => Self::FiveMinutes,
Some(s) if s.eq_ignore_ascii_case("15_minutes") => Self::FifteenMinutes,
Some(other) => {
let safe = sanitize_for_terminal(other);
tracing::warn!(
value = %safe,
"unknown [green.electricity_maps] temporal_granularity, \
falling back to hourly. Accepted values: hourly, \
5_minutes, 15_minutes"
);
Self::default()
}
}
}
#[must_use]
pub const fn as_query_value(self) -> &'static str {
match self {
Self::Hourly => "hourly",
Self::FiveMinutes => "5_minutes",
Self::FifteenMinutes => "15_minutes",
}
}
}
#[derive(Clone)]
pub struct ElectricityMapsConfig {
pub api_endpoint: String,
pub auth_token: String,
pub poll_interval: Duration,
pub region_map: HashMap<String, String>,
pub emission_factor_type: EmissionFactorType,
pub temporal_granularity: TemporalGranularity,
}
impl std::fmt::Debug for ElectricityMapsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ElectricityMapsConfig")
.field("api_endpoint", &self.api_endpoint)
.field("auth_token", &"[REDACTED]")
.field("poll_interval", &self.poll_interval)
.field("region_map", &self.region_map)
.field("emission_factor_type", &self.emission_factor_type)
.field("temporal_granularity", &self.temporal_granularity)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> ElectricityMapsConfig {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
region_map.insert("us-east-1".to_string(), "US-MIDA-PJM".to_string());
ElectricityMapsConfig {
api_endpoint: DEFAULT_ELECTRICITY_MAPS_ENDPOINT.to_string(),
auth_token: "super-secret-token-do-not-log".to_string(),
poll_interval: Duration::from_mins(5),
region_map,
emission_factor_type: EmissionFactorType::default(),
temporal_granularity: TemporalGranularity::default(),
}
}
#[test]
fn debug_impl_redacts_auth_token() {
let cfg = sample_config();
crate::test_helpers::assert_debug_redacts_secret!(&cfg, "super-secret-token-do-not-log");
}
#[test]
fn debug_impl_preserves_non_secret_fields() {
let cfg = sample_config();
let debug_output = format!("{cfg:?}");
assert!(debug_output.contains("api_endpoint"));
assert!(debug_output.contains(DEFAULT_ELECTRICITY_MAPS_ENDPOINT));
assert!(debug_output.contains("poll_interval"));
assert!(debug_output.contains("region_map"));
assert!(debug_output.contains("eu-west-3"));
assert!(debug_output.contains("FR"));
}
#[test]
fn default_electricity_maps_endpoint_constant_targets_v4() {
assert_eq!(
DEFAULT_ELECTRICITY_MAPS_ENDPOINT,
"https://api.electricitymaps.com/v4"
);
}
#[test]
fn clone_preserves_all_fields() {
let cfg = sample_config();
let cloned = cfg.clone();
assert_eq!(cfg.api_endpoint, cloned.api_endpoint);
assert_eq!(cfg.auth_token, cloned.auth_token);
assert_eq!(cfg.poll_interval, cloned.poll_interval);
assert_eq!(cfg.region_map, cloned.region_map);
assert_eq!(cfg.emission_factor_type, cloned.emission_factor_type);
assert_eq!(cfg.temporal_granularity, cloned.temporal_granularity);
}
#[test]
fn emission_factor_type_from_config_accepts_known_values() {
assert_eq!(
EmissionFactorType::from_config(None),
EmissionFactorType::Lifecycle
);
assert_eq!(
EmissionFactorType::from_config(Some("lifecycle")),
EmissionFactorType::Lifecycle
);
assert_eq!(
EmissionFactorType::from_config(Some("LIFECYCLE")),
EmissionFactorType::Lifecycle
);
assert_eq!(
EmissionFactorType::from_config(Some("direct")),
EmissionFactorType::Direct
);
assert_eq!(
EmissionFactorType::from_config(Some("Direct")),
EmissionFactorType::Direct
);
}
#[test]
fn emission_factor_type_from_config_unknown_falls_back_to_lifecycle() {
assert_eq!(
EmissionFactorType::from_config(Some("nonsense")),
EmissionFactorType::Lifecycle
);
}
#[test]
fn emission_factor_type_query_values_match_api_spec() {
assert_eq!(EmissionFactorType::Lifecycle.as_query_value(), "lifecycle");
assert_eq!(EmissionFactorType::Direct.as_query_value(), "direct");
}
#[test]
fn temporal_granularity_from_config_accepts_known_values() {
assert_eq!(
TemporalGranularity::from_config(None),
TemporalGranularity::Hourly
);
assert_eq!(
TemporalGranularity::from_config(Some("hourly")),
TemporalGranularity::Hourly
);
assert_eq!(
TemporalGranularity::from_config(Some("HOURLY")),
TemporalGranularity::Hourly
);
assert_eq!(
TemporalGranularity::from_config(Some("5_minutes")),
TemporalGranularity::FiveMinutes
);
assert_eq!(
TemporalGranularity::from_config(Some("15_minutes")),
TemporalGranularity::FifteenMinutes
);
assert_eq!(
TemporalGranularity::from_config(Some("5_MINUTES")),
TemporalGranularity::FiveMinutes
);
assert_eq!(
TemporalGranularity::from_config(Some("15_Minutes")),
TemporalGranularity::FifteenMinutes
);
}
#[test]
fn temporal_granularity_from_config_unknown_falls_back_to_hourly() {
assert_eq!(
TemporalGranularity::from_config(Some("nonsense")),
TemporalGranularity::Hourly
);
assert_eq!(
TemporalGranularity::from_config(Some("daily")),
TemporalGranularity::Hourly
);
}
#[test]
fn temporal_granularity_query_values_match_api_spec() {
assert_eq!(TemporalGranularity::Hourly.as_query_value(), "hourly");
assert_eq!(
TemporalGranularity::FiveMinutes.as_query_value(),
"5_minutes"
);
assert_eq!(
TemporalGranularity::FifteenMinutes.as_query_value(),
"15_minutes"
);
}
#[test]
fn api_version_default_is_v4() {
assert_eq!(ApiVersion::default(), ApiVersion::V4);
}
#[test]
fn api_version_from_endpoint_matches_v3_at_path_end() {
assert_eq!(
ApiVersion::from_endpoint("https://api.electricitymaps.com/v3"),
ApiVersion::V3
);
}
#[test]
fn api_version_from_endpoint_matches_v3_in_path() {
assert_eq!(
ApiVersion::from_endpoint("https://corporate-proxy.acme.com/electricitymaps/v3/api"),
ApiVersion::V3
);
}
#[test]
fn api_version_from_endpoint_matches_v3_with_trailing_slash() {
assert_eq!(
ApiVersion::from_endpoint("https://api.electricitymaps.com/v3/"),
ApiVersion::V3
);
}
#[test]
fn api_version_from_endpoint_matches_v4() {
assert_eq!(
ApiVersion::from_endpoint("https://api.electricitymaps.com/v4"),
ApiVersion::V4
);
}
#[test]
fn api_version_from_endpoint_returns_custom_for_versionless_url() {
assert_eq!(
ApiVersion::from_endpoint("http://127.0.0.1:9999"),
ApiVersion::Custom
);
assert_eq!(
ApiVersion::from_endpoint("https://api.electricitymaps.com"),
ApiVersion::Custom
);
}
#[test]
fn api_version_from_endpoint_avoids_v30_and_v300_false_positives() {
assert_eq!(
ApiVersion::from_endpoint("https://api.electricitymaps.com/v30"),
ApiVersion::Custom
);
assert_eq!(
ApiVersion::from_endpoint("https://api.electricitymaps.com/v300/foo"),
ApiVersion::Custom
);
}
#[test]
fn api_version_chip_labels_are_stable() {
assert_eq!(ApiVersion::V3.as_chip_label(), "v3");
assert_eq!(ApiVersion::V4.as_chip_label(), "v4");
assert_eq!(ApiVersion::Custom.as_chip_label(), "custom");
}
#[test]
fn api_version_serde_round_trip() {
for variant in [ApiVersion::V3, ApiVersion::V4, ApiVersion::Custom] {
let s = serde_json::to_string(&variant).unwrap();
let back: ApiVersion = serde_json::from_str(&s).unwrap();
assert_eq!(variant, back);
}
assert_eq!(serde_json::to_string(&ApiVersion::V4).unwrap(), "\"v4\"");
assert_eq!(serde_json::to_string(&ApiVersion::V3).unwrap(), "\"v3\"");
assert_eq!(
serde_json::to_string(&ApiVersion::Custom).unwrap(),
"\"custom\""
);
}
#[test]
fn emission_factor_type_serde_lowercase() {
assert_eq!(
serde_json::to_string(&EmissionFactorType::Lifecycle).unwrap(),
"\"lifecycle\""
);
assert_eq!(
serde_json::to_string(&EmissionFactorType::Direct).unwrap(),
"\"direct\""
);
let back: EmissionFactorType = serde_json::from_str("\"direct\"").unwrap();
assert_eq!(back, EmissionFactorType::Direct);
}
#[test]
fn temporal_granularity_serde_renames_digit_starting_variants() {
assert_eq!(
serde_json::to_string(&TemporalGranularity::Hourly).unwrap(),
"\"hourly\""
);
assert_eq!(
serde_json::to_string(&TemporalGranularity::FiveMinutes).unwrap(),
"\"5_minutes\""
);
assert_eq!(
serde_json::to_string(&TemporalGranularity::FifteenMinutes).unwrap(),
"\"15_minutes\""
);
let back: TemporalGranularity = serde_json::from_str("\"5_minutes\"").unwrap();
assert_eq!(back, TemporalGranularity::FiveMinutes);
let back: TemporalGranularity = serde_json::from_str("\"15_minutes\"").unwrap();
assert_eq!(back, TemporalGranularity::FifteenMinutes);
}
}