Skip to main content

things3_core/
config.rs

1//! Configuration management for Things 3 integration
2
3use crate::error::{Result, ThingsError};
4use std::path::{Path, PathBuf};
5
6/// Configuration for Things 3 database access
7#[derive(Debug, Clone)]
8pub struct ThingsConfig {
9    /// Path to the Things 3 database
10    pub database_path: PathBuf,
11    /// Whether to use the default database path if the specified path doesn't exist
12    pub fallback_to_default: bool,
13}
14
15impl ThingsConfig {
16    /// Create a new configuration with a custom database path
17    ///
18    /// # Arguments
19    /// * `database_path` - Path to the Things 3 database
20    /// * `fallback_to_default` - Whether to fall back to the default path if the specified path doesn't exist
21    #[must_use]
22    pub fn new<P: AsRef<Path>>(database_path: P, fallback_to_default: bool) -> Self {
23        Self {
24            database_path: database_path.as_ref().to_path_buf(),
25            fallback_to_default,
26        }
27    }
28
29    /// Create a configuration with the default database path
30    #[must_use]
31    pub fn with_default_path() -> Self {
32        Self {
33            database_path: Self::get_default_database_path(),
34            fallback_to_default: false,
35        }
36    }
37
38    /// Get the effective database path, falling back to default if needed
39    ///
40    /// # Errors
41    /// Returns `ThingsError::Message` if neither the specified path nor the default path exists
42    pub fn get_effective_database_path(&self) -> Result<PathBuf> {
43        // Check if the specified path exists
44        if self.database_path.exists() {
45            return Ok(self.database_path.clone());
46        }
47
48        // If fallback is enabled, try the default path
49        if self.fallback_to_default {
50            let default_path = Self::get_default_database_path();
51            if default_path.exists() {
52                return Ok(default_path);
53            }
54        }
55
56        Err(ThingsError::configuration(format!(
57            "Database not found at {} and fallback is {}",
58            self.database_path.display(),
59            if self.fallback_to_default {
60                "enabled but default path also not found"
61            } else {
62                "disabled"
63            }
64        )))
65    }
66
67    /// Get the default Things 3 database path
68    #[must_use]
69    pub fn get_default_database_path() -> PathBuf {
70        let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
71        PathBuf::from(format!(
72            "{home}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-0Z0Z2/Things Database.thingsdatabase/main.sqlite"
73        ))
74    }
75
76    /// Create configuration from environment variables
77    ///
78    /// Reads the database path from `THINGS_DB_PATH` (preferred) or the legacy
79    /// `THINGS_DATABASE_PATH`, and the fallback flag from `THINGS_FALLBACK_TO_DEFAULT`.
80    #[must_use]
81    pub fn from_env() -> Self {
82        let database_path = match std::env::var("THINGS_DB_PATH") {
83            Ok(v) => PathBuf::from(v),
84            Err(_) => match std::env::var("THINGS_DATABASE_PATH") {
85                Ok(v) => {
86                    tracing::warn!(
87                        "THINGS_DATABASE_PATH is deprecated; please use THINGS_DB_PATH instead"
88                    );
89                    PathBuf::from(v)
90                }
91                Err(_) => Self::get_default_database_path(),
92            },
93        };
94
95        let fallback_to_default = if let Ok(v) = std::env::var("THINGS_FALLBACK_TO_DEFAULT") {
96            let lower = v.to_lowercase();
97            match lower.as_str() {
98                "true" | "1" | "yes" | "on" => true,
99                _ => false, // Default to false for invalid values
100            }
101        } else {
102            true
103        };
104
105        Self::new(database_path, fallback_to_default)
106    }
107
108    /// Create configuration for testing with a temporary database
109    ///
110    /// # Errors
111    /// Returns `ThingsError::Io` if the temporary file cannot be created
112    pub fn for_testing() -> Result<Self> {
113        use tempfile::NamedTempFile;
114        let temp_file = NamedTempFile::new()?;
115        let db_path = temp_file.path().to_path_buf();
116        Ok(Self::new(db_path, false))
117    }
118}
119
120impl Default for ThingsConfig {
121    fn default() -> Self {
122        Self::with_default_path()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serial_test::serial;
130    use tempfile::NamedTempFile;
131
132    #[test]
133    fn test_config_creation() {
134        let config = ThingsConfig::new("/path/to/db.sqlite", true);
135        assert_eq!(config.database_path, PathBuf::from("/path/to/db.sqlite"));
136        assert!(config.fallback_to_default);
137    }
138
139    #[test]
140    fn test_default_config() {
141        let config = ThingsConfig::default();
142        assert!(config
143            .database_path
144            .to_string_lossy()
145            .contains("Things Database.thingsdatabase"));
146        assert!(!config.fallback_to_default);
147    }
148
149    #[test]
150    #[serial]
151    fn test_config_from_env() {
152        let test_path = "/custom/path/db.sqlite";
153
154        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
155        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
156        let original_fallback = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
157
158        std::env::remove_var("THINGS_DB_PATH");
159        std::env::set_var("THINGS_DATABASE_PATH", test_path);
160        std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", "true");
161
162        let config = ThingsConfig::from_env();
163        let path_matches = config.database_path.as_os_str() == test_path;
164        let fallback_set = config.fallback_to_default;
165
166        if let Some(v) = original_db_path {
167            std::env::set_var("THINGS_DB_PATH", v);
168        }
169        if let Some(v) = original_legacy {
170            std::env::set_var("THINGS_DATABASE_PATH", v);
171        } else {
172            std::env::remove_var("THINGS_DATABASE_PATH");
173        }
174        if let Some(v) = original_fallback {
175            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", v);
176        } else {
177            std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
178        }
179
180        assert!(path_matches);
181        assert!(fallback_set);
182    }
183
184    #[test]
185    #[serial]
186    fn test_from_env_reads_things_db_path() {
187        let test_path = "/custom/path/via_db_path.sqlite";
188
189        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
190        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
191
192        std::env::remove_var("THINGS_DATABASE_PATH");
193        std::env::set_var("THINGS_DB_PATH", test_path);
194
195        let config = ThingsConfig::from_env();
196        assert_eq!(config.database_path, PathBuf::from(test_path));
197
198        if let Some(v) = original_db_path {
199            std::env::set_var("THINGS_DB_PATH", v);
200        } else {
201            std::env::remove_var("THINGS_DB_PATH");
202        }
203        if let Some(v) = original_legacy {
204            std::env::set_var("THINGS_DATABASE_PATH", v);
205        }
206    }
207
208    #[test]
209    #[serial]
210    fn test_from_env_prefers_things_db_path_over_legacy() {
211        let new_path = "/new/preferred.sqlite";
212        let legacy_path = "/legacy/ignored.sqlite";
213
214        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
215        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
216
217        std::env::set_var("THINGS_DB_PATH", new_path);
218        std::env::set_var("THINGS_DATABASE_PATH", legacy_path);
219
220        let config = ThingsConfig::from_env();
221        assert_eq!(config.database_path, PathBuf::from(new_path));
222
223        if let Some(v) = original_db_path {
224            std::env::set_var("THINGS_DB_PATH", v);
225        } else {
226            std::env::remove_var("THINGS_DB_PATH");
227        }
228        if let Some(v) = original_legacy {
229            std::env::set_var("THINGS_DATABASE_PATH", v);
230        } else {
231            std::env::remove_var("THINGS_DATABASE_PATH");
232        }
233    }
234
235    #[test]
236    #[serial]
237    fn test_from_env_falls_back_to_legacy_things_database_path() {
238        let legacy_path = "/legacy/only.sqlite";
239
240        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
241        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
242
243        std::env::remove_var("THINGS_DB_PATH");
244        std::env::set_var("THINGS_DATABASE_PATH", legacy_path);
245
246        let config = ThingsConfig::from_env();
247        assert_eq!(config.database_path, PathBuf::from(legacy_path));
248
249        if let Some(v) = original_db_path {
250            std::env::set_var("THINGS_DB_PATH", v);
251        }
252        if let Some(v) = original_legacy {
253            std::env::set_var("THINGS_DATABASE_PATH", v);
254        } else {
255            std::env::remove_var("THINGS_DATABASE_PATH");
256        }
257    }
258
259    #[test]
260    fn test_effective_database_path() {
261        // Test with existing file
262        let temp_file = NamedTempFile::new().unwrap();
263        let db_path = temp_file.path();
264        let config = ThingsConfig::new(db_path, false);
265
266        let effective_path = config.get_effective_database_path().unwrap();
267        assert_eq!(effective_path, db_path);
268    }
269
270    #[test]
271    fn test_fallback_behavior() {
272        // Test fallback when it should succeed (default path exists)
273        let config = ThingsConfig::new("/nonexistent/path.sqlite", true);
274        let result = config.get_effective_database_path();
275
276        // If the default path exists, fallback should succeed
277        if ThingsConfig::get_default_database_path().exists() {
278            assert!(result.is_ok());
279            assert_eq!(result.unwrap(), ThingsConfig::get_default_database_path());
280        } else {
281            // If default path doesn't exist, should get an error
282            assert!(result.is_err());
283        }
284    }
285
286    #[test]
287    fn test_fallback_disabled() {
288        // Test when fallback is disabled - should always fail if path doesn't exist
289        let config = ThingsConfig::new("/nonexistent/path.sqlite", false);
290        let result = config.get_effective_database_path();
291
292        // Should always fail when fallback is disabled and path doesn't exist
293        assert!(result.is_err());
294    }
295
296    #[test]
297    fn test_config_with_fallback_enabled() {
298        let config = ThingsConfig::new("/nonexistent/path", true);
299        assert_eq!(config.database_path, PathBuf::from("/nonexistent/path"));
300        assert!(config.fallback_to_default);
301    }
302
303    #[test]
304    #[serial]
305    fn test_config_from_env_with_custom_path() {
306        let test_path = "/test/env/custom/path";
307
308        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
309        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
310        let original_fallback = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
311
312        std::env::remove_var("THINGS_DB_PATH");
313        std::env::set_var("THINGS_DATABASE_PATH", test_path);
314        std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", "false");
315
316        let config = ThingsConfig::from_env();
317        let path_matches = config.database_path.as_os_str() == test_path;
318        let fallback_off = !config.fallback_to_default;
319
320        if let Some(v) = original_db_path {
321            std::env::set_var("THINGS_DB_PATH", v);
322        }
323        if let Some(v) = original_legacy {
324            std::env::set_var("THINGS_DATABASE_PATH", v);
325        } else {
326            std::env::remove_var("THINGS_DATABASE_PATH");
327        }
328        if let Some(v) = original_fallback {
329            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", v);
330        } else {
331            std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
332        }
333
334        assert!(path_matches);
335        assert!(fallback_off);
336    }
337
338    #[test]
339    #[serial]
340    fn test_config_from_env_with_fallback() {
341        let test_id = std::thread::current().id();
342        let test_path = format!("/test/env/path/fallback_{test_id:?}");
343
344        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
345        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
346        let original_fallback = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
347
348        std::env::remove_var("THINGS_DB_PATH");
349        std::env::set_var("THINGS_DATABASE_PATH", &test_path);
350        std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", "true");
351
352        let config = ThingsConfig::from_env();
353        let path_matches =
354            config.database_path.to_string_lossy() == PathBuf::from(&test_path).to_string_lossy();
355        let fallback_set = config.fallback_to_default;
356
357        if let Some(v) = original_db_path {
358            std::env::set_var("THINGS_DB_PATH", v);
359        }
360        if let Some(v) = original_legacy {
361            std::env::set_var("THINGS_DATABASE_PATH", v);
362        } else {
363            std::env::remove_var("THINGS_DATABASE_PATH");
364        }
365        if let Some(v) = original_fallback {
366            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", v);
367        } else {
368            std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
369        }
370
371        assert!(path_matches);
372        assert!(fallback_set);
373    }
374
375    #[test]
376    #[serial]
377    fn test_config_from_env_with_invalid_fallback() {
378        let test_id = std::thread::current().id();
379        let test_path = format!("/test/env/path/invalid_{test_id:?}");
380
381        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
382        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
383        let original_fallback = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
384
385        std::env::remove_var("THINGS_DB_PATH");
386        std::env::set_var("THINGS_DATABASE_PATH", &test_path);
387        std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", "invalid");
388
389        let config = ThingsConfig::from_env();
390        let path_matches =
391            config.database_path.to_string_lossy() == PathBuf::from(&test_path).to_string_lossy();
392        let fallback_off = !config.fallback_to_default;
393
394        if let Some(v) = original_db_path {
395            std::env::set_var("THINGS_DB_PATH", v);
396        }
397        if let Some(v) = original_legacy {
398            std::env::set_var("THINGS_DATABASE_PATH", v);
399        } else {
400            std::env::remove_var("THINGS_DATABASE_PATH");
401        }
402        if let Some(v) = original_fallback {
403            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", v);
404        } else {
405            std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
406        }
407
408        assert!(path_matches);
409        assert!(fallback_off);
410    }
411
412    #[test]
413    fn test_config_debug_formatting() {
414        let config = ThingsConfig::new("/test/path", true);
415        let debug_str = format!("{config:?}");
416        assert!(debug_str.contains("/test/path"));
417        assert!(debug_str.contains("true"));
418    }
419
420    #[test]
421    fn test_config_clone() {
422        let config1 = ThingsConfig::new("/test/path", true);
423        let config2 = config1.clone();
424
425        assert_eq!(config1.database_path, config2.database_path);
426        assert_eq!(config1.fallback_to_default, config2.fallback_to_default);
427    }
428
429    #[test]
430    fn test_config_with_different_path_types() {
431        // Test with relative path
432        let config = ThingsConfig::new("relative/path", false);
433        assert_eq!(config.database_path, PathBuf::from("relative/path"));
434
435        // Test with absolute path
436        let config = ThingsConfig::new("/absolute/path", false);
437        assert_eq!(config.database_path, PathBuf::from("/absolute/path"));
438
439        // Test with current directory
440        let config = ThingsConfig::new(".", false);
441        assert_eq!(config.database_path, PathBuf::from("."));
442    }
443
444    #[test]
445    fn test_config_edge_cases() {
446        // Test with empty string path
447        let config = ThingsConfig::new("", false);
448        assert_eq!(config.database_path, PathBuf::from(""));
449
450        // Test with very long path
451        let long_path = "/".repeat(1000);
452        let config = ThingsConfig::new(&long_path, false);
453        assert_eq!(config.database_path, PathBuf::from(&long_path));
454    }
455
456    #[test]
457    fn test_get_default_database_path() {
458        let default_path = ThingsConfig::get_default_database_path();
459
460        // Should be a valid path (may or may not exist)
461        assert!(!default_path.to_string_lossy().is_empty());
462
463        // Should be a reasonable path (may or may not contain "Things3" depending on system)
464        assert!(!default_path.to_string_lossy().is_empty());
465    }
466
467    #[test]
468    fn test_for_testing() {
469        // Test that for_testing creates a valid config
470        let config = ThingsConfig::for_testing().unwrap();
471
472        // Should have a valid database path
473        assert!(!config.database_path.to_string_lossy().is_empty());
474
475        // Should not have fallback enabled (as specified in the method)
476        assert!(!config.fallback_to_default);
477
478        // The path should be a valid file path (even if it doesn't exist yet)
479        assert!(config.database_path.parent().is_some());
480    }
481
482    #[test]
483    fn test_with_default_path() {
484        let config = ThingsConfig::with_default_path();
485
486        // Should use the default database path
487        assert_eq!(
488            config.database_path,
489            ThingsConfig::get_default_database_path()
490        );
491
492        // Should not have fallback enabled
493        assert!(!config.fallback_to_default);
494    }
495
496    #[test]
497    fn test_effective_database_path_fallback_enabled_but_default_missing() {
498        // Test the error case when fallback is enabled but default path doesn't exist
499        let config = ThingsConfig::new("/nonexistent/path.sqlite", true);
500        let result = config.get_effective_database_path();
501
502        // Check if the default path exists - if it does, fallback will succeed
503        let default_path = ThingsConfig::get_default_database_path();
504        if default_path.exists() {
505            // If default path exists, fallback should succeed
506            assert!(result.is_ok());
507            assert_eq!(result.unwrap(), default_path);
508        } else {
509            // If default path doesn't exist, should get an error
510            assert!(result.is_err());
511            let error = result.unwrap_err();
512            match error {
513                ThingsError::Configuration { message } => {
514                    assert!(message.contains("Database not found at"));
515                    assert!(message.contains("fallback is enabled but default path also not found"));
516                }
517                _ => panic!("Expected Configuration error, got: {error:?}"),
518            }
519        }
520    }
521
522    #[test]
523    fn test_effective_database_path_fallback_disabled_error_message() {
524        // Test the error case when fallback is disabled
525        let config = ThingsConfig::new("/nonexistent/path.sqlite", false);
526        let result = config.get_effective_database_path();
527
528        // Should get an error with specific message about fallback being disabled
529        assert!(result.is_err());
530        let error = result.unwrap_err();
531        match error {
532            ThingsError::Configuration { message } => {
533                assert!(message.contains("Database not found at"));
534                assert!(message.contains("fallback is disabled"));
535            }
536            _ => panic!("Expected Configuration error, got: {error:?}"),
537        }
538    }
539
540    #[test]
541    #[serial]
542    fn test_from_env_without_variables() {
543        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
544        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
545        let original_fallback = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
546
547        std::env::remove_var("THINGS_DB_PATH");
548        std::env::remove_var("THINGS_DATABASE_PATH");
549        std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
550
551        let config = ThingsConfig::from_env();
552        let expected_path = ThingsConfig::get_default_database_path();
553
554        if let Some(v) = original_db_path {
555            std::env::set_var("THINGS_DB_PATH", v);
556        }
557        if let Some(v) = original_legacy {
558            std::env::set_var("THINGS_DATABASE_PATH", v);
559        }
560        if let Some(v) = original_fallback {
561            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", v);
562        }
563
564        assert_eq!(config.database_path, expected_path);
565        assert!(config.fallback_to_default);
566    }
567
568    #[test]
569    fn test_from_env_fallback_parsing() {
570        // Test various fallback value parsing without environment variable conflicts
571        let test_cases = vec![
572            ("true", true),
573            ("TRUE", true),
574            ("True", true),
575            ("1", true),
576            ("yes", true),
577            ("YES", true),
578            ("on", true),
579            ("ON", true),
580            ("false", false),
581            ("FALSE", false),
582            ("0", false),
583            ("no", false),
584            ("off", false),
585            ("invalid", false),
586            ("", false),
587        ];
588
589        for (value, expected) in test_cases {
590            // Create a config manually to test the parsing logic
591            let fallback = value.to_lowercase();
592            let result =
593                fallback == "true" || fallback == "1" || fallback == "yes" || fallback == "on";
594            assert_eq!(result, expected, "Failed for value: '{value}'");
595        }
596    }
597
598    #[test]
599    fn test_default_trait_implementation() {
600        // Test that Default trait works correctly
601        let config = ThingsConfig::default();
602
603        // Should be equivalent to with_default_path
604        let expected = ThingsConfig::with_default_path();
605        assert_eq!(config.database_path, expected.database_path);
606        assert_eq!(config.fallback_to_default, expected.fallback_to_default);
607    }
608
609    #[test]
610    fn test_config_with_path_reference() {
611        // Test that the config works with different path reference types
612        let path_str = "/test/path/string";
613        let path_buf = PathBuf::from("/test/path/buf");
614
615        let config1 = ThingsConfig::new(path_str, true);
616        let config2 = ThingsConfig::new(&path_buf, false);
617
618        assert_eq!(config1.database_path, PathBuf::from(path_str));
619        assert_eq!(config2.database_path, path_buf);
620    }
621
622    #[test]
623    fn test_effective_database_path_existing_file() {
624        // Test when the specified path exists
625        let temp_file = NamedTempFile::new().unwrap();
626        let db_path = temp_file.path().to_path_buf();
627        let config = ThingsConfig::new(&db_path, false);
628
629        let effective_path = config.get_effective_database_path().unwrap();
630        assert_eq!(effective_path, db_path);
631    }
632
633    #[test]
634    fn test_effective_database_path_fallback_success() {
635        // Test successful fallback when default path exists
636        let default_path = ThingsConfig::get_default_database_path();
637
638        // Only test if default path actually exists
639        if default_path.exists() {
640            let config = ThingsConfig::new("/nonexistent/path.sqlite", true);
641            let effective_path = config.get_effective_database_path().unwrap();
642            assert_eq!(effective_path, default_path);
643        }
644    }
645
646    #[test]
647    fn test_config_debug_implementation() {
648        // Test that Debug trait is properly implemented
649        let config = ThingsConfig::new("/test/debug/path", true);
650        let debug_str = format!("{config:?}");
651
652        // Should contain both fields
653        assert!(debug_str.contains("database_path"));
654        assert!(debug_str.contains("fallback_to_default"));
655        assert!(debug_str.contains("/test/debug/path"));
656        assert!(debug_str.contains("true"));
657    }
658
659    #[test]
660    fn test_config_clone_implementation() {
661        // Test that Clone trait works correctly
662        let config1 = ThingsConfig::new("/test/clone/path", true);
663        let config2 = config1.clone();
664
665        // Should be equal
666        assert_eq!(config1.database_path, config2.database_path);
667        assert_eq!(config1.fallback_to_default, config2.fallback_to_default);
668
669        // Should be independent (modifying one doesn't affect the other)
670        let config3 = ThingsConfig::new("/different/path", false);
671        assert_ne!(config1.database_path, config3.database_path);
672        assert_ne!(config1.fallback_to_default, config3.fallback_to_default);
673    }
674
675    #[test]
676    fn test_get_default_database_path_format() {
677        // Test that the default path has the expected format
678        let default_path = ThingsConfig::get_default_database_path();
679        let path_str = default_path.to_string_lossy();
680
681        // Should contain the expected macOS Things 3 path components
682        assert!(path_str.contains("Library"));
683        assert!(path_str.contains("Group Containers"));
684        assert!(path_str.contains("JLMPQHK86H.com.culturedcode.ThingsMac"));
685        assert!(path_str.contains("ThingsData-0Z0Z2"));
686        assert!(path_str.contains("Things Database.thingsdatabase"));
687        assert!(path_str.contains("main.sqlite"));
688    }
689
690    #[test]
691    fn test_home_env_var_fallback() {
692        // Test that the default path handles missing HOME environment variable
693        // This is tricky to test without affecting the environment, so we'll test the logic indirectly
694        let default_path = ThingsConfig::get_default_database_path();
695        let path_str = default_path.to_string_lossy();
696
697        // Should start with either a valid home path or "~" fallback
698        assert!(path_str.starts_with('/') || path_str.starts_with('~'));
699    }
700
701    #[test]
702    fn test_config_effective_database_path_existing_file() {
703        // Create a temporary file for testing
704        let temp_dir = std::env::temp_dir();
705        let temp_file = temp_dir.join("test_db.sqlite");
706        std::fs::File::create(&temp_file).unwrap();
707
708        let config = ThingsConfig::new(temp_file.clone(), false);
709        let effective_path = config.get_effective_database_path().unwrap();
710        assert_eq!(effective_path, temp_file);
711
712        // Clean up
713        std::fs::remove_file(&temp_file).unwrap();
714    }
715
716    #[test]
717    fn test_config_effective_database_path_fallback_success() {
718        // Create a temporary file to simulate an existing database
719        let temp_dir = std::env::temp_dir();
720        let temp_file = temp_dir.join("test_database.sqlite");
721        std::fs::File::create(&temp_file).unwrap();
722
723        // Create a config with the temp file as the database path
724        let config = ThingsConfig::new(temp_file.clone(), true);
725
726        let effective_path = config.get_effective_database_path().unwrap();
727
728        // Should return the existing file path
729        assert_eq!(effective_path, temp_file);
730
731        // Clean up
732        std::fs::remove_file(&temp_file).unwrap();
733    }
734
735    #[test]
736    fn test_config_effective_database_path_fallback_disabled_error_message() {
737        let non_existent_path = PathBuf::from("/nonexistent/path/db.sqlite");
738        let config = ThingsConfig::new(non_existent_path, false);
739
740        // This should return an error when fallback is disabled and path doesn't exist
741        let result = config.get_effective_database_path();
742        assert!(result.is_err());
743        let error = result.unwrap_err();
744        assert!(matches!(error, ThingsError::Configuration { .. }));
745    }
746
747    #[test]
748    #[serial]
749    fn test_config_effective_database_path_fallback_enabled_but_default_missing() {
750        // Temporarily change HOME to a non-existent directory to ensure default path doesn't exist
751        let original_home = std::env::var("HOME").ok();
752        std::env::set_var("HOME", "/nonexistent/home");
753
754        // Create a config with a non-existent path and fallback enabled
755        let non_existent_path = PathBuf::from("/nonexistent/path/db.sqlite");
756        let config = ThingsConfig::new(non_existent_path, true);
757
758        // This should return an error when both the configured path and default path don't exist
759        let result = config.get_effective_database_path();
760
761        // Restore original HOME
762        if let Some(home) = original_home {
763            std::env::set_var("HOME", home);
764        } else {
765            std::env::remove_var("HOME");
766        }
767
768        assert!(
769            result.is_err(),
770            "Expected error when both configured and default paths don't exist"
771        );
772        let error = result.unwrap_err();
773        assert!(matches!(error, ThingsError::Configuration { .. }));
774
775        // Check the error message contains the expected text
776        let error_message = format!("{error}");
777        assert!(error_message.contains("Database not found at /nonexistent/path/db.sqlite"));
778        assert!(error_message.contains("fallback is enabled but default path also not found"));
779    }
780
781    #[test]
782    fn test_config_fallback_behavior() {
783        let path = PathBuf::from("/test/path/db.sqlite");
784
785        // Test with fallback enabled
786        let config_with_fallback = ThingsConfig::new(path.clone(), true);
787        assert!(config_with_fallback.fallback_to_default);
788
789        // Test with fallback disabled
790        let config_without_fallback = ThingsConfig::new(path, false);
791        assert!(!config_without_fallback.fallback_to_default);
792    }
793
794    #[test]
795    fn test_config_fallback_disabled() {
796        let path = PathBuf::from("/test/path/db.sqlite");
797        let config = ThingsConfig::new(path, false);
798        assert!(!config.fallback_to_default);
799    }
800
801    #[test]
802    #[serial]
803    fn test_config_from_env_without_variables() {
804        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
805        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
806        let original_fallback = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
807
808        std::env::remove_var("THINGS_DB_PATH");
809        std::env::remove_var("THINGS_DATABASE_PATH");
810        std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
811
812        let config = ThingsConfig::from_env();
813        let contains_default = config
814            .database_path
815            .to_string_lossy()
816            .contains("Things Database.thingsdatabase");
817
818        if let Some(v) = original_db_path {
819            std::env::set_var("THINGS_DB_PATH", v);
820        }
821        if let Some(v) = original_legacy {
822            std::env::set_var("THINGS_DATABASE_PATH", v);
823        }
824        if let Some(v) = original_fallback {
825            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", v);
826        }
827
828        assert!(contains_default);
829    }
830
831    #[test]
832    fn test_config_from_env_fallback_parsing() {
833        // Test the parsing logic directly without relying on environment variables
834        // This avoids potential race conditions or environment variable isolation issues in CI
835
836        let test_cases = vec![
837            ("true", true),
838            ("false", false),
839            ("1", true),
840            ("0", false),
841            ("yes", true),
842            ("no", false),
843            ("invalid", false),
844        ];
845
846        for (value, expected) in test_cases {
847            // Test the parsing logic directly
848            let lower = value.to_lowercase();
849            let result = match lower.as_str() {
850                "true" | "1" | "yes" | "on" => true,
851                _ => false, // Default to false for invalid values
852            };
853
854            assert_eq!(
855                result, expected,
856                "Failed for value: '{value}', expected: {expected}, got: {result}"
857            );
858        }
859    }
860
861    #[test]
862    fn test_config_for_testing() {
863        let result = ThingsConfig::for_testing();
864        assert!(result.is_ok(), "Should create test config successfully");
865
866        let config = result.unwrap();
867        assert!(
868            !config.fallback_to_default,
869            "Test config should have fallback disabled"
870        );
871
872        // Test config should use a temporary database path
873        let path_str = config.database_path.to_string_lossy();
874        assert!(
875            path_str.contains("tmp") || !path_str.is_empty(),
876            "Test config should use a temporary path"
877        );
878    }
879
880    #[test]
881    fn test_config_effective_database_path_error_cases() {
882        // Test with non-existent path and fallback disabled
883        let non_existent_path = PathBuf::from("/absolutely/non/existent/path/database.db");
884        let config = ThingsConfig::new(&non_existent_path, false);
885
886        let result = config.get_effective_database_path();
887        assert!(
888            result.is_err(),
889            "Should fail when file doesn't exist and fallback is disabled"
890        );
891
892        let error_msg = result.unwrap_err().to_string();
893        assert!(
894            error_msg.contains("fallback is disabled"),
895            "Error message should mention fallback is disabled"
896        );
897    }
898
899    #[test]
900    fn test_config_effective_database_path_with_existing_file() {
901        // Create a temporary file to test with
902        let temp_file = NamedTempFile::new().unwrap();
903        let temp_path = temp_file.path().to_path_buf();
904
905        let config = ThingsConfig::new(&temp_path, false);
906        let effective_path = config.get_effective_database_path().unwrap();
907
908        assert_eq!(effective_path, temp_path);
909    }
910
911    #[test]
912    fn test_config_get_default_database_path_format() {
913        let path = ThingsConfig::get_default_database_path();
914        let path_str = path.to_string_lossy();
915
916        // Test the specific format of the path
917        assert!(
918            path_str.contains("JLMPQHK86H.com.culturedcode.ThingsMac"),
919            "Should contain the correct container identifier"
920        );
921        assert!(
922            path_str.contains("ThingsData-0Z0Z2"),
923            "Should contain the correct data directory"
924        );
925        assert!(
926            path_str.contains("Things Database.thingsdatabase"),
927            "Should contain Things database directory"
928        );
929        assert!(
930            path_str.contains("main.sqlite"),
931            "Should contain main.sqlite file"
932        );
933    }
934
935    #[test]
936    fn test_config_with_different_path_types_comprehensive() {
937        // Test with string path
938        let string_path = "/test/path/db.sqlite";
939        let config1 = ThingsConfig::new(string_path, false);
940        assert_eq!(config1.database_path, PathBuf::from(string_path));
941        assert!(!config1.fallback_to_default);
942
943        // Test with PathBuf
944        let pathbuf_path = PathBuf::from("/another/test/path.db");
945        let config2 = ThingsConfig::new(&pathbuf_path, true);
946        assert_eq!(config2.database_path, pathbuf_path);
947        assert!(config2.fallback_to_default);
948    }
949
950    #[test]
951    fn test_config_from_env_edge_cases() {
952        // Test the parsing logic for edge cases
953        let test_cases = vec![
954            ("true", true),
955            ("TRUE", true),
956            ("True", true),
957            ("1", true),
958            ("yes", true),
959            ("YES", true),
960            ("on", true),
961            ("ON", true),
962            ("false", false),
963            ("FALSE", false),
964            ("0", false),
965            ("no", false),
966            ("off", false),
967            ("invalid", false),
968            ("", false),
969            ("random_string", false),
970        ];
971
972        for (value, expected) in test_cases {
973            // Test the parsing logic directly (matches the implementation)
974            let lower = value.to_lowercase();
975            let result = matches!(lower.as_str(), "true" | "1" | "yes" | "on");
976            assert_eq!(result, expected, "Failed for value: '{value}'");
977        }
978    }
979
980    #[test]
981    #[serial]
982    fn test_config_from_env_fallback_parsing_with_env_vars() {
983        // Save original value
984        let original_value = std::env::var("THINGS_FALLBACK_TO_DEFAULT").ok();
985
986        // Test different fallback values with actual environment variables
987        let test_cases = vec![
988            ("true", true),
989            ("false", false),
990            ("1", true),
991            ("0", false),
992            ("yes", true),
993            ("no", false),
994            ("invalid", false),
995        ];
996
997        for (value, expected) in test_cases {
998            // Clear any existing value first
999            std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
1000
1001            // Set the test value
1002            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", value);
1003
1004            // Verify the environment variable is set correctly
1005            let env_value = std::env::var("THINGS_FALLBACK_TO_DEFAULT")
1006                .unwrap_or_else(|_| "NOT_SET".to_string());
1007            println!("Environment variable set to: '{env_value}'");
1008
1009            // Double-check the environment variable is still set right before calling from_env
1010            let env_value_check = std::env::var("THINGS_FALLBACK_TO_DEFAULT")
1011                .unwrap_or_else(|_| "NOT_SET".to_string());
1012            println!("Environment variable check before from_env: '{env_value_check}'");
1013
1014            let config = ThingsConfig::from_env();
1015
1016            // Debug: print what we're testing
1017            println!(
1018                "Testing value: '{}', expected: {}, got: {}",
1019                value, expected, config.fallback_to_default
1020            );
1021
1022            assert_eq!(
1023                config.fallback_to_default, expected,
1024                "Failed for value: '{}', expected: {}, got: {}",
1025                value, expected, config.fallback_to_default
1026            );
1027        }
1028
1029        // Restore original value
1030        if let Some(original) = original_value {
1031            std::env::set_var("THINGS_FALLBACK_TO_DEFAULT", original);
1032        } else {
1033            std::env::remove_var("THINGS_FALLBACK_TO_DEFAULT");
1034        }
1035    }
1036
1037    #[test]
1038    #[serial]
1039    fn test_config_home_env_var_fallback() {
1040        // Snapshot all env vars from_env() reads, so we can assert the default
1041        // HOME-based path and restore cleanly even if prior serial tests leaked state.
1042        let original_home = std::env::var("HOME").ok();
1043        let original_db_path = std::env::var("THINGS_DB_PATH").ok();
1044        let original_legacy = std::env::var("THINGS_DATABASE_PATH").ok();
1045
1046        std::env::remove_var("THINGS_DB_PATH");
1047        std::env::remove_var("THINGS_DATABASE_PATH");
1048        std::env::set_var("HOME", "/test/home");
1049
1050        let config = ThingsConfig::from_env();
1051        let contains_default = config
1052            .database_path
1053            .to_string_lossy()
1054            .contains("Things Database.thingsdatabase");
1055
1056        if let Some(home) = original_home {
1057            std::env::set_var("HOME", home);
1058        } else {
1059            std::env::remove_var("HOME");
1060        }
1061        if let Some(v) = original_db_path {
1062            std::env::set_var("THINGS_DB_PATH", v);
1063        }
1064        if let Some(v) = original_legacy {
1065            std::env::set_var("THINGS_DATABASE_PATH", v);
1066        }
1067
1068        assert!(contains_default);
1069    }
1070
1071    #[test]
1072    fn test_config_with_default_path() {
1073        let config = ThingsConfig::with_default_path();
1074        assert!(config
1075            .database_path
1076            .to_string_lossy()
1077            .contains("Things Database.thingsdatabase"));
1078        assert!(!config.fallback_to_default);
1079    }
1080}