lore_cli/config/
mod.rs

1//! Configuration management.
2//!
3//! Handles loading and saving Lore configuration from `~/.lore/config.yaml`.
4//!
5//! Note: Configuration options are planned for a future release. Currently
6//! this module provides path information only. The Config struct and its
7//! methods are preserved for future use.
8
9use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14/// Lore configuration settings.
15///
16/// Controls watcher behavior, auto-linking, and commit integration.
17/// Loaded from `~/.lore/config.yaml` when available.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct Config {
20    /// List of enabled watcher names (e.g., "claude-code", "cursor").
21    pub watchers: Vec<String>,
22
23    /// Whether to automatically link sessions to commits.
24    pub auto_link: bool,
25
26    /// Minimum confidence score (0.0-1.0) required for auto-linking.
27    pub auto_link_threshold: f64,
28
29    /// Whether to append session references to commit messages.
30    pub commit_footer: bool,
31}
32
33impl Default for Config {
34    fn default() -> Self {
35        Self {
36            watchers: vec!["claude-code".to_string()],
37            auto_link: false,
38            auto_link_threshold: 0.7,
39            commit_footer: false,
40        }
41    }
42}
43
44impl Config {
45    /// Loads configuration from the default config file.
46    ///
47    /// Returns default configuration if the file does not exist.
48    pub fn load() -> Result<Self> {
49        let path = Self::config_path()?;
50        Self::load_from_path(&path)
51    }
52
53    /// Saves configuration to the default config file.
54    ///
55    /// Creates the `~/.lore` directory if it does not exist.
56    #[allow(dead_code)]
57    pub fn save(&self) -> Result<()> {
58        let path = Self::config_path()?;
59        self.save_to_path(&path)
60    }
61
62    /// Loads configuration from a specific path.
63    ///
64    /// Returns default configuration if the file does not exist.
65    pub fn load_from_path(path: &Path) -> Result<Self> {
66        if !path.exists() {
67            return Ok(Self::default());
68        }
69
70        let content = fs::read_to_string(path)
71            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
72
73        if content.trim().is_empty() {
74            return Ok(Self::default());
75        }
76
77        let config: Config = serde_yaml::from_str(&content)
78            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
79
80        Ok(config)
81    }
82
83    /// Saves configuration to a specific path.
84    ///
85    /// Creates parent directories if they do not exist.
86    pub fn save_to_path(&self, path: &Path) -> Result<()> {
87        if let Some(parent) = path.parent() {
88            fs::create_dir_all(parent).with_context(|| {
89                format!("Failed to create config directory: {}", parent.display())
90            })?;
91        }
92
93        let content = serde_yaml::to_string(self).context("Failed to serialize config")?;
94
95        fs::write(path, content)
96            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
97
98        Ok(())
99    }
100
101    /// Gets a configuration value by key.
102    ///
103    /// Supported keys:
104    /// - `watchers` - comma-separated list of enabled watchers
105    /// - `auto_link` - "true" or "false"
106    /// - `auto_link_threshold` - float between 0.0 and 1.0
107    /// - `commit_footer` - "true" or "false"
108    ///
109    /// Returns `None` if the key is not recognized.
110    pub fn get(&self, key: &str) -> Option<String> {
111        match key {
112            "watchers" => Some(self.watchers.join(",")),
113            "auto_link" => Some(self.auto_link.to_string()),
114            "auto_link_threshold" => Some(self.auto_link_threshold.to_string()),
115            "commit_footer" => Some(self.commit_footer.to_string()),
116            _ => None,
117        }
118    }
119
120    /// Sets a configuration value by key.
121    ///
122    /// Supported keys:
123    /// - `watchers` - comma-separated list of enabled watchers
124    /// - `auto_link` - "true" or "false"
125    /// - `auto_link_threshold` - float between 0.0 and 1.0 (inclusive)
126    /// - `commit_footer` - "true" or "false"
127    ///
128    /// Returns an error if the key is not recognized or the value is invalid.
129    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
130        match key {
131            "watchers" => {
132                self.watchers = value
133                    .split(',')
134                    .map(|s| s.trim().to_string())
135                    .filter(|s| !s.is_empty())
136                    .collect();
137            }
138            "auto_link" => {
139                self.auto_link = parse_bool(value)
140                    .with_context(|| format!("Invalid value for auto_link: '{value}'"))?;
141            }
142            "auto_link_threshold" => {
143                let threshold: f64 = value
144                    .parse()
145                    .with_context(|| format!("Invalid value for auto_link_threshold: '{value}'"))?;
146                if !(0.0..=1.0).contains(&threshold) {
147                    bail!("auto_link_threshold must be between 0.0 and 1.0, got {threshold}");
148                }
149                self.auto_link_threshold = threshold;
150            }
151            "commit_footer" => {
152                self.commit_footer = parse_bool(value)
153                    .with_context(|| format!("Invalid value for commit_footer: '{value}'"))?;
154            }
155            _ => {
156                bail!("Unknown configuration key: '{key}'");
157            }
158        }
159        Ok(())
160    }
161
162    /// Returns the path to the configuration file.
163    ///
164    /// The configuration file is located at `~/.lore/config.yaml`.
165    pub fn config_path() -> Result<PathBuf> {
166        let config_dir = dirs::home_dir()
167            .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
168            .join(".lore");
169
170        Ok(config_dir.join("config.yaml"))
171    }
172
173    /// Returns the list of valid configuration keys.
174    pub fn valid_keys() -> &'static [&'static str] {
175        &[
176            "watchers",
177            "auto_link",
178            "auto_link_threshold",
179            "commit_footer",
180        ]
181    }
182}
183
184/// Parses a boolean value from a string.
185///
186/// Accepts "true", "false", "1", "0", "yes", "no" (case-insensitive).
187fn parse_bool(value: &str) -> Result<bool> {
188    match value.to_lowercase().as_str() {
189        "true" | "1" | "yes" => Ok(true),
190        "false" | "0" | "no" => Ok(false),
191        _ => bail!("Expected 'true' or 'false', got '{value}'"),
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use tempfile::TempDir;
199
200    #[test]
201    fn test_default_config() {
202        let config = Config::default();
203        assert_eq!(config.watchers, vec!["claude-code".to_string()]);
204        assert!(!config.auto_link);
205        assert!((config.auto_link_threshold - 0.7).abs() < f64::EPSILON);
206        assert!(!config.commit_footer);
207    }
208
209    #[test]
210    fn test_load_nonexistent_returns_default() {
211        let temp_dir = TempDir::new().unwrap();
212        let path = temp_dir.path().join("nonexistent.yaml");
213
214        let config = Config::load_from_path(&path).unwrap();
215        assert_eq!(config, Config::default());
216    }
217
218    #[test]
219    fn test_save_and_load() {
220        let temp_dir = TempDir::new().unwrap();
221        let path = temp_dir.path().join("config.yaml");
222
223        let config = Config {
224            auto_link: true,
225            auto_link_threshold: 0.8,
226            watchers: vec!["claude-code".to_string(), "cursor".to_string()],
227            ..Default::default()
228        };
229
230        config.save_to_path(&path).unwrap();
231
232        let loaded = Config::load_from_path(&path).unwrap();
233        assert_eq!(loaded, config);
234    }
235
236    #[test]
237    fn test_save_creates_parent_directories() {
238        let temp_dir = TempDir::new().unwrap();
239        let path = temp_dir
240            .path()
241            .join("nested")
242            .join("dir")
243            .join("config.yaml");
244
245        let config = Config::default();
246        config.save_to_path(&path).unwrap();
247
248        assert!(path.exists());
249    }
250
251    #[test]
252    fn test_get_watchers() {
253        let config = Config {
254            watchers: vec!["claude-code".to_string(), "cursor".to_string()],
255            ..Default::default()
256        };
257
258        assert_eq!(
259            config.get("watchers"),
260            Some("claude-code,cursor".to_string())
261        );
262    }
263
264    #[test]
265    fn test_get_auto_link() {
266        let config = Config {
267            auto_link: true,
268            ..Default::default()
269        };
270
271        assert_eq!(config.get("auto_link"), Some("true".to_string()));
272    }
273
274    #[test]
275    fn test_get_auto_link_threshold() {
276        let config = Config::default();
277        assert_eq!(config.get("auto_link_threshold"), Some("0.7".to_string()));
278    }
279
280    #[test]
281    fn test_get_commit_footer() {
282        let config = Config::default();
283        assert_eq!(config.get("commit_footer"), Some("false".to_string()));
284    }
285
286    #[test]
287    fn test_get_unknown_key() {
288        let config = Config::default();
289        assert_eq!(config.get("unknown_key"), None);
290    }
291
292    #[test]
293    fn test_set_watchers() {
294        let mut config = Config::default();
295        config
296            .set("watchers", "claude-code, cursor, copilot")
297            .unwrap();
298
299        assert_eq!(
300            config.watchers,
301            vec![
302                "claude-code".to_string(),
303                "cursor".to_string(),
304                "copilot".to_string()
305            ]
306        );
307    }
308
309    #[test]
310    fn test_set_auto_link() {
311        let mut config = Config::default();
312
313        config.set("auto_link", "true").unwrap();
314        assert!(config.auto_link);
315
316        config.set("auto_link", "false").unwrap();
317        assert!(!config.auto_link);
318
319        config.set("auto_link", "yes").unwrap();
320        assert!(config.auto_link);
321
322        config.set("auto_link", "no").unwrap();
323        assert!(!config.auto_link);
324    }
325
326    #[test]
327    fn test_set_auto_link_threshold() {
328        let mut config = Config::default();
329
330        config.set("auto_link_threshold", "0.5").unwrap();
331        assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
332
333        config.set("auto_link_threshold", "0.0").unwrap();
334        assert!((config.auto_link_threshold - 0.0).abs() < f64::EPSILON);
335
336        config.set("auto_link_threshold", "1.0").unwrap();
337        assert!((config.auto_link_threshold - 1.0).abs() < f64::EPSILON);
338    }
339
340    #[test]
341    fn test_set_auto_link_threshold_invalid_range() {
342        let mut config = Config::default();
343
344        assert!(config.set("auto_link_threshold", "-0.1").is_err());
345        assert!(config.set("auto_link_threshold", "1.1").is_err());
346        assert!(config.set("auto_link_threshold", "2.0").is_err());
347    }
348
349    #[test]
350    fn test_set_auto_link_threshold_invalid_format() {
351        let mut config = Config::default();
352
353        assert!(config.set("auto_link_threshold", "not_a_number").is_err());
354    }
355
356    #[test]
357    fn test_set_commit_footer() {
358        let mut config = Config::default();
359
360        config.set("commit_footer", "true").unwrap();
361        assert!(config.commit_footer);
362
363        config.set("commit_footer", "false").unwrap();
364        assert!(!config.commit_footer);
365    }
366
367    #[test]
368    fn test_set_unknown_key() {
369        let mut config = Config::default();
370
371        assert!(config.set("unknown_key", "value").is_err());
372    }
373
374    #[test]
375    fn test_set_invalid_bool() {
376        let mut config = Config::default();
377
378        assert!(config.set("auto_link", "maybe").is_err());
379    }
380
381    #[test]
382    fn test_load_empty_file_returns_default() {
383        let temp_dir = TempDir::new().unwrap();
384        let path = temp_dir.path().join("config.yaml");
385
386        fs::write(&path, "").unwrap();
387
388        let config = Config::load_from_path(&path).unwrap();
389        assert_eq!(config, Config::default());
390    }
391
392    #[test]
393    fn test_valid_keys() {
394        let keys = Config::valid_keys();
395        assert!(keys.contains(&"watchers"));
396        assert!(keys.contains(&"auto_link"));
397        assert!(keys.contains(&"auto_link_threshold"));
398        assert!(keys.contains(&"commit_footer"));
399    }
400
401    #[test]
402    fn test_parse_bool() {
403        assert!(parse_bool("true").unwrap());
404        assert!(parse_bool("TRUE").unwrap());
405        assert!(parse_bool("True").unwrap());
406        assert!(parse_bool("1").unwrap());
407        assert!(parse_bool("yes").unwrap());
408        assert!(parse_bool("YES").unwrap());
409
410        assert!(!parse_bool("false").unwrap());
411        assert!(!parse_bool("FALSE").unwrap());
412        assert!(!parse_bool("False").unwrap());
413        assert!(!parse_bool("0").unwrap());
414        assert!(!parse_bool("no").unwrap());
415        assert!(!parse_bool("NO").unwrap());
416
417        assert!(parse_bool("invalid").is_err());
418    }
419}