use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ConfigSortOrder {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ConfigColorMode {
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ConfigCostMode {
Show,
Hide,
}
#[derive(Debug, Default, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct Config {
#[serde(default)]
pub(crate) offline: bool,
#[serde(default)]
pub(crate) compact: bool,
#[serde(default)]
pub(crate) no_cost: bool,
#[serde(default)]
pub(crate) no_color: bool,
#[serde(default)]
pub(crate) breakdown: bool,
#[serde(default)]
pub(crate) debug: bool,
#[serde(default)]
pub(crate) strict_pricing: bool,
#[serde(default)]
pub(crate) order: Option<ConfigSortOrder>,
#[serde(default)]
pub(crate) color: Option<ConfigColorMode>,
#[serde(default)]
pub(crate) cost: Option<ConfigCostMode>,
#[serde(default)]
pub(crate) timezone: Option<String>,
#[serde(default)]
pub(crate) locale: Option<String>,
#[serde(default)]
pub(crate) currency: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
impl Config {
pub(crate) fn load() -> Self {
Self::load_internal(false)
}
pub(crate) fn load_quiet() -> Self {
Self::load_internal(true)
}
fn load_internal(quiet: bool) -> Self {
Self::load_from_paths(&Self::get_config_paths(), quiet)
}
fn load_from_paths(paths: &[PathBuf], quiet: bool) -> Self {
for path in paths {
if path.exists()
&& let Ok(content) = fs::read_to_string(path)
{
match toml::from_str::<Config>(&content) {
Ok(config) => {
if !quiet {
eprintln!("Loaded config from {}", path.display());
}
return config;
}
Err(e) => {
if !quiet {
eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
}
}
}
}
}
Self::default()
}
fn get_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".config").join("ccstats").join("config.toml"));
}
if let Some(config_dir) = dirs::config_dir() {
let macos_path = config_dir.join("ccstats").join("config.toml");
if !paths.contains(&macos_path) {
paths.push(macos_path);
}
}
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".ccstats.toml"));
}
paths
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_config_paths_non_empty() {
let paths = Config::get_config_paths();
assert!(!paths.is_empty());
}
#[test]
fn test_config_paths_contain_expected_filenames() {
let paths = Config::get_config_paths();
let has_xdg = paths
.iter()
.any(|p| p.to_string_lossy().contains(".config/ccstats/config.toml"));
let has_dotfile = paths
.iter()
.any(|p| p.to_string_lossy().ends_with(".ccstats.toml"));
assert!(has_xdg);
assert!(has_dotfile);
}
#[test]
fn test_deserialize_empty_toml() {
let config: Config = toml::from_str("").unwrap();
assert!(!config.offline);
assert!(!config.compact);
assert!(!config.no_cost);
assert!(!config.no_color);
assert!(!config.breakdown);
assert!(!config.debug);
assert!(!config.strict_pricing);
assert!(config.order.is_none());
assert!(config.color.is_none());
assert!(config.cost.is_none());
assert!(config.timezone.is_none());
assert!(config.locale.is_none());
assert!(config.source.is_none());
}
#[test]
fn test_deserialize_all_booleans_true() {
let toml_str = r"
offline = true
compact = true
no_cost = true
no_color = true
breakdown = true
debug = true
strict_pricing = true
";
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.offline);
assert!(config.compact);
assert!(config.no_cost);
assert!(config.no_color);
assert!(config.breakdown);
assert!(config.debug);
assert!(config.strict_pricing);
}
#[test]
fn test_deserialize_partial_booleans() {
let config: Config = toml::from_str("compact = true\ndebug = true").unwrap();
assert!(config.compact);
assert!(config.debug);
assert!(!config.offline);
assert!(!config.breakdown);
}
#[test]
fn test_deserialize_sort_order_asc() {
let config: Config = toml::from_str("order = \"asc\"").unwrap();
assert!(matches!(config.order, Some(ConfigSortOrder::Asc)));
}
#[test]
fn test_deserialize_sort_order_desc() {
let config: Config = toml::from_str("order = \"desc\"").unwrap();
assert!(matches!(config.order, Some(ConfigSortOrder::Desc)));
}
#[test]
fn test_deserialize_color_modes() {
for (input, expected) in [("auto", "auto"), ("always", "always"), ("never", "never")] {
let config: Config = toml::from_str(&format!("color = \"{input}\"")).unwrap();
match config.color {
Some(ConfigColorMode::Auto) => assert_eq!(expected, "auto"),
Some(ConfigColorMode::Always) => assert_eq!(expected, "always"),
Some(ConfigColorMode::Never) => assert_eq!(expected, "never"),
None => panic!("expected Some"),
}
}
}
#[test]
fn test_deserialize_cost_modes() {
let show: Config = toml::from_str("cost = \"show\"").unwrap();
assert!(matches!(show.cost, Some(ConfigCostMode::Show)));
let hide: Config = toml::from_str("cost = \"hide\"").unwrap();
assert!(matches!(hide.cost, Some(ConfigCostMode::Hide)));
}
#[test]
fn test_deserialize_string_fields() {
let config: Config =
toml::from_str("timezone = \"Asia/Tokyo\"\nlocale = \"ja-JP\"\nsource = \"codex\"")
.unwrap();
assert_eq!(config.timezone.as_deref(), Some("Asia/Tokyo"));
assert_eq!(config.locale.as_deref(), Some("ja-JP"));
assert_eq!(config.source.as_deref(), Some("codex"));
}
#[test]
fn test_deserialize_full_config() {
let toml_str = r#"
offline = true
compact = false
no_cost = true
no_color = false
breakdown = true
debug = false
strict_pricing = true
order = "desc"
color = "never"
cost = "hide"
timezone = "US/Eastern"
locale = "en-US"
source = "codex"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.offline);
assert!(!config.compact);
assert!(config.no_cost);
assert!(!config.no_color);
assert!(config.breakdown);
assert!(!config.debug);
assert!(config.strict_pricing);
assert!(matches!(config.order, Some(ConfigSortOrder::Desc)));
assert!(matches!(config.color, Some(ConfigColorMode::Never)));
assert!(matches!(config.cost, Some(ConfigCostMode::Hide)));
assert_eq!(config.timezone.as_deref(), Some("US/Eastern"));
assert_eq!(config.locale.as_deref(), Some("en-US"));
assert_eq!(config.source.as_deref(), Some("codex"));
}
#[test]
fn test_deserialize_invalid_enum_value() {
let result = toml::from_str::<Config>("order = \"random\"");
assert!(result.is_err());
}
#[test]
fn test_deserialize_wrong_type() {
let result = toml::from_str::<Config>("offline = \"yes\"");
assert!(result.is_err());
}
#[test]
fn test_deserialize_enum_case_sensitive() {
let result = toml::from_str::<Config>("order = \"Asc\"");
assert!(result.is_err());
}
#[test]
fn test_deserialize_unknown_fields_ignored() {
let result = toml::from_str::<Config>("unknown_field = true");
assert!(result.is_ok());
}
fn write_temp_config(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn test_load_from_valid_file() {
let f = write_temp_config("compact = true\noffline = true");
let config = Config::load_from_paths(&[f.path().to_path_buf()], true);
assert!(config.compact);
assert!(config.offline);
}
#[test]
fn test_load_from_nonexistent_path_returns_default() {
let config =
Config::load_from_paths(&[PathBuf::from("/nonexistent/path/config.toml")], true);
assert!(!config.offline);
assert!(!config.compact);
}
#[test]
fn test_load_from_empty_paths_returns_default() {
let config = Config::load_from_paths(&[], true);
assert!(!config.offline);
assert!(config.order.is_none());
}
#[test]
fn test_load_priority_first_valid_wins() {
let f1 = write_temp_config("compact = true");
let f2 = write_temp_config("compact = false\noffline = true");
let config =
Config::load_from_paths(&[f1.path().to_path_buf(), f2.path().to_path_buf()], true);
assert!(config.compact);
assert!(!config.offline);
}
#[test]
fn test_load_skips_invalid_toml_tries_next() {
let bad = write_temp_config("this is not valid toml [[[");
let good = write_temp_config("debug = true");
let config =
Config::load_from_paths(&[bad.path().to_path_buf(), good.path().to_path_buf()], true);
assert!(config.debug);
}
#[test]
fn test_load_skips_nonexistent_tries_next() {
let good = write_temp_config("breakdown = true");
let config = Config::load_from_paths(
&[
PathBuf::from("/no/such/file.toml"),
good.path().to_path_buf(),
],
true,
);
assert!(config.breakdown);
}
#[test]
fn test_load_all_invalid_returns_default() {
let bad1 = write_temp_config("not valid [[[");
let bad2 = write_temp_config("also bad {{{");
let config = Config::load_from_paths(
&[bad1.path().to_path_buf(), bad2.path().to_path_buf()],
true,
);
assert!(!config.offline);
assert!(!config.compact);
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(!config.offline);
assert!(!config.compact);
assert!(!config.no_cost);
assert!(!config.no_color);
assert!(!config.breakdown);
assert!(!config.debug);
assert!(!config.strict_pricing);
assert!(config.order.is_none());
assert!(config.color.is_none());
assert!(config.cost.is_none());
assert!(config.timezone.is_none());
assert!(config.locale.is_none());
assert!(config.source.is_none());
}
}