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#[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#[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 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
145pub fn default_config_path() -> Result<PathBuf, OrgModeError> {
147 Ok(default_config_dir()?.join("config"))
148}
149
150pub 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
171pub 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
208pub 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
231pub 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}