Skip to main content

cloudflare_dns/
config.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::env;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Deserialize)]
8pub struct Config {
9    pub cloudflare_api_token: String,
10    pub cloudflare_zone_id: String,
11}
12
13impl Config {
14    /// Load configuration with fallback chain:
15    /// 1. ~/.config/cloudflaredns/config.yaml
16    /// 2. ./.env (current directory)
17    /// 3. Environment variables
18    pub fn load() -> Result<Self> {
19        // Try config file first
20        if let Ok(config) = Self::load_from_file() {
21            config.validate()?;
22            return Ok(config);
23        }
24
25        // Try .env file
26        dotenvy::dotenv().ok();
27
28        // Fall back to environment variables
29        let config = Self::load_from_env()?;
30        config.validate()?;
31        Ok(config)
32    }
33
34    /// Validate configuration values.
35    fn validate(&self) -> Result<()> {
36        if self.cloudflare_api_token.is_empty() {
37            anyhow::bail!("Cloudflare API token cannot be empty");
38        }
39
40        if self.cloudflare_zone_id.is_empty() {
41            anyhow::bail!("Cloudflare zone ID cannot be empty");
42        }
43
44        // Basic format validation - zone IDs are typically 32 character hex strings
45        if !self
46            .cloudflare_zone_id
47            .chars()
48            .all(|c| c.is_ascii_hexdigit() || c == '-')
49        {
50            anyhow::bail!("Cloudflare zone ID appears to be in an invalid format");
51        }
52
53        // API tokens should be reasonably long
54        if self.cloudflare_api_token.len() < 20 {
55            anyhow::bail!("Cloudflare API token appears to be in an invalid format (too short)");
56        }
57
58        Ok(())
59    }
60
61    fn load_from_file() -> Result<Self> {
62        let config_dir = dirs::home_dir()
63            .context("Could not determine home directory")?
64            .join(".config")
65            .join("cloudflaredns");
66
67        let config_path = config_dir.join("config.yaml");
68
69        if !config_path.exists() {
70            println!("Config file not found at {}", config_path.display());
71            anyhow::bail!("Config file not found at {}", config_path.display());
72        }
73
74        let content = fs::read_to_string(&config_path)
75            .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
76
77        let config: ConfigFile = serde_yaml::from_str(&content)
78            .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
79
80        Ok(Config {
81            cloudflare_api_token: config.cloudflare.api_token,
82            cloudflare_zone_id: config.cloudflare.zone_id,
83        })
84    }
85
86    /// Load configuration from environment variables only.
87    /// Useful for testing or when no config file is available.
88    pub fn load_from_env() -> Result<Self> {
89        let api_token = env::var("CLOUDFLARE_API_TOKEN").context(
90            "CLOUDFLARE_API_TOKEN not set in config file, .env, or environment variables",
91        )?;
92
93        let zone_id = env::var("CLOUDFLARE_ZONE_ID")
94            .context("CLOUDFLARE_ZONE_ID not set in config file, .env, or environment variables")?;
95
96        Ok(Config {
97            cloudflare_api_token: api_token,
98            cloudflare_zone_id: zone_id,
99        })
100    }
101
102    /// Get the expected config file path for display purposes
103    pub fn config_path() -> PathBuf {
104        dirs::home_dir()
105            .unwrap_or_default()
106            .join(".config")
107            .join("cloudflaredns")
108            .join("config.yaml")
109    }
110}
111
112#[derive(Debug, Deserialize)]
113struct ConfigFile {
114    cloudflare: CloudflareConfig,
115}
116
117#[derive(Debug, Deserialize)]
118struct CloudflareConfig {
119    api_token: String,
120    zone_id: String,
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::fs;
127    use std::io::Write;
128
129    fn create_temp_config_file(
130        api_token: &str,
131        zone_id: &str,
132    ) -> (tempfile::TempDir, std::path::PathBuf) {
133        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
134        let config_dir = temp_dir.path().join(".config").join("cloudflaredns");
135        fs::create_dir_all(&config_dir).expect("Failed to create config dir");
136
137        let config_path = config_dir.join("config.yaml");
138        let content = format!(
139            r#"cloudflare:
140  api_token: {}
141  zone_id: {}
142"#,
143            api_token, zone_id
144        );
145
146        let mut file = fs::File::create(&config_path).expect("Failed to create config file");
147        file.write_all(content.as_bytes())
148            .expect("Failed to write config");
149
150        (temp_dir, config_path)
151    }
152
153    #[test]
154    fn test_config_from_env() {
155        // Set environment variables for testing
156        unsafe {
157            std::env::set_var("CLOUDFLARE_API_TOKEN", "test_api_token_1234567890abcdef");
158            std::env::set_var("CLOUDFLARE_ZONE_ID", "0123456789abcdef0123456789abcdef");
159        }
160
161        let config = Config::load_from_env().expect("Failed to load config from env");
162        assert_eq!(
163            config.cloudflare_api_token,
164            "test_api_token_1234567890abcdef"
165        );
166        assert_eq!(
167            config.cloudflare_zone_id,
168            "0123456789abcdef0123456789abcdef"
169        );
170
171        // Clean up
172        unsafe {
173            std::env::remove_var("CLOUDFLARE_API_TOKEN");
174            std::env::remove_var("CLOUDFLARE_ZONE_ID");
175        }
176    }
177
178    #[test]
179    fn test_config_from_env_missing_token() {
180        unsafe {
181            std::env::set_var("CLOUDFLARE_ZONE_ID", "0123456789abcdef0123456789abcdef");
182            std::env::remove_var("CLOUDFLARE_API_TOKEN");
183        }
184
185        let result = Config::load_from_env();
186        assert!(result.is_err());
187
188        unsafe {
189            std::env::remove_var("CLOUDFLARE_ZONE_ID");
190        }
191    }
192
193    #[test]
194    fn test_config_from_env_missing_zone_id() {
195        unsafe {
196            std::env::set_var("CLOUDFLARE_API_TOKEN", "test_api_token_1234567890abcdef");
197            std::env::remove_var("CLOUDFLARE_ZONE_ID");
198        }
199
200        let result = Config::load_from_env();
201        assert!(result.is_err());
202
203        unsafe {
204            std::env::remove_var("CLOUDFLARE_API_TOKEN");
205        }
206    }
207
208    #[test]
209    fn test_config_path_generation() {
210        let config_path = Config::config_path();
211        // Should end with ~/.config/cloudflaredns/config.yaml
212        let path_str = config_path.to_string_lossy();
213        assert!(path_str.contains(".config/cloudflaredns/config.yaml"));
214    }
215
216    #[test]
217    fn test_config_file_parsing() {
218        let (temp_dir, config_path) = create_temp_config_file("file_token_abc", "file_zone_xyz");
219
220        // Read and parse the file manually to test the parsing logic
221        let content = fs::read_to_string(&config_path).expect("Failed to read config file");
222        let config_file: ConfigFile =
223            serde_yaml::from_str(&content).expect("Failed to parse config");
224
225        assert_eq!(config_file.cloudflare.api_token, "file_token_abc");
226        assert_eq!(config_file.cloudflare.zone_id, "file_zone_xyz");
227
228        // Prevent temp dir from being dropped until end of test
229        drop(temp_dir);
230    }
231
232    #[test]
233    fn test_config_file_not_found() {
234        // Use a unique temp dir that definitely has no config
235        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
236        let fake_config_dir = temp_dir.path().join("nonexistent").join("cloudflaredns");
237
238        // Try to load from a path that doesn't exist
239        let result = fs::read_to_string(fake_config_dir.join("config.yaml"));
240        assert!(result.is_err());
241    }
242
243    #[test]
244    fn test_config_yaml_invalid_format() {
245        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
246        let config_dir = temp_dir
247            .path()
248            .join("test_config_invalid")
249            .join("cloudflaredns");
250        fs::create_dir_all(&config_dir).expect("Failed to create config dir");
251        let config_path = config_dir.join("config.yaml");
252
253        // Write invalid YAML
254        let mut file = fs::File::create(&config_path).expect("Failed to create config file");
255        file.write_all(b"invalid: yaml: : content: [")
256            .expect("Failed to write invalid YAML");
257
258        // Read and parse directly - should fail on invalid YAML
259        let content = fs::read_to_string(&config_path).expect("Failed to read config file");
260        let result: Result<ConfigFile, _> = serde_yaml::from_str(&content);
261        assert!(result.is_err());
262    }
263}