Skip to main content

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