Skip to main content

claude_code_statusline_core/
config.rs

1//! Configuration loading and management module
2//!
3//! This module handles loading configuration from TOML files and provides
4//! the implementation for the Config type defined in types::config.
5//!
6//! # Configuration File Location
7//!
8//! The configuration file is expected in the user's configuration directory
9//! (e.g., `~/.config/claude-code-statusline.toml` on Unix). If the file
10//! doesn't exist, default configuration values are used.
11//!
12//! # Example Configuration
13//!
14//! ```toml
15//! format = "$directory $git_branch $claude_model"
16//! command_timeout = 300
17//! debug = true
18//!
19//! [directory]
20//! style = "bold blue"
21//! truncation_length = 5
22//!
23//! [claude_model]
24//! symbol = "<"
25//! style = "bold yellow"
26//! ```
27
28use crate::error::CoreError;
29pub use crate::types::config::Config;
30use std::fs;
31use std::path::PathBuf;
32
33impl Config {
34    /// Loads configuration from the default location
35    ///
36    /// Attempts to read and parse the configuration file from the user's
37    /// configuration directory (e.g., `~/.config/claude-code-statusline.toml`).
38    /// If the file doesn't exist or
39    /// cannot be read, returns the default configuration.
40    ///
41    /// # Returns
42    ///
43    /// * `Ok(Config)` - Successfully loaded configuration or defaults
44    /// * `Err` - Failed to read or parse the configuration file
45    ///
46    /// # Examples
47    ///
48    /// ```no_run
49    /// use claude_code_statusline_core::Config;
50    ///
51    /// let config = Config::load().expect("Failed to load config");
52    /// println!("Format: {}", config.format);
53    /// ```
54    pub fn load() -> Result<Self, CoreError> {
55        // Candidate 1: XDG-style (~/.config/claude-code-statusline.toml)
56        let xdg_candidate =
57            dirs::home_dir().map(|h| h.join(".config").join("claude-code-statusline.toml"));
58
59        if let Some(ref xdg) = xdg_candidate {
60            if xdg.exists() {
61                let contents = fs::read_to_string(xdg).map_err(|e| CoreError::ConfigRead {
62                    path: xdg.display().to_string(),
63                    source: e,
64                })?;
65                let cfg: Config =
66                    toml::from_str(&contents).map_err(|e| CoreError::ConfigParse {
67                        path: xdg.display().to_string(),
68                        source: e,
69                    })?;
70                return Ok(cfg);
71            }
72        }
73
74        // Candidate 2: Platform config dir (e.g., macOS ~/Library/Application Support)
75        let primary = get_config_path();
76        if primary.exists() {
77            let contents = fs::read_to_string(&primary).map_err(|e| CoreError::ConfigRead {
78                path: primary.display().to_string(),
79                source: e,
80            })?;
81            let cfg: Config = toml::from_str(&contents).map_err(|e| CoreError::ConfigParse {
82                path: primary.display().to_string(),
83                source: e,
84            })?;
85            return Ok(cfg);
86        }
87
88        // Default when no config file is present
89        Ok(Config::default())
90    }
91}
92
93/// Determines the path to the configuration file
94///
95/// Constructs the path to `claude-code-statusline.toml` within the user's
96/// configuration directory. Uses `dirs::config_dir()` for cross-platform
97/// compatibility and falls back to the literal
98/// `~/.config/claude-code-statusline.toml` if no config directory can be
99/// determined.
100///
101/// # Returns
102///
103/// A `PathBuf` pointing to the expected configuration file location
104fn get_config_path() -> PathBuf {
105    // Prefer platform config dir for display/tooling
106    if let Some(base) = dirs::config_dir() {
107        return base.join("claude-code-statusline.toml");
108    }
109    // 1) Prefer XDG-style path: ~/.config/claude-code-statusline.toml (Linux-like)
110    if let Some(home) = dirs::home_dir() {
111        let xdg_path = home.join(".config").join("claude-code-statusline.toml");
112        if xdg_path.exists() {
113            return xdg_path;
114        }
115    }
116
117    // 2) Fallback to platform config dir
118    // (e.g., macOS: ~/Library/Application Support, Windows: %APPDATA%\claude-code-statusline)
119    if let Some(base) = dirs::config_dir() {
120        return base.join("claude-code-statusline.toml");
121    }
122
123    // 3) Last resort: literal XDG-style path (no expansion, best-effort)
124    PathBuf::from("~/.config/claude-code-statusline.toml")
125}
126
127/// Public accessor for the resolved configuration file path
128///
129/// Exposes a stable path resolution for consumers (e.g., CLI) so that all
130/// components agree on the location of the configuration file.
131pub fn config_path() -> PathBuf {
132    get_config_path()
133}
134
135/// Lightweight provider to access module-specific configuration tables
136/// including extra/unknown sections preserved during TOML deserialization.
137pub struct ConfigProvider<'a> {
138    config: &'a Config,
139}
140
141impl<'a> ConfigProvider<'a> {
142    pub fn new(config: &'a Config) -> Self {
143        Self { config }
144    }
145
146    /// Returns a raw TOML table for the given module name if present
147    pub fn module_table(&self, module: &str) -> Option<&toml::value::Table> {
148        // Known modules are represented as typed structs and not exposed here.
149        // This function focuses on extra/unknown sections to enable pluggable modules.
150        self.config.extra_module_table(module)
151    }
152
153    /// List available extra module section names
154    pub fn list_extra_modules(&self) -> Vec<String> {
155        self.config.extra_modules.keys().cloned().collect()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::types::config::Config as Cfg;
163
164    use std::sync::{Mutex, OnceLock};
165
166    fn env_lock() -> &'static Mutex<()> {
167        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
168        LOCK.get_or_init(|| Mutex::new(()))
169    }
170
171    #[test]
172    fn test_default_config() {
173        let config = Config::default();
174
175        // Test top-level defaults
176        assert_eq!(config.format, "$directory $claude_model");
177        assert_eq!(config.command_timeout, 500);
178        assert!(!config.debug);
179
180        // Test directory module defaults
181        assert_eq!(config.directory.format, "[$path]($style)");
182        assert_eq!(config.directory.style, "bold cyan");
183        assert_eq!(config.directory.truncation_length, 3);
184        assert!(config.directory.truncate_to_repo);
185        assert!(!config.directory.disabled);
186
187        // Test claude_model module defaults
188        assert_eq!(config.claude_model.format, "[$symbol$model]($style)");
189        assert_eq!(config.claude_model.style, "bold yellow");
190        assert_eq!(config.claude_model.symbol, "");
191        assert!(!config.claude_model.disabled);
192    }
193
194    #[test]
195    fn test_load_missing_config_returns_default() {
196        // Serialize env mutation to avoid races across tests
197        let _guard = env_lock().lock().unwrap();
198        let tmp = tempfile::tempdir().unwrap();
199        // Capture the original HOME (if any) so we can restore it later
200        let orig_home = std::env::var_os("HOME");
201        // Set HOME to the temp dir for the duration of this test
202        unsafe {
203            std::env::set_var("HOME", tmp.path());
204        }
205        let config = Config::load().unwrap();
206        // Fully restore HOME: reset to original value, or remove if it was unset
207        match orig_home {
208            Some(h) => unsafe { std::env::set_var("HOME", h) },
209            None => unsafe { std::env::remove_var("HOME") },
210        }
211        assert_eq!(config.format, "$directory $claude_model");
212        assert_eq!(config.command_timeout, 500);
213    }
214
215    #[test]
216    fn test_parse_valid_toml_config() {
217        let toml_str = r#"
218            format = "$directory $claude_model"
219            command_timeout = 300
220            debug = true
221
222            [directory]
223            format = "in [$path]($style)"
224            style = "bold blue"
225            truncation_length = 5
226
227            [claude_model]
228            symbol = "<"
229            style = "bold yellow"
230        "#;
231
232        let config: Config = toml::from_str(toml_str).unwrap();
233
234        assert_eq!(config.format, "$directory $claude_model");
235        assert_eq!(config.command_timeout, 300);
236        assert!(config.debug);
237        assert_eq!(config.directory.format, "in [$path]($style)");
238        assert_eq!(config.directory.style, "bold blue");
239        assert_eq!(config.directory.truncation_length, 5);
240        assert_eq!(config.claude_model.symbol, "<");
241        assert_eq!(config.claude_model.style, "bold yellow");
242    }
243
244    #[test]
245    fn test_partial_config_uses_defaults() {
246        let toml_str = r#"
247            debug = true
248
249            [directory]
250            style = "italic green"
251        "#;
252
253        let config: Config = toml::from_str(toml_str).unwrap();
254
255        // Specified values
256        assert!(config.debug);
257        assert_eq!(config.directory.style, "italic green");
258
259        // Default values for unspecified fields
260        assert_eq!(config.format, "$directory $claude_model");
261        assert_eq!(config.command_timeout, 500);
262        assert_eq!(config.directory.format, "[$path]($style)");
263        assert_eq!(config.claude_model.symbol, "");
264    }
265
266    #[test]
267    fn test_invalid_toml_returns_default() {
268        let invalid_toml = "this is not valid TOML [ syntax";
269        let result = toml::from_str::<Config>(invalid_toml);
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn test_config_path_with_config_dir() {
275        // This test checks the path construction logic via dirs::config_dir
276        let path = get_config_path();
277
278        if let Some(cfg_dir) = dirs::config_dir() {
279            let expected = cfg_dir.join("claude-code-statusline.toml");
280            assert_eq!(path, expected);
281        } else {
282            // Fallback when config_dir is not available
283            assert_eq!(path, PathBuf::from("~/.config/claude-code-statusline.toml"));
284        }
285    }
286
287    #[test]
288    fn extra_modules_are_preserved_and_accessible() {
289        let toml_str = r#"
290            [directory]
291            style = "bold blue"
292
293            [my_custom]
294            key = "value"
295            answer = 42
296        "#;
297        let cfg: Cfg = toml::from_str(toml_str).unwrap();
298        let provider = super::ConfigProvider::new(&cfg);
299        let t = provider.module_table("my_custom").expect("table exists");
300        assert_eq!(t.get("key").unwrap().as_str().unwrap(), "value");
301        assert_eq!(t.get("answer").unwrap().as_integer().unwrap(), 42);
302        assert!(
303            provider
304                .list_extra_modules()
305                .contains(&"my_custom".to_string())
306        );
307    }
308
309    #[test]
310    fn test_claude_model_default_symbol_is_empty() {
311        // New desired default behavior for issue #27
312        let cfg = Config::default();
313        assert_eq!(cfg.claude_model.symbol, "");
314    }
315}