foxy/config/
file.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! File-based configuration provider implementation.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use serde_json;
11use toml;
12
13use super::ConfigProvider;
14use super::ConfigError;
15
16/// Supported file formats for configuration.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FileFormat {
19    /// JSON format (.json)
20    Json,
21    /// TOML format (.toml)
22    Toml,
23    /// YAML format (.yaml, .yml)
24    Yaml,
25}
26
27impl FileFormat {
28    /// Detect the file format from the file extension.
29    pub fn from_extension(path: &Path) -> Option<Self> {
30        path.extension().and_then(|ext| {
31            let ext_str = ext.to_string_lossy().to_lowercase();
32            match ext_str.as_str() {
33                "json" => Some(FileFormat::Json),
34                "toml" => Some(FileFormat::Toml),
35                "yaml" | "yml" => Some(FileFormat::Yaml),
36                _ => None,
37            }
38        })
39    }
40}
41
42/// File-based configuration provider.
43#[derive(Debug)]
44pub struct FileConfigProvider {
45    #[allow(dead_code)]
46    path: PathBuf,
47    #[allow(dead_code)]
48    format: FileFormat,
49    data: HashMap<String, serde_json::Value>,
50}
51
52impl FileConfigProvider {
53    /// Create a new file-based configuration provider.
54    pub fn new(path: &str) -> Result<Self, ConfigError> {
55        let path_buf = PathBuf::from(path);
56        let format = FileFormat::from_extension(&path_buf)
57            .ok_or_else(|| ConfigError::provider_error("file", "unsupported file format"))?;
58
59        let data = Self::read_file(&path_buf, format)?;
60
61        Ok(Self {
62            path: path_buf,
63            format,
64            data,
65        })
66    }
67
68    /// Read and parse a configuration file.
69    fn read_file(path: &Path, format: FileFormat) -> Result<HashMap<String, serde_json::Value>, ConfigError> {
70        let content = fs::read_to_string(path)
71            .map_err(|e| ConfigError::provider_error("file", format!("failed to read file: {}", e)))?;
72
73        match format {
74            FileFormat::Json => {
75                serde_json::from_str(&content)
76                    .map_err(|e| ConfigError::provider_error("file", format!("invalid JSON: {}", e)))
77            },
78            FileFormat::Toml => {
79                let toml_value: toml::Value = toml::from_str(&content)
80                    .map_err(|e| ConfigError::provider_error("file", format!("invalid TOML: {}", e)))?;
81
82                // Convert toml::Value to serde_json::Value for unified internal representation
83                let json_value = serde_json::to_value(toml_value)
84                    .map_err(|e| ConfigError::provider_error("file", format!("failed to convert TOML: {}", e)))?;
85
86                match json_value {
87                    serde_json::Value::Object(map) => {
88                        // Convert the map to our format
89                        Ok(map.into_iter().collect())
90                    },
91                    _ => Err(ConfigError::provider_error("file", "root configuration must be an object")),
92                }
93            },
94            FileFormat::Yaml => {
95                {
96                    let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
97                        .map_err(|e| ConfigError::provider_error("file", format!("invalid YAML: {}", e)))?;
98
99                    let json_value = serde_json::to_value(yaml_value)
100                        .map_err(|e| ConfigError::provider_error("file", format!("failed to convert YAML: {}", e)))?;
101
102                    match json_value {
103                        serde_json::Value::Object(map) => {
104                            Ok(map.into_iter().collect())
105                        },
106                        _ => Err(ConfigError::provider_error("file", "root configuration must be an object")),
107                    }
108                }
109            },
110        }
111    }
112
113    /// Get a nested value from the configuration by a dot-separated key path.
114    fn get_nested_value(&self, key_path: &str) -> Option<&serde_json::Value> {
115        let parts: Vec<&str> = key_path.split('.').collect();
116
117        let mut current = self.data.get(parts[0])?;
118
119        for part in parts.iter().skip(1) {
120            current = current.get(part)?;
121        }
122
123        Some(current)
124    }
125}
126
127impl ConfigProvider for FileConfigProvider {
128    fn has(&self, key: &str) -> bool {
129        self.get_nested_value(key).is_some()
130    }
131
132    fn provider_name(&self) -> &str {
133        "file"
134    }
135
136    fn get_raw(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> {
137        match self.get_nested_value(key) {
138            Some(value) => Ok(Some(value.clone())),
139            None => Ok(None),
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::fs::File;
148    use std::io::Write;
149    use tempfile::tempdir;
150    use crate::config::ConfigProviderExt;
151
152    #[test]
153    fn test_file_format_detection() {
154        assert_eq!(FileFormat::from_extension(Path::new("config.json")), Some(FileFormat::Json));
155        assert_eq!(FileFormat::from_extension(Path::new("config.toml")), Some(FileFormat::Toml));
156        assert_eq!(FileFormat::from_extension(Path::new("config.yaml")), Some(FileFormat::Yaml));
157        assert_eq!(FileFormat::from_extension(Path::new("config.yml")), Some(FileFormat::Yaml));
158        assert_eq!(FileFormat::from_extension(Path::new("config.txt")), None);
159    }
160
161    #[test]
162    fn test_json_config() {
163        let dir = tempdir().unwrap();
164        let file_path = dir.path().join("config.json");
165
166        let content = r#"{
167            "server": {
168                "host": "127.0.0.1",
169                "port": 8080
170            },
171            "timeout": 30
172        }"#;
173
174        let mut file = File::create(&file_path).unwrap();
175        file.write_all(content.as_bytes()).unwrap();
176
177        let provider = FileConfigProvider::new(file_path.to_str().unwrap()).unwrap();
178
179        assert_eq!(provider.has("server.host"), true);
180        assert_eq!(provider.has("server.nonexistent"), false);
181
182        let host: String = provider.get("server.host").unwrap().unwrap();
183        assert_eq!(host, "127.0.0.1");
184
185        let port: u16 = provider.get("server.port").unwrap().unwrap();
186        assert_eq!(port, 8080);
187
188        let timeout: u32 = provider.get("timeout").unwrap().unwrap();
189        assert_eq!(timeout, 30);
190    }
191
192    #[test]
193    fn test_toml_config() {
194        let dir = tempdir().unwrap();
195        let file_path = dir.path().join("config.toml");
196
197        let content = r#"
198            [server]
199            host = "127.0.0.1"
200            port = 8080
201
202            timeout = 30
203        "#;
204
205        let mut file = File::create(&file_path).unwrap();
206        file.write_all(content.as_bytes()).unwrap();
207
208        let provider = FileConfigProvider::new(file_path.to_str().unwrap()).unwrap();
209
210        assert_eq!(provider.has("server.host"), true);
211        let host: String = provider.get("server.host").unwrap().unwrap();
212        assert_eq!(host, "127.0.0.1");
213
214        let port: u16 = provider.get("server.port").unwrap().unwrap();
215        assert_eq!(port, 8080);
216    }
217}