org_core/
config.rs

1use std::{env, fs, io, path::PathBuf};
2
3use crate::OrgModeError;
4use serde::{Deserialize, Serialize};
5use shellexpand::tilde;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Config {
9    pub org: OrgConfig,
10    #[serde(default)]
11    pub logging: LoggingConfig,
12    #[serde(default)]
13    pub cli: CliConfig,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct OrgConfig {
18    #[serde(default = "default_org_directory")]
19    pub org_directory: String,
20    #[serde(default = "default_notes_file")]
21    pub org_default_notes_file: String,
22    #[serde(default = "default_agenda_files")]
23    pub org_agenda_files: Vec<String>,
24    #[serde(default)]
25    pub org_agenda_text_search_extra_files: Vec<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct LoggingConfig {
30    #[serde(default = "default_log_level")]
31    pub level: String,
32    #[serde(default = "default_log_file")]
33    pub file: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CliConfig {
38    #[serde(default = "default_output_format")]
39    pub default_format: String,
40}
41
42fn default_org_directory() -> String {
43    "~/org/".to_string()
44}
45
46fn default_notes_file() -> String {
47    "notes.org".to_string()
48}
49
50fn default_agenda_files() -> Vec<String> {
51    vec!["agenda.org".to_string()]
52}
53
54fn default_log_level() -> String {
55    "info".to_string()
56}
57
58fn default_log_file() -> String {
59    "~/.local/share/org-mcp-server/logs/server.log".to_string()
60}
61
62fn default_output_format() -> String {
63    "plain".to_string()
64}
65
66impl Default for Config {
67    fn default() -> Self {
68        Self {
69            org: OrgConfig {
70                org_directory: default_org_directory(),
71                org_default_notes_file: default_notes_file(),
72                org_agenda_files: default_agenda_files(),
73                org_agenda_text_search_extra_files: Vec::default(),
74            },
75            logging: LoggingConfig::default(),
76            cli: CliConfig::default(),
77        }
78    }
79}
80
81impl Default for LoggingConfig {
82    fn default() -> Self {
83        Self {
84            level: default_log_level(),
85            file: default_log_file(),
86        }
87    }
88}
89
90impl Default for CliConfig {
91    fn default() -> Self {
92        Self {
93            default_format: default_output_format(),
94        }
95    }
96}
97
98#[derive(Debug)]
99pub struct ConfigBuilder {
100    config: Config,
101}
102
103impl Default for ConfigBuilder {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl ConfigBuilder {
110    pub fn new() -> Self {
111        Self {
112            config: Config::default(),
113        }
114    }
115
116    pub fn with_config_file(mut self, config_path: Option<&str>) -> Result<Self, OrgModeError> {
117        let config_file = if let Some(path) = config_path {
118            PathBuf::from(path)
119        } else {
120            self.default_config_path()?
121        };
122
123        if config_file.exists() {
124            let content = std::fs::read_to_string(&config_file).map_err(|e| {
125                OrgModeError::ConfigError(format!(
126                    "Failed to read config file {config_file:?}: {e}"
127                ))
128            })?;
129
130            self.config = toml::from_str(&content).map_err(|e| {
131                OrgModeError::ConfigError(format!(
132                    "Failed to parse config file {config_file:?}: {e}"
133                ))
134            })?;
135        }
136
137        Ok(self)
138    }
139
140    pub fn with_env_vars(mut self) -> Self {
141        if let Ok(root_dir) = env::var("ORG_ROOT_DIRECTORY") {
142            self.config.org.org_directory = root_dir;
143        }
144
145        if let Ok(notes_file) = env::var("ORG_DEFAULT_NOTES_FILE") {
146            self.config.org.org_default_notes_file = notes_file;
147        }
148
149        if let Ok(agenda_files) = env::var("ORG_AGENDA_FILES") {
150            self.config.org.org_agenda_files = agenda_files
151                .split(',')
152                .map(|s| s.trim().to_string())
153                .collect();
154        }
155
156        if let Ok(extra_files) = env::var("ORG_AGENDA_TEXT_SEARCH_EXTRA_FILES") {
157            self.config.org.org_agenda_text_search_extra_files = extra_files
158                .split(',')
159                .map(|s| s.trim().to_string())
160                .collect();
161        }
162
163        if let Ok(log_level) = env::var("ORG_LOG_LEVEL") {
164            self.config.logging.level = log_level;
165        }
166
167        if let Ok(log_file) = env::var("ORG_LOG_FILE") {
168            self.config.logging.file = log_file;
169        }
170
171        self
172    }
173
174    pub fn with_cli_overrides(
175        mut self,
176        root_directory: Option<String>,
177        log_level: Option<String>,
178    ) -> Self {
179        if let Some(root_dir) = root_directory {
180            self.config.org.org_directory = root_dir;
181        }
182
183        if let Some(level) = log_level {
184            self.config.logging.level = level;
185        }
186
187        self
188    }
189
190    pub fn build(self) -> Config {
191        self.config
192    }
193
194    fn default_config_path(&self) -> Result<PathBuf, OrgModeError> {
195        let config_dir = dirs::config_dir().ok_or_else(|| {
196            OrgModeError::ConfigError("Could not determine config directory".to_string())
197        })?;
198
199        Ok(config_dir.join("org-mcp-server.toml"))
200    }
201}
202
203impl Config {
204    pub fn load() -> Result<Self, OrgModeError> {
205        ConfigBuilder::new()
206            .with_config_file(None)?
207            .with_env_vars()
208            .build()
209            .validate()
210    }
211
212    pub fn load_with_overrides(
213        config_file: Option<String>,
214        root_directory: Option<String>,
215        log_level: Option<String>,
216    ) -> Result<Self, OrgModeError> {
217        ConfigBuilder::new()
218            .with_config_file(config_file.as_deref())?
219            .with_env_vars()
220            .with_cli_overrides(root_directory, log_level)
221            .build()
222            .validate()
223    }
224
225    pub fn validate(mut self) -> Result<Self, OrgModeError> {
226        let expanded_root = tilde(&self.org.org_directory);
227        let root_path = PathBuf::from(expanded_root.as_ref());
228
229        if !root_path.exists() {
230            return Err(OrgModeError::ConfigError(format!(
231                "Root directory does not exist: {}",
232                self.org.org_directory
233            )));
234        }
235
236        if !root_path.is_dir() {
237            return Err(OrgModeError::ConfigError(format!(
238                "Root directory is not a directory: {}",
239                self.org.org_directory
240            )));
241        }
242
243        match fs::read_dir(&root_path) {
244            Ok(_) => {}
245            Err(e) => {
246                if e.kind() == io::ErrorKind::PermissionDenied {
247                    return Err(OrgModeError::InvalidDirectory(format!(
248                        "Permission denied accessing directory: {root_path:?}"
249                    )));
250                }
251                return Err(OrgModeError::IoError(e));
252            }
253        }
254
255        self.org.org_directory = expanded_root.to_string();
256        Ok(self)
257    }
258
259    pub fn generate_default_config() -> Result<String, OrgModeError> {
260        let config = Config::default();
261        toml::to_string_pretty(&config).map_err(|e| {
262            OrgModeError::ConfigError(format!("Failed to serialize default config: {e}"))
263        })
264    }
265
266    pub fn default_config_path() -> Result<PathBuf, OrgModeError> {
267        let config_dir = dirs::config_dir().ok_or_else(|| {
268            OrgModeError::ConfigError("Could not determine config directory".to_string())
269        })?;
270
271        Ok(config_dir.join("org-mcp-server.toml"))
272    }
273
274    pub fn save_to_file(&self, path: &PathBuf) -> Result<(), OrgModeError> {
275        if let Some(parent) = path.parent() {
276            std::fs::create_dir_all(parent).map_err(|e| {
277                OrgModeError::ConfigError(format!("Failed to create config directory: {e}"))
278            })?;
279        }
280
281        let content = toml::to_string_pretty(self)
282            .map_err(|e| OrgModeError::ConfigError(format!("Failed to serialize config: {e}")))?;
283
284        std::fs::write(path, content)
285            .map_err(|e| OrgModeError::ConfigError(format!("Failed to write config file: {e}")))?;
286
287        Ok(())
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serial_test::serial;
295    use temp_env::with_vars;
296    use tempfile::tempdir;
297
298    #[test]
299    fn test_default_config() {
300        let config = Config::default();
301        assert_eq!(config.org.org_directory, "~/org/");
302        assert_eq!(config.org.org_default_notes_file, "notes.org");
303        assert_eq!(config.org.org_agenda_files, vec!["agenda.org"]);
304        assert!(config.org.org_agenda_text_search_extra_files.is_empty());
305        assert_eq!(config.logging.level, "info");
306        assert_eq!(config.cli.default_format, "plain");
307    }
308
309    #[test]
310    fn test_config_serialization() {
311        let config = Config::default();
312        let toml_str = toml::to_string_pretty(&config).unwrap();
313        let parsed: Config = toml::from_str(&toml_str).unwrap();
314
315        assert_eq!(config.org.org_directory, parsed.org.org_directory);
316        assert_eq!(
317            config.org.org_default_notes_file,
318            parsed.org.org_default_notes_file
319        );
320        assert_eq!(config.org.org_agenda_files, parsed.org.org_agenda_files);
321    }
322
323    #[test]
324    #[cfg_attr(
325        target_os = "windows",
326        ignore = "Environment variable handling unreliable in Windows tests"
327    )]
328    #[serial]
329    fn test_env_var_override() {
330        with_vars(
331            [
332                ("ORG_ROOT_DIRECTORY", Some("/tmp/test-org")),
333                ("ORG_DEFAULT_NOTES_FILE", Some("test-notes.org")),
334                ("ORG_AGENDA_FILES", Some("agenda1.org,agenda2.org")),
335            ],
336            || {
337                let config = ConfigBuilder::new().with_env_vars().build();
338
339                assert_eq!(config.org.org_directory, "/tmp/test-org");
340                assert_eq!(config.org.org_default_notes_file, "test-notes.org");
341                assert_eq!(
342                    config.org.org_agenda_files,
343                    vec!["agenda1.org", "agenda2.org"]
344                );
345            },
346        );
347    }
348
349    #[test]
350    fn test_cli_override() {
351        let config = ConfigBuilder::new()
352            .with_cli_overrides(Some("/custom/org".to_string()), Some("debug".to_string()))
353            .build();
354
355        assert_eq!(config.org.org_directory, "/custom/org");
356        assert_eq!(config.logging.level, "debug");
357    }
358
359    #[test]
360    fn test_config_file_loading() {
361        let temp_dir = tempdir().unwrap();
362        let config_path = temp_dir.path().join("test-config.toml");
363
364        let test_config = r#"
365[org]
366org_directory = "/test/org"
367org_default_notes_file = "custom-notes.org"
368org_agenda_files = ["test1.org", "test2.org"]
369
370[logging]
371level = "debug"
372
373[cli]
374default_format = "json"
375"#;
376
377        std::fs::write(&config_path, test_config).unwrap();
378
379        let config = ConfigBuilder::new()
380            .with_config_file(Some(config_path.to_str().unwrap()))
381            .unwrap()
382            .build();
383
384        assert_eq!(config.org.org_directory, "/test/org");
385        assert_eq!(config.org.org_default_notes_file, "custom-notes.org");
386        assert_eq!(config.org.org_agenda_files, vec!["test1.org", "test2.org"]);
387        assert_eq!(config.logging.level, "debug");
388        assert_eq!(config.cli.default_format, "json");
389    }
390
391    #[test]
392    fn test_validate_directory_expansion() {
393        let temp_dir = tempdir().unwrap();
394        let mut config = Config::default();
395        config.org.org_directory = temp_dir.path().to_str().unwrap().to_string();
396
397        let validated = config.validate().unwrap();
398        assert_eq!(
399            validated.org.org_directory,
400            temp_dir.path().to_str().unwrap()
401        );
402    }
403
404    #[test]
405    fn test_validate_nonexistent_directory() {
406        let mut config = Config::default();
407        config.org.org_directory = "/nonexistent/test/directory".to_string();
408
409        let result = config.validate();
410        assert!(result.is_err());
411        match result.unwrap_err() {
412            OrgModeError::ConfigError(msg) => {
413                assert!(msg.contains("Root directory does not exist"));
414            }
415            _ => panic!("Expected ConfigError"),
416        }
417    }
418
419    #[test]
420    fn test_validate_non_directory_path() {
421        let temp_dir = tempdir().unwrap();
422        let file_path = temp_dir.path().join("not-a-dir.txt");
423        std::fs::write(&file_path, "test").unwrap();
424
425        let mut config = Config::default();
426        config.org.org_directory = file_path.to_str().unwrap().to_string();
427
428        let result = config.validate();
429        assert!(result.is_err());
430        match result.unwrap_err() {
431            OrgModeError::ConfigError(msg) => {
432                assert!(msg.contains("not a directory"));
433            }
434            _ => panic!("Expected ConfigError"),
435        }
436    }
437
438    #[test]
439    #[serial]
440    fn test_load_full_path() {
441        let temp_dir = tempdir().unwrap();
442        let config_path = temp_dir.path().join("config.toml");
443
444        // Convert path to forward slashes for TOML compatibility on Windows
445        let path_str = temp_dir.path().to_str().unwrap().replace('\\', "/");
446        let test_config = format!(
447            r#"
448[org]
449org_directory = "{}"
450"#,
451            path_str
452        );
453
454        std::fs::write(&config_path, test_config).unwrap();
455
456        with_vars(
457            [
458                ("XDG_CONFIG_HOME", temp_dir.path().to_str()),
459                ("HOME", temp_dir.path().to_str()),
460            ],
461            || {
462                let config = ConfigBuilder::new()
463                    .with_config_file(Some(config_path.to_str().unwrap()))
464                    .unwrap()
465                    .with_env_vars()
466                    .build()
467                    .validate()
468                    .unwrap();
469
470                assert_eq!(config.org.org_directory, path_str);
471            },
472        );
473    }
474
475    #[test]
476    #[serial]
477    fn test_load_with_overrides_full_hierarchy() {
478        let temp_dir = tempdir().unwrap();
479        let config_path = temp_dir.path().join("config.toml");
480
481        // Convert path to forward slashes for TOML compatibility on Windows
482        let path_str = temp_dir.path().to_str().unwrap().replace('\\', "/");
483        let test_config = format!(
484            r#"
485[org]
486org_directory = "{}"
487
488[logging]
489level = "debug"
490"#,
491            path_str
492        );
493
494        std::fs::write(&config_path, test_config).unwrap();
495
496        with_vars([("ORG_ROOT_DIRECTORY", None::<&str>)], || {
497            let config = Config::load_with_overrides(
498                Some(config_path.to_str().unwrap().to_string()),
499                None,
500                Some("trace".to_string()),
501            )
502            .unwrap();
503
504            assert_eq!(config.org.org_directory, path_str);
505            assert_eq!(config.logging.level, "trace");
506        });
507    }
508
509    #[test]
510    fn test_generate_default_config() {
511        let toml_str = Config::generate_default_config().unwrap();
512        assert!(toml_str.contains("org_directory"));
513        assert!(toml_str.contains("~/org/"));
514        assert!(toml_str.contains("[logging]"));
515        assert!(toml_str.contains("[cli]"));
516
517        let parsed: Config = toml::from_str(&toml_str).unwrap();
518        assert_eq!(parsed.org.org_directory, "~/org/");
519    }
520
521    #[test]
522    fn test_save_to_file() {
523        let temp_dir = tempdir().unwrap();
524        let nested_path = temp_dir.path().join("nested").join("config.toml");
525
526        let mut config = Config::default();
527        config.org.org_directory = temp_dir.path().to_str().unwrap().to_string();
528
529        config.save_to_file(&nested_path).unwrap();
530
531        assert!(nested_path.exists());
532        let content = std::fs::read_to_string(&nested_path).unwrap();
533        assert!(content.contains("org_directory"));
534    }
535
536    #[test]
537    #[cfg_attr(
538        target_os = "windows",
539        ignore = "Environment variable handling unreliable in Windows tests"
540    )]
541    #[serial]
542    fn test_env_var_all_fields() {
543        with_vars(
544            [
545                ("ORG_ROOT_DIRECTORY", Some("/tmp/test-org")),
546                ("ORG_DEFAULT_NOTES_FILE", Some("test-notes.org")),
547                ("ORG_AGENDA_FILES", Some("agenda1.org,agenda2.org")),
548                (
549                    "ORG_AGENDA_TEXT_SEARCH_EXTRA_FILES",
550                    Some("archive.org,old.org"),
551                ),
552                ("ORG_LOG_LEVEL", Some("trace")),
553                ("ORG_LOG_FILE", Some("/tmp/test.log")),
554            ],
555            || {
556                let config = ConfigBuilder::new().with_env_vars().build();
557
558                assert_eq!(config.org.org_directory, "/tmp/test-org");
559                assert_eq!(config.org.org_default_notes_file, "test-notes.org");
560                assert_eq!(
561                    config.org.org_agenda_files,
562                    vec!["agenda1.org", "agenda2.org"]
563                );
564                assert_eq!(
565                    config.org.org_agenda_text_search_extra_files,
566                    vec!["archive.org", "old.org"]
567                );
568                assert_eq!(config.logging.level, "trace");
569                assert_eq!(config.logging.file, "/tmp/test.log");
570            },
571        );
572    }
573
574    #[test]
575    fn test_invalid_toml_syntax() {
576        let temp_dir = tempdir().unwrap();
577        let config_path = temp_dir.path().join("invalid.toml");
578
579        std::fs::write(&config_path, "invalid toml [ syntax").unwrap();
580
581        let result = ConfigBuilder::new().with_config_file(Some(config_path.to_str().unwrap()));
582
583        assert!(result.is_err());
584        match result.unwrap_err() {
585            OrgModeError::ConfigError(msg) => {
586                assert!(msg.contains("Failed to parse config file"));
587            }
588            _ => panic!("Expected ConfigError"),
589        }
590    }
591
592    #[test]
593    fn test_config_file_read_error() {
594        let result = ConfigBuilder::new().with_config_file(Some("/nonexistent/path/config.toml"));
595
596        assert!(result.is_ok());
597    }
598
599    #[test]
600    fn test_missing_config_directory_fallback() {
601        let result = ConfigBuilder::new().with_config_file(None);
602
603        assert!(result.is_ok());
604    }
605}