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}
23
24#[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 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
90pub fn default_config_path() -> Result<PathBuf, OrgModeError> {
92 Ok(default_config_dir()?.join("config"))
93}
94
95pub 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
116pub 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
153pub 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
176pub 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}