ccsync_core/config/
discovery.rs

1//! Configuration file discovery from multiple locations
2
3use std::path::{Path, PathBuf};
4
5use crate::error::Result;
6
7/// Configuration file locations in order of precedence
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ConfigFiles {
10    /// Config from CLI flag (highest precedence)
11    pub cli: Option<PathBuf>,
12    /// Project-local config (.ccsync.local)
13    pub local: Option<PathBuf>,
14    /// Project config (.ccsync)
15    pub project: Option<PathBuf>,
16    /// Global XDG config
17    pub global: Option<PathBuf>,
18}
19
20/// Config file discovery
21pub struct ConfigDiscovery;
22
23impl Default for ConfigDiscovery {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl ConfigDiscovery {
30    /// Create a new config discovery instance
31    #[must_use]
32    pub const fn new() -> Self {
33        Self
34    }
35
36    /// Discover all available configuration files
37    ///
38    /// Returns a `ConfigFiles` struct with paths to discovered configs.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if a CLI config path is specified but doesn't exist.
43    pub fn discover(cli_path: Option<&Path>) -> Result<ConfigFiles> {
44        let cli = if let Some(p) = cli_path {
45            if !p.exists() {
46                anyhow::bail!(
47                    "Config file specified via CLI does not exist: {}",
48                    p.display()
49                );
50            }
51            Some(p.to_path_buf())
52        } else {
53            None
54        };
55
56        let local = Self::find_file(".ccsync.local");
57        let project = Self::find_file(".ccsync");
58        let global = Self::find_global_config();
59
60        Ok(ConfigFiles {
61            cli,
62            local,
63            project,
64            global,
65        })
66    }
67
68    /// Find a config file in the current directory or parent directories
69    ///
70    /// Note: Does not follow symlinks for security reasons
71    fn find_file(name: &str) -> Option<PathBuf> {
72        let mut current = std::env::current_dir().ok()?;
73
74        loop {
75            let candidate = current.join(name);
76
77            // Use symlink_metadata to avoid following symlinks (security)
78            if let Ok(metadata) = candidate.symlink_metadata()
79                && metadata.is_file()
80            {
81                return Some(candidate);
82            }
83
84            // Move to parent directory
85            if !current.pop() {
86                break;
87            }
88        }
89
90        None
91    }
92
93    /// Find global config in XDG config directory
94    ///
95    /// Note: Does not follow symlinks for security reasons
96    fn find_global_config() -> Option<PathBuf> {
97        let config_dir = dirs::config_dir()?;
98        let global_config = config_dir.join("ccsync").join("config.toml");
99
100        // Use symlink_metadata to avoid following symlinks (security)
101        if let Ok(metadata) = global_config.symlink_metadata()
102            && metadata.is_file()
103        {
104            return Some(global_config);
105        }
106
107        None
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::fs;
115    use tempfile::TempDir;
116
117    #[test]
118    fn test_discover_no_configs() {
119        let _discovery = ConfigDiscovery::new();
120        let files = ConfigDiscovery::discover(None).unwrap();
121
122        assert!(files.cli.is_none());
123        // local, project, and global may or may not exist depending on test environment
124    }
125
126    #[test]
127    fn test_discover_cli_config() {
128        let tmp = TempDir::new().unwrap();
129        let cli_config = tmp.path().join("custom.toml");
130        fs::write(&cli_config, "# config").unwrap();
131
132        let _discovery = ConfigDiscovery::new();
133        let files = ConfigDiscovery::discover(Some(&cli_config)).unwrap();
134
135        assert_eq!(files.cli, Some(cli_config));
136    }
137
138    #[test]
139    fn test_discover_cli_config_nonexistent() {
140        let tmp = TempDir::new().unwrap();
141        let cli_config = tmp.path().join("nonexistent.toml");
142
143        let _discovery = ConfigDiscovery::new();
144        let result = ConfigDiscovery::discover(Some(&cli_config));
145
146        // Nonexistent CLI config should fail loudly (fail-fast principle)
147        assert!(result.is_err());
148        assert!(result.unwrap_err().to_string().contains("does not exist"));
149    }
150
151    // Note: Tests for find_file() that search from current directory are omitted
152    // to avoid test environment pollution from std::env::set_current_dir().
153    // The find_file() function is tested implicitly through the discover() tests
154    // which will find .ccsync files if present in the repository.
155}