1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub struct StintConfig {
12 pub idle_threshold_secs: i64,
14 pub default_rate_cents: Option<i64>,
16 pub auto_discover: bool,
18 pub default_tags: Vec<String>,
20}
21
22impl Default for StintConfig {
23 fn default() -> Self {
24 Self {
25 idle_threshold_secs: 300,
26 default_rate_cents: None,
27 auto_discover: true,
28 default_tags: vec![],
29 }
30 }
31}
32
33impl StintConfig {
34 pub fn default_path() -> PathBuf {
36 let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"));
37 config_dir.join("stint").join("config.toml")
38 }
39
40 pub fn load() -> Self {
42 let path = Self::default_path();
43 Self::load_from(&path).unwrap_or_default()
44 }
45
46 pub fn load_from(path: &Path) -> Option<Self> {
48 let contents = std::fs::read_to_string(path).ok()?;
49 Some(Self::parse(&contents))
50 }
51
52 fn parse(contents: &str) -> Self {
57 let mut config = Self::default();
58 let mut values: HashMap<String, String> = HashMap::new();
59
60 for line in contents.lines() {
61 let line = line.trim();
62 if line.is_empty() || line.starts_with('#') {
63 continue;
64 }
65 if let Some((key, value)) = line.split_once('=') {
66 let key = key.trim().to_string();
67 let value = value.trim().trim_matches('"').to_string();
68 values.insert(key, value);
69 }
70 }
71
72 if let Some(v) = values.get("idle_threshold") {
73 if let Ok(secs) = v.parse::<i64>() {
74 config.idle_threshold_secs = secs;
75 }
76 }
77
78 if let Some(v) = values.get("default_rate") {
79 if let Ok(cents) = v.parse::<i64>() {
80 config.default_rate_cents = Some(cents);
81 }
82 }
83
84 if let Some(v) = values.get("auto_discover") {
85 match v.trim().to_ascii_lowercase().as_str() {
86 "true" | "1" | "yes" | "on" => config.auto_discover = true,
87 "false" | "0" | "no" | "off" => config.auto_discover = false,
88 _ => {} }
90 }
91
92 if let Some(v) = values.get("default_tags") {
93 config.default_tags = v
94 .split(',')
95 .map(|t| t.trim().to_string())
96 .filter(|t| !t.is_empty())
97 .collect();
98 }
99
100 config
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn default_config() {
110 let config = StintConfig::default();
111 assert_eq!(config.idle_threshold_secs, 300);
112 assert!(config.auto_discover);
113 assert!(config.default_rate_cents.is_none());
114 }
115
116 #[test]
117 fn parse_all_fields() {
118 let input = r#"
119# Stint configuration
120idle_threshold = 600
121default_rate = 15000
122auto_discover = true
123default_tags = rust, cli
124"#;
125 let config = StintConfig::parse(input);
126 assert_eq!(config.idle_threshold_secs, 600);
127 assert_eq!(config.default_rate_cents, Some(15000));
128 assert!(config.auto_discover);
129 assert_eq!(config.default_tags, vec!["rust", "cli"]);
130 }
131
132 #[test]
133 fn parse_disables_auto_discover() {
134 let input = "auto_discover = false";
135 let config = StintConfig::parse(input);
136 assert!(!config.auto_discover);
137 }
138
139 #[test]
140 fn parse_ignores_unknown_keys() {
141 let input = "unknown_key = whatever\nidle_threshold = 120";
142 let config = StintConfig::parse(input);
143 assert_eq!(config.idle_threshold_secs, 120);
144 }
145
146 #[test]
147 fn parse_empty_string() {
148 let config = StintConfig::parse("");
149 assert_eq!(config.idle_threshold_secs, 300); }
151
152 #[test]
153 fn missing_file_returns_none() {
154 assert!(StintConfig::load_from(Path::new("/nonexistent/path")).is_none());
155 }
156}