Skip to main content

stint_core/
config.rs

1//! Configuration file support for Stint.
2//!
3//! Reads a TOML-like config from `~/.config/stint/config.toml` (XDG-compliant).
4//! Falls back to sensible defaults when the file doesn't exist.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9/// Stint configuration with sensible defaults.
10#[derive(Debug, Clone)]
11pub struct StintConfig {
12    /// Idle threshold in seconds before auto-pause (default: 300 = 5 minutes).
13    pub idle_threshold_secs: i64,
14    /// Default hourly rate in cents for new projects (default: None).
15    pub default_rate_cents: Option<i64>,
16    /// Whether .git auto-discovery is enabled (default: true).
17    pub auto_discover: bool,
18    /// Default tags applied to auto-discovered projects.
19    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    /// Returns the XDG-compliant config file path.
35    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    /// Loads config from the default path, falling back to defaults.
41    pub fn load() -> Self {
42        let path = Self::default_path();
43        Self::load_from(&path).unwrap_or_default()
44    }
45
46    /// Loads config from a specific path.
47    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    /// Parses a simple TOML-like config string.
53    ///
54    /// Supports `key = value` lines. Comments start with `#`.
55    /// Unrecognized keys are ignored.
56    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                _ => {} // Unknown value — keep default
89            }
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); // default
150    }
151
152    #[test]
153    fn missing_file_returns_none() {
154        assert!(StintConfig::load_from(Path::new("/nonexistent/path")).is_none());
155    }
156}