org_core/
config.rs

1use std::{fs, io, path::PathBuf};
2
3use crate::OrgModeError;
4use config::{
5    Config as ConfigRs, ConfigError, Environment, File,
6    builder::{ConfigBuilder, DefaultState},
7};
8use serde::{Deserialize, Serialize};
9use shellexpand::tilde;
10
11/// Core org-mode configuration settings
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct OrgConfig {
14    #[serde(default = "default_org_directory")]
15    pub org_directory: String,
16    #[serde(default = "default_notes_file")]
17    pub org_default_notes_file: String,
18    #[serde(default = "default_agenda_files")]
19    pub org_agenda_files: Vec<String>,
20    #[serde(default)]
21    pub org_agenda_text_search_extra_files: Vec<String>,
22}
23
24/// Logging configuration (shared across CLI and server)
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct LoggingConfig {
27    #[serde(default = "default_log_level")]
28    pub level: String,
29    #[serde(default = "default_log_file")]
30    pub file: String,
31}
32
33impl Default for OrgConfig {
34    fn default() -> Self {
35        Self {
36            org_directory: default_org_directory(),
37            org_default_notes_file: default_notes_file(),
38            org_agenda_files: default_agenda_files(),
39            org_agenda_text_search_extra_files: Vec::default(),
40        }
41    }
42}
43
44impl Default for LoggingConfig {
45    fn default() -> Self {
46        Self {
47            level: default_log_level(),
48            file: default_log_file(),
49        }
50    }
51}
52
53impl OrgConfig {
54    /// Validate and expand paths in the org configuration
55    pub fn validate(mut self) -> Result<Self, OrgModeError> {
56        let expanded_root = tilde(&self.org_directory);
57        let root_path = PathBuf::from(expanded_root.as_ref());
58
59        if !root_path.exists() {
60            return Err(OrgModeError::ConfigError(format!(
61                "Root directory does not exist: {}",
62                self.org_directory
63            )));
64        }
65
66        if !root_path.is_dir() {
67            return Err(OrgModeError::ConfigError(format!(
68                "Root directory is not a directory: {}",
69                self.org_directory
70            )));
71        }
72
73        match fs::read_dir(&root_path) {
74            Ok(_) => {}
75            Err(e) => {
76                if e.kind() == io::ErrorKind::PermissionDenied {
77                    return Err(OrgModeError::InvalidDirectory(format!(
78                        "Permission denied accessing directory: {root_path:?}"
79                    )));
80                }
81                return Err(OrgModeError::IoError(e));
82            }
83        }
84
85        self.org_directory = expanded_root.to_string();
86        Ok(self)
87    }
88}
89
90/// Get the default configuration file path
91pub fn default_config_path() -> Result<PathBuf, OrgModeError> {
92    Ok(default_config_dir()?.join("config"))
93}
94
95/// Find an existing config file, trying common extensions if the base path doesn't exist
96///
97/// Returns the first existing config file path found, or None if no config file exists.
98/// Tries extensions in order: toml, yaml, yml, json
99pub fn find_config_file(base_path: PathBuf) -> Option<PathBuf> {
100    if base_path.exists() {
101        return Some(base_path);
102    }
103
104    if let Some(parent) = base_path.parent() {
105        for ext in &["toml", "yaml", "yml", "json"] {
106            let path_with_ext = parent.join(format!("config.{ext}"));
107            if path_with_ext.exists() {
108                return Some(path_with_ext);
109            }
110        }
111    }
112
113    None
114}
115
116/// Build config from file and environment sources
117///
118/// Takes a builder with defaults already set, adds file and env sources,
119/// then builds and returns the config ready for section extraction.
120///
121/// # Arguments
122/// * `config_file` - Optional path to config file
123/// * `builder` - ConfigBuilder with defaults already set
124///
125/// # Returns
126/// Built Config object ready for section extraction via .get()
127pub fn build_config_with_file_and_env(
128    config_file: Option<&str>,
129    builder: ConfigBuilder<DefaultState>,
130) -> Result<ConfigRs, OrgModeError> {
131    let config_path = if let Some(path) = config_file {
132        PathBuf::from(path)
133    } else {
134        default_config_path()?
135    };
136
137    let mut builder = builder;
138    if let Some(config_file_path) = find_config_file(config_path) {
139        builder = builder.add_source(File::from(config_file_path).required(false));
140    }
141
142    builder = builder.add_source(
143        Environment::with_prefix("ORG")
144            .prefix_separator("_")
145            .separator("__"),
146    );
147
148    builder
149        .build()
150        .map_err(|e: ConfigError| OrgModeError::ConfigError(format!("Failed to build config: {e}")))
151}
152
153/// Load org configuration using config-rs with layered sources
154pub fn load_org_config(
155    config_file: Option<&str>,
156    org_directory: Option<&str>,
157) -> Result<OrgConfig, OrgModeError> {
158    let builder = ConfigRs::builder()
159        .set_default("org.org_directory", default_org_directory())?
160        .set_default("org.org_default_notes_file", default_notes_file())?
161        .set_default("org.org_agenda_files", default_agenda_files())?;
162
163    let config = build_config_with_file_and_env(config_file, builder)?;
164
165    let mut org_config: OrgConfig = config.get("org").map_err(|e: ConfigError| {
166        OrgModeError::ConfigError(format!("Failed to deserialize org config: {e}"))
167    })?;
168
169    if let Some(org_directory) = org_directory {
170        org_config.org_directory = org_directory.to_string();
171    }
172
173    org_config.validate()
174}
175
176/// Load logging configuration using config-rs
177pub fn load_logging_config(
178    config_file: Option<&str>,
179    log_level: Option<&str>,
180) -> Result<LoggingConfig, OrgModeError> {
181    let builder = ConfigRs::builder()
182        .set_default("logging.level", default_log_level())?
183        .set_default("logging.file", default_log_file())?;
184
185    let config = build_config_with_file_and_env(config_file, builder)?;
186
187    let mut config: LoggingConfig = config.get("logging").map_err(|e: ConfigError| {
188        OrgModeError::ConfigError(format!("Failed to deserialize logging config: {e}"))
189    })?;
190
191    if let Some(level) = log_level {
192        config.level = level.to_string();
193    }
194
195    Ok(config)
196}
197
198fn default_config_dir() -> Result<PathBuf, OrgModeError> {
199    let config_dir = dirs::config_dir().ok_or_else(|| {
200        OrgModeError::ConfigError("Could not determine config directory".to_string())
201    })?;
202
203    Ok(config_dir.join("org-mcp"))
204}
205
206pub fn default_org_directory() -> String {
207    "~/org/".to_string()
208}
209
210pub fn default_notes_file() -> String {
211    "notes.org".to_string()
212}
213
214pub fn default_agenda_files() -> Vec<String> {
215    vec!["agenda.org".to_string()]
216}
217
218pub fn default_log_level() -> String {
219    "info".to_string()
220}
221
222pub fn default_log_file() -> String {
223    "~/.local/share/org-mcp-server/logs/server.log".to_string()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use serial_test::serial;
230    use temp_env::with_vars;
231    use tempfile::tempdir;
232
233    #[test]
234    fn test_default_org_config() {
235        let config = OrgConfig::default();
236        assert_eq!(config.org_directory, "~/org/");
237        assert_eq!(config.org_default_notes_file, "notes.org");
238        assert_eq!(config.org_agenda_files, vec!["agenda.org"]);
239        assert!(config.org_agenda_text_search_extra_files.is_empty());
240    }
241
242    #[test]
243    fn test_default_logging_config() {
244        let config = LoggingConfig::default();
245        assert_eq!(config.level, "info");
246        assert_eq!(config.file, "~/.local/share/org-mcp-server/logs/server.log");
247    }
248
249    #[test]
250    fn test_config_serialization() {
251        let org_config = OrgConfig::default();
252        let toml_str = toml::to_string_pretty(&org_config).unwrap();
253        let parsed: OrgConfig = toml::from_str(&toml_str).unwrap();
254
255        assert_eq!(org_config.org_directory, parsed.org_directory);
256        assert_eq!(
257            org_config.org_default_notes_file,
258            parsed.org_default_notes_file
259        );
260        assert_eq!(org_config.org_agenda_files, parsed.org_agenda_files);
261    }
262
263    #[test]
264    #[cfg_attr(
265        target_os = "windows",
266        ignore = "Environment variable handling unreliable in Windows tests"
267    )]
268    #[serial]
269    fn test_env_var_override() {
270        let temp_dir = tempdir().unwrap();
271        let temp_path = temp_dir.path().to_str().unwrap();
272
273        with_vars(
274            [
275                ("ORG_ORG__ORG_DIRECTORY", Some(temp_path)),
276                ("ORG_ORG__ORG_DEFAULT_NOTES_FILE", Some("test-notes.org")),
277            ],
278            || {
279                let config = load_org_config(None, None).unwrap();
280                assert_eq!(config.org_directory, temp_path);
281                assert_eq!(config.org_default_notes_file, "test-notes.org");
282            },
283        );
284    }
285
286    #[test]
287    fn test_validate_directory_expansion() {
288        let temp_dir = tempdir().unwrap();
289        let config = OrgConfig {
290            org_directory: temp_dir.path().to_str().unwrap().to_string(),
291            ..OrgConfig::default()
292        };
293
294        let validated = config.validate().unwrap();
295        assert_eq!(validated.org_directory, temp_dir.path().to_str().unwrap());
296    }
297
298    #[test]
299    fn test_validate_nonexistent_directory() {
300        let config = OrgConfig {
301            org_directory: "/nonexistent/test/directory".to_string(),
302            ..OrgConfig::default()
303        };
304
305        let result = config.validate();
306        assert!(result.is_err());
307        match result.unwrap_err() {
308            OrgModeError::ConfigError(msg) => {
309                assert!(msg.contains("Root directory does not exist"));
310            }
311            _ => panic!("Expected ConfigError"),
312        }
313    }
314
315    #[test]
316    fn test_validate_non_directory_path() {
317        let temp_dir = tempdir().unwrap();
318        let file_path = temp_dir.path().join("not-a-dir.txt");
319        std::fs::write(&file_path, "test").unwrap();
320
321        let config = OrgConfig {
322            org_directory: file_path.to_str().unwrap().to_string(),
323            ..OrgConfig::default()
324        };
325
326        let result = config.validate();
327        assert!(result.is_err());
328        match result.unwrap_err() {
329            OrgModeError::ConfigError(msg) => {
330                assert!(msg.contains("not a directory"));
331            }
332            _ => panic!("Expected ConfigError"),
333        }
334    }
335
336    #[test]
337    #[serial]
338    fn test_load_from_toml_file() {
339        let temp_dir = tempdir().unwrap();
340        let path_str = test_utils::config::normalize_path(temp_dir.path());
341        let test_config = format!(
342            r#"
343[org]
344org_directory = "{path_str}"
345org_default_notes_file = "custom-notes.org"
346org_agenda_files = ["test1.org", "test2.org"]
347"#,
348        );
349
350        let config_path = test_utils::config::create_toml_config(&temp_dir, &test_config).unwrap();
351
352        let config = load_org_config(Some(config_path.to_str().unwrap()), None);
353        let config = config.unwrap();
354
355        assert_eq!(config.org_directory, path_str);
356        assert_eq!(config.org_default_notes_file, "custom-notes.org");
357        assert_eq!(config.org_agenda_files, vec!["test1.org", "test2.org"]);
358    }
359
360    #[test]
361    #[serial]
362    fn test_load_from_yaml_file() {
363        let temp_dir = tempdir().unwrap();
364        let path_str = test_utils::config::normalize_path(temp_dir.path());
365        let yaml_config = format!(
366            r#"
367org:
368  org_directory: "{path_str}"
369  org_default_notes_file: "yaml-notes.org"
370  org_agenda_files:
371    - "yaml1.org"
372    - "yaml2.org"
373"#
374        );
375
376        let yaml_path = test_utils::config::create_yaml_config(&temp_dir, &yaml_config).unwrap();
377        let config_dir = yaml_path.parent().unwrap();
378
379        let config = load_org_config(Some(config_dir.join("config").to_str().unwrap()), None);
380        let config = config.unwrap();
381
382        assert_eq!(config.org_directory, path_str);
383        assert_eq!(config.org_default_notes_file, "yaml-notes.org");
384        assert_eq!(config.org_agenda_files, vec!["yaml1.org", "yaml2.org"]);
385    }
386
387    #[test]
388    #[serial]
389    fn test_load_from_yml_file() {
390        let temp_dir = tempdir().unwrap();
391        let path_str = test_utils::config::normalize_path(temp_dir.path());
392        let yml_config = format!(
393            r#"
394org:
395  org_directory: "{path_str}"
396  org_default_notes_file: "yml-notes.org"
397logging:
398  level: "debug"
399  file: "/tmp/test.log"
400"#
401        );
402
403        let yml_path = test_utils::config::create_yml_config(&temp_dir, &yml_config).unwrap();
404        let config_dir = yml_path.parent().unwrap();
405
406        let config = load_org_config(Some(config_dir.join("config").to_str().unwrap()), None);
407        let config = config.unwrap();
408        assert_eq!(config.org_default_notes_file, "yml-notes.org");
409
410        let logging_config =
411            load_logging_config(Some(config_dir.join("config").to_str().unwrap()), None);
412        let logging_config = logging_config.unwrap();
413        assert_eq!(logging_config.level, "debug");
414        assert_eq!(logging_config.file, "/tmp/test.log");
415    }
416
417    #[test]
418    #[serial]
419    fn test_load_from_json_file() {
420        let temp_dir = tempdir().unwrap();
421        let path_str = test_utils::config::normalize_path(temp_dir.path());
422        let json_config = format!(
423            r#"{{
424  "org": {{
425    "org_directory": "{path_str}",
426    "org_default_notes_file": "json-notes.org",
427    "org_agenda_files": ["json1.org", "json2.org"]
428  }}
429}}"#
430        );
431
432        let json_path = test_utils::config::create_json_config(&temp_dir, &json_config).unwrap();
433        let config_dir = json_path.parent().unwrap();
434
435        let config = load_org_config(Some(config_dir.join("config").to_str().unwrap()), None);
436        let config = config.unwrap();
437
438        assert_eq!(config.org_directory, path_str);
439        assert_eq!(config.org_default_notes_file, "json-notes.org");
440        assert_eq!(config.org_agenda_files, vec!["json1.org", "json2.org"]);
441    }
442
443    #[test]
444    #[serial]
445    fn test_logging_config_file_extensions() {
446        let temp_dir = tempdir().unwrap();
447
448        let toml_config = r#"
449[logging]
450level = "trace"
451file = "/var/log/test.log"
452"#;
453
454        let toml_path = test_utils::config::create_toml_config(&temp_dir, toml_config).unwrap();
455
456        let config = load_logging_config(Some(toml_path.to_str().unwrap()), None);
457        let config = config.unwrap();
458
459        assert_eq!(config.level, "trace");
460        assert_eq!(config.file, "/var/log/test.log");
461    }
462}