use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::{KtoError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub default_notify: Option<NotifyTarget>,
#[serde(default)]
pub rate_limits: HashMap<String, f64>,
#[serde(default = "default_interval")]
pub default_interval_secs: u64,
#[serde(default)]
pub quiet_hours: Option<QuietHours>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuietHours {
pub start: String,
pub end: String,
#[serde(default)]
pub timezone: Option<String>,
}
impl QuietHours {
pub fn is_quiet_now(&self) -> bool {
use chrono::{Local, NaiveTime, Timelike};
let now = Local::now();
let current_time = NaiveTime::from_hms_opt(now.hour(), now.minute(), 0)
.unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
let start = NaiveTime::parse_from_str(&self.start, "%H:%M")
.unwrap_or_else(|_| NaiveTime::from_hms_opt(22, 0, 0).unwrap());
let end = NaiveTime::parse_from_str(&self.end, "%H:%M")
.unwrap_or_else(|_| NaiveTime::from_hms_opt(8, 0, 0).unwrap());
if start > end {
current_time >= start || current_time < end
} else {
current_time >= start && current_time < end
}
}
}
fn default_interval() -> u64 {
900 }
impl Default for Config {
fn default() -> Self {
Self {
default_notify: None,
rate_limits: HashMap::new(),
default_interval_secs: default_interval(),
quiet_hours: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotifyTarget {
Command { command: String },
Ntfy { topic: String, server: Option<String> },
Slack { webhook_url: String },
Discord { webhook_url: String },
Gotify { server: String, token: String },
Telegram { bot_token: String, chat_id: String },
Pushover { user_key: String, api_token: String },
Email {
smtp_server: String,
smtp_port: Option<u16>,
username: String,
password: String,
from: String,
to: String,
},
Matrix {
homeserver: String,
room_id: String,
access_token: String,
},
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
Ok(toml::from_str(&content)?)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| KtoError::ConfigError(e.to_string()))?;
std::fs::write(&config_path, content)?;
Ok(())
}
pub fn config_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "kto")
.ok_or_else(|| KtoError::ConfigError("Could not determine config directory".into()))?;
Ok(dirs.config_dir().join("config.toml"))
}
pub fn data_dir() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "kto")
.ok_or_else(|| KtoError::ConfigError("Could not determine data directory".into()))?;
Ok(dirs.data_dir().to_path_buf())
}
pub fn db_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("KTO_DB") {
return Ok(PathBuf::from(path));
}
Ok(Self::data_dir()?.join("kto.db"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.default_interval_secs, 900);
assert!(config.rate_limits.is_empty());
}
}