gonfig/config.rs
1use crate::{
2 error::{Error, Result},
3 source::{ConfigSource, Source},
4};
5use serde_json::Value;
6use std::any::Any;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Supported configuration file formats.
11///
12/// This enum represents the different file formats that gonfig can parse
13/// for configuration files. Each format has its own parsing logic and
14/// error handling.
15///
16/// # Examples
17///
18/// ```rust
19/// use gonfig::ConfigFormat;
20///
21/// // Automatic detection from file extension
22/// let format = ConfigFormat::from_extension("json").unwrap();
23/// assert!(matches!(format, ConfigFormat::Json));
24///
25/// // Manual format specification
26/// let format = ConfigFormat::Yaml;
27/// ```
28#[derive(Debug, Clone, PartialEq)]
29pub enum ConfigFormat {
30 /// JSON format (.json files)
31 Json,
32 /// YAML format (.yaml, .yml files)
33 Yaml,
34 /// TOML format (.toml files)
35 Toml,
36}
37
38impl ConfigFormat {
39 /// Detect configuration format from file extension.
40 ///
41 /// Returns the appropriate format for common file extensions:
42 /// - `json` → [`ConfigFormat::Json`]
43 /// - `yaml`, `yml` → [`ConfigFormat::Yaml`]
44 /// - `toml` → [`ConfigFormat::Toml`]
45 ///
46 /// # Examples
47 ///
48 /// ```rust
49 /// use gonfig::ConfigFormat;
50 ///
51 /// assert!(matches!(ConfigFormat::from_extension("json"), Some(ConfigFormat::Json)));
52 /// assert!(matches!(ConfigFormat::from_extension("yml"), Some(ConfigFormat::Yaml)));
53 /// assert!(matches!(ConfigFormat::from_extension("toml"), Some(ConfigFormat::Toml)));
54 /// assert_eq!(ConfigFormat::from_extension("unknown"), None);
55 /// ```
56 pub fn from_extension(ext: &str) -> Option<Self> {
57 match ext.to_lowercase().as_str() {
58 "json" => Some(ConfigFormat::Json),
59 "yaml" | "yml" => Some(ConfigFormat::Yaml),
60 "toml" => Some(ConfigFormat::Toml),
61 _ => None,
62 }
63 }
64
65 /// Parse configuration content according to the format.
66 ///
67 /// Converts the string content into a [`serde_json::Value`] that can be
68 /// merged with other configuration sources. All formats are normalized
69 /// to JSON values internally.
70 ///
71 /// # Arguments
72 ///
73 /// * `content` - The configuration file content as a string
74 ///
75 /// # Returns
76 ///
77 /// A [`serde_json::Value`] representing the parsed configuration.
78 ///
79 /// # Errors
80 ///
81 /// Returns [`Error::Serialization`] if the content cannot be parsed
82 /// according to the format's syntax rules.
83 ///
84 /// # Examples
85 ///
86 /// ```rust
87 /// use gonfig::ConfigFormat;
88 ///
89 /// let format = ConfigFormat::Json;
90 /// let content = r#"{"name": "test", "port": 8080}"#;
91 /// let value = format.parse(content).unwrap();
92 /// ```
93 pub fn parse(&self, content: &str) -> Result<Value> {
94 match self {
95 ConfigFormat::Json => serde_json::from_str(content)
96 .map_err(|e| Error::Serialization(format!("JSON parse error: {}", e))),
97 ConfigFormat::Yaml => serde_yaml::from_str(content)
98 .map_err(|e| Error::Serialization(format!("YAML parse error: {}", e))),
99 ConfigFormat::Toml => {
100 let toml_value: toml::Value = toml::from_str(content)
101 .map_err(|e| Error::Serialization(format!("TOML parse error: {}", e)))?;
102 serde_json::to_value(toml_value).map_err(|e| {
103 Error::Serialization(format!("TOML to JSON conversion error: {}", e))
104 })
105 }
106 }
107 }
108}
109
110/// Configuration file source.
111///
112/// The `Config` struct represents a configuration file that can be loaded
113/// and parsed. It supports automatic format detection, optional files,
114/// and various configuration file formats (JSON, YAML, TOML).
115///
116/// # Examples
117///
118/// ```rust,no_run
119/// use gonfig::Config;
120///
121/// // Load a required configuration file
122/// let config = Config::from_file("app.json")?;
123///
124/// // Load an optional configuration file (won't fail if missing)
125/// let config = Config::from_file_optional("optional.yaml")?;
126/// # Ok::<(), gonfig::Error>(())
127/// ```
128#[derive(Debug, Clone)]
129pub struct Config {
130 path: PathBuf,
131 format: ConfigFormat,
132 required: bool,
133 data: Option<Value>,
134}
135
136impl Config {
137 /// Load a required configuration file with automatic format detection.
138 ///
139 /// The file format is detected from the file extension. If the file doesn't
140 /// exist or cannot be parsed, this method returns an error.
141 ///
142 /// # Arguments
143 ///
144 /// * `path` - Path to the configuration file
145 ///
146 /// # Examples
147 ///
148 /// ```rust,no_run
149 /// use gonfig::Config;
150 ///
151 /// let config = Config::from_file("app.json")?;
152 /// let config = Config::from_file("settings.yaml")?;
153 /// let config = Config::from_file("config.toml")?;
154 /// # Ok::<(), gonfig::Error>(())
155 /// ```
156 ///
157 /// # Errors
158 ///
159 /// - [`Error::Config`] if the file extension is not recognized
160 /// - [`Error::Io`] if the file cannot be read
161 /// - [`Error::Serialization`] if the file cannot be parsed
162 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
163 let path = path.as_ref().to_path_buf();
164 let format = path
165 .extension()
166 .and_then(|ext| ext.to_str())
167 .and_then(ConfigFormat::from_extension)
168 .ok_or_else(|| Error::Config(format!("Unknown config format for file: {:?}", path)))?;
169
170 let mut config = Self {
171 path,
172 format,
173 required: true,
174 data: None,
175 };
176
177 config.load()?;
178 Ok(config)
179 }
180
181 /// Load an optional configuration file with automatic format detection.
182 ///
183 /// Similar to [`from_file`], but won't return an error if the file doesn't exist.
184 /// Parse errors will still cause the method to fail.
185 ///
186 /// # Arguments
187 ///
188 /// * `path` - Path to the optional configuration file
189 ///
190 /// # Examples
191 ///
192 /// ```rust,no_run
193 /// use gonfig::Config;
194 ///
195 /// // Won't fail if user.json doesn't exist
196 /// let config = Config::from_file_optional("user.json")?;
197 /// # Ok::<(), gonfig::Error>(())
198 /// ```
199 ///
200 /// [`from_file`]: Config::from_file
201 pub fn from_file_optional(path: impl AsRef<Path>) -> Result<Self> {
202 let path = path.as_ref().to_path_buf();
203 let format = path
204 .extension()
205 .and_then(|ext| ext.to_str())
206 .and_then(ConfigFormat::from_extension)
207 .ok_or_else(|| Error::Config(format!("Unknown config format for file: {:?}", path)))?;
208
209 let path_display = path.display().to_string();
210 let mut config = Self {
211 path,
212 format,
213 required: false,
214 data: None,
215 };
216
217 // For optional configs, only ignore file-not-found errors
218 match config.load() {
219 Ok(()) => {}
220 Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
221 // File not found is OK for optional configs
222 }
223 Err(e) => {
224 // Log parse errors but don't fail
225 tracing::warn!(
226 "Failed to parse optional config file {}: {}",
227 path_display,
228 e
229 );
230 }
231 }
232 Ok(config)
233 }
234
235 /// Load a configuration file with explicit format specification.
236 ///
237 /// Use this method when you need to override automatic format detection
238 /// or when working with files that don't have standard extensions.
239 ///
240 /// # Arguments
241 ///
242 /// * `path` - Path to the configuration file
243 /// * `format` - The format to use for parsing
244 ///
245 /// # Examples
246 ///
247 /// ```rust,no_run
248 /// use gonfig::{Config, ConfigFormat};
249 ///
250 /// // Force JSON parsing for a file without extension
251 /// let config = Config::with_format("config", ConfigFormat::Json)?;
252 /// # Ok::<(), gonfig::Error>(())
253 /// ```
254 pub fn with_format(path: impl AsRef<Path>, format: ConfigFormat) -> Result<Self> {
255 let mut config = Self {
256 path: path.as_ref().to_path_buf(),
257 format,
258 required: true,
259 data: None,
260 };
261
262 config.load()?;
263 Ok(config)
264 }
265
266 fn load(&mut self) -> Result<()> {
267 match fs::read_to_string(&self.path) {
268 Ok(content) => {
269 self.data = Some(self.format.parse(&content)?);
270 Ok(())
271 }
272 Err(e) => {
273 if self.required {
274 Err(Error::Io(e))
275 } else {
276 self.data = Some(Value::Object(serde_json::Map::new()));
277 Ok(())
278 }
279 }
280 }
281 }
282
283 /// Reload the configuration from disk.
284 ///
285 /// This method re-reads the configuration file and parses it again.
286 /// Useful for applications that need to respond to configuration changes
287 /// at runtime.
288 ///
289 /// # Examples
290 ///
291 /// ```rust,no_run
292 /// use gonfig::Config;
293 ///
294 /// let mut config = Config::from_file("app.json")?;
295 /// // ... some time later ...
296 /// config.reload()?; // Re-read from disk
297 /// # Ok::<(), gonfig::Error>(())
298 /// ```
299 ///
300 /// # Errors
301 ///
302 /// Returns the same errors as the original loading method if the file
303 /// cannot be read or parsed.
304 pub fn reload(&mut self) -> Result<()> {
305 self.load()
306 }
307}
308
309impl ConfigSource for Config {
310 fn source_type(&self) -> Source {
311 Source::ConfigFile
312 }
313
314 fn collect(&self) -> Result<Value> {
315 Ok(self
316 .data
317 .clone()
318 .unwrap_or_else(|| Value::Object(serde_json::Map::new())))
319 }
320
321 fn has_value(&self, key: &str) -> bool {
322 if let Some(data) = &self.data {
323 let parts: Vec<&str> = key.split('.').collect();
324 let mut current = data;
325
326 for part in parts {
327 match current.get(part) {
328 Some(value) => current = value,
329 None => return false,
330 }
331 }
332 true
333 } else {
334 false
335 }
336 }
337
338 fn get_value(&self, key: &str) -> Option<Value> {
339 if let Some(data) = &self.data {
340 let parts: Vec<&str> = key.split('.').collect();
341 let mut current = data;
342
343 for part in parts {
344 match current.get(part) {
345 Some(value) => current = value,
346 None => return None,
347 }
348 }
349 Some(current.clone())
350 } else {
351 None
352 }
353 }
354
355 fn as_any(&self) -> &dyn Any {
356 self
357 }
358}