use std::path::{Path, PathBuf};
use config_kit::{ConfigBuilder, ConfigLayer, Sensitive};
use serde::Deserialize;
use trendkit_core::error::{Result, TrendError};
#[derive(Debug, Deserialize)]
pub struct Settings {
#[serde(default = "default_db_path")]
pub db_path: PathBuf,
#[serde(default)]
pub github_token: Option<Sensitive<String>>,
#[serde(default = "default_owner")]
pub github_owner: String,
#[serde(default = "default_pages")]
pub github_pages: u32,
#[serde(default = "default_window_days")]
pub window_days: u32,
#[serde(default)]
pub telegram_chat_id: Option<String>,
#[serde(default = "default_token_file")]
pub telegram_token_file: PathBuf,
#[serde(default = "default_ai_base_url")]
pub ai_base_url: String,
#[serde(default = "default_ai_model")]
pub ai_model: String,
#[serde(default)]
pub ai_api_key: Option<Sensitive<String>>,
}
fn default_db_path() -> PathBuf {
PathBuf::from("trendkit.db")
}
fn default_owner() -> String {
"WyattAu".to_owned()
}
fn default_pages() -> u32 {
3
}
fn default_window_days() -> u32 {
14
}
fn default_token_file() -> PathBuf {
PathBuf::from("/etc/alertmanager/telegram-token")
}
fn default_ai_base_url() -> String {
"https://api.deepseek.com".to_owned()
}
fn default_ai_model() -> String {
"deepseek-chat".to_owned()
}
impl Default for Settings {
fn default() -> Self {
Settings {
db_path: default_db_path(),
github_token: None,
github_owner: default_owner(),
github_pages: default_pages(),
window_days: default_window_days(),
telegram_chat_id: None,
telegram_token_file: default_token_file(),
ai_base_url: default_ai_base_url(),
ai_model: default_ai_model(),
ai_api_key: None,
}
}
}
pub fn load(config: Option<&Path>) -> Result<Settings> {
let file_layer = match config {
Some(path) => {
if !path.exists() {
return Err(TrendError::Config(format!(
"config file not found: {}",
path.display()
)));
}
Some(ConfigLayer::file(path))
}
None => {
let default_path = Path::new("trendkit.toml");
if default_path.exists() {
Some(ConfigLayer::file(default_path))
} else {
None
}
}
};
let mut settings: Settings = match file_layer {
Some(file) => ConfigBuilder::new()
.layer(file)
.layer(ConfigLayer::env_prefix("TRENDKIT_"))
.load_strict(),
None => ConfigBuilder::new()
.layer(ConfigLayer::env_prefix("TRENDKIT_"))
.load_strict(),
}
.map_err(|e| TrendError::Config(e.to_string()))?;
if settings.github_token.is_none() {
settings.github_token = std::env::var("GH_TOKEN").ok().map(Sensitive::new);
}
if settings.ai_api_key.is_none() {
settings.ai_api_key = std::env::var("TRENDKIT_AI_API_KEY")
.ok()
.map(Sensitive::new);
}
Ok(settings)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn defaults_are_sane() {
let settings = Settings::default();
assert_eq!(settings.db_path, PathBuf::from("trendkit.db"));
assert_eq!(settings.github_owner, "WyattAu");
assert_eq!(settings.github_pages, 3);
assert_eq!(settings.window_days, 14);
assert!(settings.github_token.is_none());
}
#[test]
fn missing_explicit_config_is_an_error() {
let err = load(Some(Path::new("/nonexistent/trendkit.toml"))).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn sensitive_tokens_never_debug_print() {
let token = Sensitive::new("hunter2".to_owned());
assert_eq!(format!("{token:?}"), "[REDACTED]");
assert_eq!(token.expose(), "hunter2");
}
}