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 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 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}