use serde::Deserialize;
use std::fmt;
use crate::error::InvalidOptionsError;
use super::models::UnitSystem;
#[derive(Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ClientOptions {
#[serde(default = "String::new")]
pub api_key: String,
#[serde(default = "ClientOptions::default_language")]
pub language: String,
#[serde(default = "ClientOptions::default_units")]
pub units: UnitSystem,
}
impl ClientOptions {
pub fn default_api_key() -> String {
std::env::var("API_KEY").unwrap_or_default()
}
pub fn default_language() -> String {
"en".to_string()
}
pub fn default_units() -> UnitSystem {
UnitSystem::Metric
}
pub fn masked_api_key(&self) -> String {
mask(&self.api_key)
}
pub fn validate(&self) -> Result<(), InvalidOptionsError> {
if self.api_key.is_empty() {
return Err(InvalidOptionsError {
message: "api_key must be non-blank".to_string(),
});
}
Ok(())
}
pub fn mask_api_key_if_present(&self, any_string: &str) -> String {
any_string.replace(&self.api_key, &self.masked_api_key())
}
}
impl Default for ClientOptions {
fn default() -> Self {
Self {
api_key: Self::default_api_key(),
language: Self::default_language(),
units: Self::default_units(),
}
}
}
fn mask(s: &str) -> String {
let mut masked: String = s.to_string();
if !s.is_empty() {
let range = (masked.len() - 1).clamp(0, 3)..;
masked.replace_range(range, "****");
}
masked
}
impl fmt::Debug for ClientOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Config {{ api_key: \"{}\", language: \"{}\", units: {} }}",
mask(&self.api_key),
self.language,
self.units
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_options_default() {
std::env::set_var("API_KEY", "some value");
let def = ClientOptions::default();
assert_eq!(def.api_key, "some value");
assert_eq!(def.language, "en");
assert_eq!(def.units, UnitSystem::Metric);
}
#[test]
fn serde_parse() {
let parsed: ClientOptions = serde_yaml::from_str(
"\
api_key: abc123
language: \"de\"
units: imperial
",
)
.unwrap();
assert_eq!(parsed.api_key, "abc123");
assert_eq!(parsed.units, UnitSystem::Imperial);
assert_eq!(parsed.language, "de");
}
#[test]
fn mask_only_shows_the_first_3_characters_always_followed_by_only_4_stars() {
assert_eq!(mask("ABCDEFGHIJKLMNOPQRSTUVWZYZ"), "ABC****");
assert_eq!(mask("ABCDEFGH"), "ABC****");
assert_eq!(mask("ABCD"), "ABC****");
assert_eq!(mask("ABC"), "AB****");
assert_eq!(mask("AB"), "A****");
assert_eq!(mask("A"), "****");
}
#[test]
fn mask_returns_empty_string_for_empty_input() {
assert_eq!(mask(""), "");
}
#[test]
fn client_options_debug_masks_api_key() {
let options = ClientOptions {
api_key: "PLAINTEXT_API_KEY".to_string(),
..ClientOptions::default()
};
assert_eq!(options.api_key, "PLAINTEXT_API_KEY");
let debug = format!("{options:?}");
assert!(!debug.contains("PLAINTEXT"));
assert!(debug.contains("PLA****"));
}
#[test]
fn client_options_masked_api_key_masks() {
let options = ClientOptions {
api_key: "PLAINTEXT_API_KEY".to_string(),
..ClientOptions::default()
};
assert_eq!(options.api_key, "PLAINTEXT_API_KEY");
assert_eq!(options.masked_api_key(), "PLA****");
}
#[test]
fn client_options_mask_api_key_if_present() {
let options = ClientOptions {
api_key: "the".to_string(),
..ClientOptions::default()
};
assert_eq!(
options.mask_api_key_if_present(
"I think the quote is, \"It was the best of times, it was the worst of times, ...\""
),
"I think th**** quote is, \"It was th**** best of times, it was th**** worst of times, ...\""
);
}
}