Skip to main content

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 serde_json;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use toml;
12
13use super::ConfigError;
14use super::ConfigProvider;
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(
70        path: &Path,
71        format: FileFormat,
72    ) -> Result<HashMap<String, serde_json::Value>, ConfigError> {
73        let content = fs::read_to_string(path).map_err(|e| {
74            ConfigError::provider_error("file", format!("failed to read file: {e}"))
75        })?;
76
77        match format {
78            FileFormat::Json => serde_json::from_str(&content)
79                .map_err(|e| ConfigError::provider_error("file", format!("invalid JSON: {e}"))),
80            FileFormat::Toml => {
81                let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
82                    ConfigError::provider_error("file", format!("invalid TOML: {e}"))
83                })?;
84
85                // Convert toml::Value to serde_json::Value for unified internal representation
86                let json_value = serde_json::to_value(toml_value).map_err(|e| {
87                    ConfigError::provider_error("file", format!("failed to convert TOML: {e}"))
88                })?;
89
90                match json_value {
91                    serde_json::Value::Object(map) => {
92                        // Convert the map to our format
93                        Ok(map.into_iter().collect())
94                    }
95                    _ => Err(ConfigError::provider_error(
96                        "file",
97                        "root configuration must be an object",
98                    )),
99                }
100            }
101            FileFormat::Yaml => {
102                let yaml_value: serde_yaml::Value =
103                    serde_yaml::from_str(&content).map_err(|e| {
104                        ConfigError::provider_error("file", format!("invalid YAML: {e}"))
105                    })?;
106
107                let json_value = serde_json::to_value(yaml_value).map_err(|e| {
108                    ConfigError::provider_error("file", format!("failed to convert YAML: {e}"))
109                })?;
110
111                match json_value {
112                    serde_json::Value::Object(map) => Ok(map.into_iter().collect()),
113                    _ => Err(ConfigError::provider_error(
114                        "file",
115                        "root configuration must be an object",
116                    )),
117                }
118            }
119        }
120    }
121
122    /// Get a nested value from the configuration by a dot-separated key path.
123    fn get_nested_value(&self, key_path: &str) -> Option<&serde_json::Value> {
124        let parts: Vec<&str> = key_path.split('.').collect();
125
126        let mut current = self.data.get(parts[0])?;
127
128        for part in parts.iter().skip(1) {
129            current = current.get(part)?;
130        }
131
132        Some(current)
133    }
134}
135
136impl ConfigProvider for FileConfigProvider {
137    fn has(&self, key: &str) -> bool {
138        self.get_nested_value(key).is_some()
139    }
140
141    fn provider_name(&self) -> &str {
142        "file"
143    }
144
145    fn get_raw(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> {
146        match self.get_nested_value(key) {
147            Some(value) => Ok(Some(value.clone())),
148            None => Ok(None),
149        }
150    }
151}