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