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    #[serde(default = "default_todo_keywords")]
23    pub org_todo_keywords: Vec<String>,
24}
25
26/// Logging configuration (shared across CLI and server)
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct LoggingConfig {
29    #[serde(default = "default_log_level")]
30    pub level: String,
31    #[serde(default = "default_log_file")]
32    pub file: String,
33}
34
35impl Default for OrgConfig {
36    fn default() -> Self {
37        Self {
38            org_directory: default_org_directory(),
39            org_default_notes_file: default_notes_file(),
40            org_agenda_files: default_agenda_files(),
41            org_agenda_text_search_extra_files: Vec::default(),
42            org_todo_keywords: default_todo_keywords(),
43        }
44    }
45}
46
47impl Default for LoggingConfig {
48    fn default() -> Self {
49        Self {
50            level: default_log_level(),
51            file: default_log_file(),
52        }
53    }
54}
55
56impl OrgConfig {
57    /// Validate and expand paths in the org configuration
58    pub fn validate(mut self) -> Result<Self, OrgModeError> {
59        let expanded_root = tilde(&self.org_directory);
60        let root_path = PathBuf::from(expanded_root.as_ref());
61
62        if !root_path.exists() {
63            return Err(OrgModeError::ConfigError(format!(
64                "Root directory does not exist: {}",
65                self.org_directory
66            )));
67        }
68
69        if !root_path.is_dir() {
70            return Err(OrgModeError::ConfigError(format!(
71                "Root directory is not a directory: {}",
72                self.org_directory
73            )));
74        }
75
76        if self.org_todo_keywords.len() < 2 {
77            return Err(OrgModeError::ConfigError(
78                "org_todo_keywords must contain at least two keywords".to_string(),
79            ));
80        }
81
82        let separators: Vec<usize> = self
83            .org_todo_keywords
84            .iter()
85            .enumerate()
86            .filter_map(|(i, x)| (x == "|").then_some(i))
87            .collect();
88
89        if separators.len() > 1 {
90            return Err(OrgModeError::ConfigError(
91                "Multiple '|' separators found in org_todo_keywords".to_string(),
92            ));
93        }
94
95        if separators.len() == 1 {
96            let sep_pos = separators[0];
97            if sep_pos == 0 {
98                return Err(OrgModeError::ConfigError(
99                    "Separator '|' cannot be at the beginning of org_todo_keywords".to_string(),
100                ));
101            }
102            if sep_pos == self.org_todo_keywords.len() - 1 {
103                return Err(OrgModeError::ConfigError(
104                    "Separator '|' cannot be at the end of org_todo_keywords".to_string(),
105                ));
106            }
107        }
108
109        match fs::read_dir(&root_path) {
110            Ok(_) => {}
111            Err(e) => {
112                if e.kind() == io::ErrorKind::PermissionDenied {
113                    return Err(OrgModeError::InvalidDirectory(format!(
114                        "Permission denied accessing directory: {root_path:?}"
115                    )));
116                }
117                return Err(OrgModeError::IoError(e));
118            }
119        }
120
121        self.org_directory = expanded_root.to_string();
122        Ok(self)
123    }
124
125    pub fn unfinished_keywords(&self) -> Vec<String> {
126        if let Some(pos) = self.org_todo_keywords.iter().position(|x| x == "|") {
127            self.org_todo_keywords[..pos].to_vec()
128        } else {
129            self.org_todo_keywords[..self.org_todo_keywords.len() - 1].to_vec()
130        }
131    }
132
133    pub fn finished_keywords(&self) -> Vec<String> {
134        if let Some(pos) = self.org_todo_keywords.iter().position(|x| x == "|") {
135            self.org_todo_keywords[pos + 1..self.org_todo_keywords.len()].to_vec()
136        } else {
137            self.org_todo_keywords
138                .last()
139                .map(|e| vec![e.clone()])
140                .unwrap_or_default()
141        }
142    }
143}
144
145/// Get the default configuration file path
146pub fn default_config_path() -> Result<PathBuf, OrgModeError> {
147    Ok(default_config_dir()?.join("config"))
148}
149
150/// Find an existing config file, trying common extensions if the base path doesn't exist
151///
152/// Returns the first existing config file path found, or None if no config file exists.
153/// Tries extensions in order: toml, yaml, yml, json
154pub fn find_config_file(base_path: PathBuf) -> Option<PathBuf> {
155    if base_path.exists() {
156        return Some(base_path);
157    }
158
159    if let Some(parent) = base_path.parent() {
160        for ext in &["toml", "yaml", "yml", "json"] {
161            let path_with_ext = parent.join(format!("config.{ext}"));
162            if path_with_ext.exists() {
163                return Some(path_with_ext);
164            }
165        }
166    }
167
168    None
169}
170
171/// Build config from file and environment sources
172///
173/// Takes a builder with defaults already set, adds file and env sources,
174/// then builds and returns the config ready for section extraction.
175///
176/// # Arguments
177/// * `config_file` - Optional path to config file
178/// * `builder` - ConfigBuilder with defaults already set
179///
180/// # Returns
181/// Built Config object ready for section extraction via .get()
182pub fn build_config_with_file_and_env(
183    config_file: Option<&str>,
184    builder: ConfigBuilder<DefaultState>,
185) -> Result<ConfigRs, OrgModeError> {
186    let config_path = if let Some(path) = config_file {
187        PathBuf::from(path)
188    } else {
189        default_config_path()?
190    };
191
192    let mut builder = builder;
193    if let Some(config_file_path) = find_config_file(config_path) {
194        builder = builder.add_source(File::from(config_file_path).required(false));
195    }
196
197    builder = builder.add_source(
198        Environment::with_prefix("ORG")
199            .prefix_separator("_")
200            .separator("__"),
201    );
202
203    builder
204        .build()
205        .map_err(|e: ConfigError| OrgModeError::ConfigError(format!("Failed to build config: {e}")))
206}
207
208/// Load org configuration using config-rs with layered sources
209pub fn load_org_config(
210    config_file: Option<&str>,
211    org_directory: Option<&str>,
212) -> Result<OrgConfig, OrgModeError> {
213    let builder = ConfigRs::builder()
214        .set_default("org.org_directory", default_org_directory())?
215        .set_default("org.org_default_notes_file", default_notes_file())?
216        .set_default("org.org_agenda_files", default_agenda_files())?;
217
218    let config = build_config_with_file_and_env(config_file, builder)?;
219
220    let mut org_config: OrgConfig = config.get("org").map_err(|e: ConfigError| {
221        OrgModeError::ConfigError(format!("Failed to deserialize org config: {e}"))
222    })?;
223
224    if let Some(org_directory) = org_directory {
225        org_config.org_directory = org_directory.to_string();
226    }
227
228    org_config.validate()
229}
230
231/// Load logging configuration using config-rs
232pub fn load_logging_config(
233    config_file: Option<&str>,
234    log_level: Option<&str>,
235) -> Result<LoggingConfig, OrgModeError> {
236    let builder = ConfigRs::builder()
237        .set_default("logging.level", default_log_level())?
238        .set_default("logging.file", default_log_file())?;
239
240    let config = build_config_with_file_and_env(config_file, builder)?;
241
242    let mut config: LoggingConfig = config.get("logging").map_err(|e: ConfigError| {
243        OrgModeError::ConfigError(format!("Failed to deserialize logging config: {e}"))
244    })?;
245
246    if let Some(level) = log_level {
247        config.level = level.to_string();
248    }
249
250    Ok(config)
251}
252
253fn default_config_dir() -> Result<PathBuf, OrgModeError> {
254    let config_dir = dirs::config_dir().ok_or_else(|| {
255        OrgModeError::ConfigError("Could not determine config directory".to_string())
256    })?;
257
258    Ok(config_dir.join("org-mcp"))
259}
260
261pub fn default_org_directory() -> String {
262    "~/org/".to_string()
263}
264
265pub fn default_notes_file() -> String {
266    "notes.org".to_string()
267}
268
269pub fn default_agenda_files() -> Vec<String> {
270    vec!["agenda.org".to_string()]
271}
272
273pub fn default_todo_keywords() -> Vec<String> {
274    vec!["TODO".to_string(), "|".to_string(), "DONE".to_string()]
275}
276
277pub fn default_log_level() -> String {
278    "info".to_string()
279}
280
281pub fn default_log_file() -> String {
282    "~/.local/share/org-mcp-server/logs/server.log".to_string()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use serial_test::serial;
289    use temp_env::with_vars;
290    use tempfile::tempdir;
291
292    #[test]
293    fn test_default_org_config() {
294        let config = OrgConfig::default();
295        assert_eq!(config.org_directory, "~/org/");
296        assert_eq!(config.org_default_notes_file, "notes.org");
297        assert_eq!(config.org_agenda_files, vec!["agenda.org"]);
298        assert!(config.org_agenda_text_search_extra_files.is_empty());
299    }
300
301    #[test]
302    fn test_default_logging_config() {
303        let config = LoggingConfig::default();
304        assert_eq!(config.level, "info");
305        assert_eq!(config.file, "~/.local/share/org-mcp-server/logs/server.log");
306    }
307
308    #[test]
309    fn test_config_serialization() {
310        let org_config = OrgConfig::default();
311        let toml_str = toml::to_string_pretty(&org_config).unwrap();
312        let parsed: OrgConfig = toml::from_str(&toml_str).unwrap();
313
314        assert_eq!(org_config.org_directory, parsed.org_directory);
315        assert_eq!(
316            org_config.org_default_notes_file,
317            parsed.org_default_notes_file
318        );
319        assert_eq!(org_config.org_agenda_files, parsed.org_agenda_files);
320    }
321
322    #[test]
323    #[cfg_attr(
324        target_os = "windows",
325        ignore = "Environment variable handling unreliable in Windows tests"
326    )]
327    #[serial]
328    fn test_env_var_override() {
329        let temp_dir = tempdir().unwrap();
330        let temp_path = temp_dir.path().to_str().unwrap();
331
332        with_vars(
333            [
334                ("ORG_ORG__ORG_DIRECTORY", Some(temp_path)),
335                ("ORG_ORG__ORG_DEFAULT_NOTES_FILE", Some("test-notes.org")),
336            ],
337            || {
338                let config = load_org_config(None, None).unwrap();
339                assert_eq!(config.org_directory, temp_path);
340                assert_eq!(config.org_default_notes_file, "test-notes.org");
341            },
342        );
343    }
344
345    #[test]
346    fn test_validate_directory_expansion() {
347        let temp_dir = tempdir().unwrap();
348        let config = OrgConfig {
349            org_directory: temp_dir.path().to_str().unwrap().to_string(),
350            ..OrgConfig::default()
351        };
352
353        let validated = config.validate().unwrap();
354        assert_eq!(validated.org_directory, temp_dir.path().to_str().unwrap());
355    }
356
357    #[test]
358    fn test_validate_nonexistent_directory() {
359        let config = OrgConfig {
360            org_directory: "/nonexistent/test/directory".to_string(),
361            ..OrgConfig::default()
362        };
363
364        let result = config.validate();
365        assert!(result.is_err());
366        match result.unwrap_err() {
367            OrgModeError::ConfigError(msg) => {
368                assert!(msg.contains("Root directory does not exist"));
369            }
370            _ => panic!("Expected ConfigError"),
371        }
372    }
373
374    #[test]
375    fn test_validate_non_directory_path() {
376        let temp_dir = tempdir().unwrap();
377        let file_path = temp_dir.path().join("not-a-dir.txt");
378        std::fs::write(&file_path, "test").unwrap();
379
380        let config = OrgConfig {
381            org_directory: file_path.to_str().unwrap().to_string(),
382            ..OrgConfig::default()
383        };
384
385        let result = config.validate();
386        assert!(result.is_err());
387        match result.unwrap_err() {
388            OrgModeError::ConfigError(msg) => {
389                assert!(msg.contains("not a directory"));
390            }
391            _ => panic!("Expected ConfigError"),
392        }
393    }
394
395    #[test]
396    #[serial]
397    fn test_load_from_toml_file() {
398        let temp_dir = tempdir().unwrap();
399        let path_str = test_utils::config::normalize_path(temp_dir.path());
400        let test_config = format!(
401            r#"
402[org]
403org_directory = "{path_str}"
404org_default_notes_file = "custom-notes.org"
405org_agenda_files = ["test1.org", "test2.org"]
406"#,
407        );
408
409        let config_path = test_utils::config::create_toml_config(&temp_dir, &test_config).unwrap();
410
411        let config = load_org_config(Some(config_path.to_str().unwrap()), None);
412        let config = config.unwrap();
413
414        assert_eq!(config.org_directory, path_str);
415        assert_eq!(config.org_default_notes_file, "custom-notes.org");
416        assert_eq!(config.org_agenda_files, vec!["test1.org", "test2.org"]);
417    }
418
419    #[test]
420    #[serial]
421    fn test_load_from_yaml_file() {
422        let temp_dir = tempdir().unwrap();
423        let path_str = test_utils::config::normalize_path(temp_dir.path());
424        let yaml_config = format!(
425            r#"
426org:
427  org_directory: "{path_str}"
428  org_default_notes_file: "yaml-notes.org"
429  org_agenda_files:
430    - "yaml1.org"
431    - "yaml2.org"
432"#
433        );
434
435        let yaml_path = test_utils::config::create_yaml_config(&temp_dir, &yaml_config).unwrap();
436        let config_dir = yaml_path.parent().unwrap();
437
438        let config = load_org_config(Some(config_dir.join("config").to_str().unwrap()), None);
439        let config = config.unwrap();
440
441        assert_eq!(config.org_directory, path_str);
442        assert_eq!(config.org_default_notes_file, "yaml-notes.org");
443        assert_eq!(config.org_agenda_files, vec!["yaml1.org", "yaml2.org"]);
444    }
445
446    #[test]
447    #[serial]
448    fn test_load_from_yml_file() {
449        let temp_dir = tempdir().unwrap();
450        let path_str = test_utils::config::normalize_path(temp_dir.path());
451        let yml_config = format!(
452            r#"
453org:
454  org_directory: "{path_str}"
455  org_default_notes_file: "yml-notes.org"
456logging:
457  level: "debug"
458  file: "/tmp/test.log"
459"#
460        );
461
462        let yml_path = test_utils::config::create_yml_config(&temp_dir, &yml_config).unwrap();
463        let config_dir = yml_path.parent().unwrap();
464
465        let config = load_org_config(Some(config_dir.join("config").to_str().unwrap()), None);
466        let config = config.unwrap();
467        assert_eq!(config.org_default_notes_file, "yml-notes.org");
468
469        let logging_config =
470            load_logging_config(Some(config_dir.join("config").to_str().unwrap()), None);
471        let logging_config = logging_config.unwrap();
472        assert_eq!(logging_config.level, "debug");
473        assert_eq!(logging_config.file, "/tmp/test.log");
474    }
475
476    #[test]
477    #[serial]
478    fn test_load_from_json_file() {
479        let temp_dir = tempdir().unwrap();
480        let path_str = test_utils::config::normalize_path(temp_dir.path());
481        let json_config = format!(
482            r#"{{
483  "org": {{
484    "org_directory": "{path_str}",
485    "org_default_notes_file": "json-notes.org",
486    "org_agenda_files": ["json1.org", "json2.org"]
487  }}
488}}"#
489        );
490
491        let json_path = test_utils::config::create_json_config(&temp_dir, &json_config).unwrap();
492        let config_dir = json_path.parent().unwrap();
493
494        let config = load_org_config(Some(config_dir.join("config").to_str().unwrap()), None);
495        let config = config.unwrap();
496
497        assert_eq!(config.org_directory, path_str);
498        assert_eq!(config.org_default_notes_file, "json-notes.org");
499        assert_eq!(config.org_agenda_files, vec!["json1.org", "json2.org"]);
500    }
501
502    #[test]
503    #[serial]
504    fn test_logging_config_file_extensions() {
505        let temp_dir = tempdir().unwrap();
506
507        let toml_config = r#"
508[logging]
509level = "trace"
510file = "/var/log/test.log"
511"#;
512
513        let toml_path = test_utils::config::create_toml_config(&temp_dir, toml_config).unwrap();
514
515        let config = load_logging_config(Some(toml_path.to_str().unwrap()), None);
516        let config = config.unwrap();
517
518        assert_eq!(config.level, "trace");
519        assert_eq!(config.file, "/var/log/test.log");
520    }
521
522    #[test]
523    fn test_unfinished_keywords_with_separator() {
524        let config = OrgConfig {
525            org_todo_keywords: vec![
526                "TODO".to_string(),
527                "IN_PROGRESS".to_string(),
528                "|".to_string(),
529                "DONE".to_string(),
530                "CANCELLED".to_string(),
531            ],
532            ..OrgConfig::default()
533        };
534
535        let unfinished = config.unfinished_keywords();
536        assert_eq!(unfinished, vec!["TODO", "IN_PROGRESS"]);
537    }
538
539    #[test]
540    fn test_unfinished_keywords_without_separator() {
541        let config = OrgConfig {
542            org_todo_keywords: vec![
543                "TODO".to_string(),
544                "IN_PROGRESS".to_string(),
545                "DONE".to_string(),
546            ],
547            ..OrgConfig::default()
548        };
549
550        let unfinished = config.unfinished_keywords();
551        assert_eq!(unfinished, vec!["TODO", "IN_PROGRESS"]);
552    }
553
554    #[test]
555    fn test_finished_keywords_with_separator() {
556        let config = OrgConfig {
557            org_todo_keywords: vec![
558                "TODO".to_string(),
559                "|".to_string(),
560                "DONE".to_string(),
561                "CANCELLED".to_string(),
562            ],
563            ..OrgConfig::default()
564        };
565
566        let finished = config.finished_keywords();
567        assert_eq!(finished, vec!["DONE", "CANCELLED"]);
568    }
569
570    #[test]
571    fn test_finished_keywords_without_separator() {
572        let config = OrgConfig {
573            org_todo_keywords: vec!["TODO".to_string(), "DONE".to_string()],
574            ..OrgConfig::default()
575        };
576
577        let finished = config.finished_keywords();
578        assert_eq!(finished, vec!["DONE"]);
579    }
580
581    #[test]
582    fn test_validate_empty_keywords() {
583        let temp_dir = tempdir().unwrap();
584        let config = OrgConfig {
585            org_directory: temp_dir.path().to_str().unwrap().to_string(),
586            org_todo_keywords: vec![],
587            ..OrgConfig::default()
588        };
589
590        let result = config.validate();
591        assert!(result.is_err());
592        match result.unwrap_err() {
593            OrgModeError::ConfigError(msg) => {
594                assert!(msg.contains("must contain at least two keywords"));
595            }
596            _ => panic!("Expected ConfigError"),
597        }
598    }
599
600    #[test]
601    fn test_validate_single_keyword() {
602        let temp_dir = tempdir().unwrap();
603        let config = OrgConfig {
604            org_directory: temp_dir.path().to_str().unwrap().to_string(),
605            org_todo_keywords: vec!["TODO".to_string()],
606            ..OrgConfig::default()
607        };
608
609        let result = config.validate();
610        assert!(result.is_err());
611        match result.unwrap_err() {
612            OrgModeError::ConfigError(msg) => {
613                assert!(msg.contains("must contain at least two keywords"));
614            }
615            _ => panic!("Expected ConfigError"),
616        }
617    }
618
619    #[test]
620    fn test_validate_multiple_separators() {
621        let temp_dir = tempdir().unwrap();
622        let config = OrgConfig {
623            org_directory: temp_dir.path().to_str().unwrap().to_string(),
624            org_todo_keywords: vec![
625                "TODO".to_string(),
626                "|".to_string(),
627                "DONE".to_string(),
628                "|".to_string(),
629                "CANCELLED".to_string(),
630            ],
631            ..OrgConfig::default()
632        };
633
634        let result = config.validate();
635        assert!(result.is_err());
636        match result.unwrap_err() {
637            OrgModeError::ConfigError(msg) => {
638                assert!(msg.contains("Multiple '|' separators"));
639            }
640            _ => panic!("Expected ConfigError"),
641        }
642    }
643
644    #[test]
645    fn test_validate_separator_at_beginning() {
646        let temp_dir = tempdir().unwrap();
647        let config = OrgConfig {
648            org_directory: temp_dir.path().to_str().unwrap().to_string(),
649            org_todo_keywords: vec!["|".to_string(), "DONE".to_string()],
650            ..OrgConfig::default()
651        };
652
653        let result = config.validate();
654        assert!(result.is_err());
655        match result.unwrap_err() {
656            OrgModeError::ConfigError(msg) => {
657                assert!(msg.contains("cannot be at the beginning"));
658            }
659            _ => panic!("Expected ConfigError"),
660        }
661    }
662
663    #[test]
664    fn test_validate_separator_at_end() {
665        let temp_dir = tempdir().unwrap();
666        let config = OrgConfig {
667            org_directory: temp_dir.path().to_str().unwrap().to_string(),
668            org_todo_keywords: vec!["TODO".to_string(), "|".to_string()],
669            ..OrgConfig::default()
670        };
671
672        let result = config.validate();
673        assert!(result.is_err());
674        match result.unwrap_err() {
675            OrgModeError::ConfigError(msg) => {
676                assert!(msg.contains("cannot be at the end"));
677            }
678            _ => panic!("Expected ConfigError"),
679        }
680    }
681
682    #[test]
683    fn test_validate_only_separator() {
684        let temp_dir = tempdir().unwrap();
685        let config = OrgConfig {
686            org_directory: temp_dir.path().to_str().unwrap().to_string(),
687            org_todo_keywords: vec!["|".to_string()],
688            ..OrgConfig::default()
689        };
690
691        let result = config.validate();
692        assert!(result.is_err());
693        match result.unwrap_err() {
694            OrgModeError::ConfigError(msg) => {
695                assert!(msg.contains("must contain at least two keywords"));
696            }
697            _ => panic!("Expected ConfigError"),
698        }
699    }
700
701    #[test]
702    fn test_validate_valid_keywords_with_separator() {
703        let temp_dir = tempdir().unwrap();
704        let config = OrgConfig {
705            org_directory: temp_dir.path().to_str().unwrap().to_string(),
706            org_todo_keywords: vec!["TODO".to_string(), "|".to_string(), "DONE".to_string()],
707            ..OrgConfig::default()
708        };
709
710        let result = config.validate();
711        assert!(result.is_ok());
712    }
713
714    #[test]
715    fn test_validate_valid_keywords_without_separator() {
716        let temp_dir = tempdir().unwrap();
717        let config = OrgConfig {
718            org_directory: temp_dir.path().to_str().unwrap().to_string(),
719            org_todo_keywords: vec!["TODO".to_string(), "DONE".to_string()],
720            ..OrgConfig::default()
721        };
722
723        let result = config.validate();
724        assert!(result.is_ok());
725    }
726
727    #[test]
728    fn test_validate_multiple_unfinished_and_finished() {
729        let temp_dir = tempdir().unwrap();
730        let config = OrgConfig {
731            org_directory: temp_dir.path().to_str().unwrap().to_string(),
732            org_todo_keywords: vec![
733                "TODO".to_string(),
734                "IN_PROGRESS".to_string(),
735                "|".to_string(),
736                "DONE".to_string(),
737                "CANCELLED".to_string(),
738            ],
739            ..OrgConfig::default()
740        };
741
742        let result = config.validate();
743        assert!(result.is_ok());
744    }
745}