1use std::fs;
27use std::io::Read;
28use std::path::{Path, PathBuf};
29
30use serde::de::DeserializeOwned;
31use serde::Serialize;
32
33use crate::error::Error;
34use crate::Result;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum ConfigFormat {
39 Yaml,
41 Json,
43 Toml,
45}
46
47impl ConfigFormat {
48 pub fn from_extension(ext: &str) -> Option<Self> {
50 match ext.to_lowercase().as_str() {
51 "yaml" | "yml" => Some(Self::Yaml),
52 "json" => Some(Self::Json),
53 "toml" => Some(Self::Toml),
54 _ => None,
55 }
56 }
57
58 pub fn from_path(path: impl AsRef<Path>) -> Option<Self> {
60 path.as_ref()
61 .extension()
62 .and_then(|e| e.to_str())
63 .and_then(Self::from_extension)
64 }
65
66 pub fn extension(&self) -> &'static str {
68 match self {
69 Self::Yaml => "yaml",
70 Self::Json => "json",
71 Self::Toml => "toml",
72 }
73 }
74
75 pub fn extensions(&self) -> &'static [&'static str] {
77 match self {
78 Self::Yaml => &["yaml", "yml"],
79 Self::Json => &["json"],
80 Self::Toml => &["toml"],
81 }
82 }
83
84 pub fn mime_type(&self) -> &'static str {
86 match self {
87 Self::Yaml => "application/x-yaml",
88 Self::Json => "application/json",
89 Self::Toml => "application/toml",
90 }
91 }
92}
93
94impl std::fmt::Display for ConfigFormat {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Self::Yaml => write!(f, "YAML"),
98 Self::Json => write!(f, "JSON"),
99 Self::Toml => write!(f, "TOML"),
100 }
101 }
102}
103
104pub struct ConfigLoader;
106
107impl ConfigLoader {
108 pub fn load<T: DeserializeOwned>(path: impl AsRef<Path>) -> Result<T> {
122 let path = path.as_ref();
123 let format = ConfigFormat::from_path(path).ok_or_else(|| {
124 Error::Config(format!(
125 "Cannot determine format from file extension: {}",
126 path.display()
127 ))
128 })?;
129
130 Self::load_with_format(path, format)
131 }
132
133 pub fn load_with_format<T: DeserializeOwned>(
135 path: impl AsRef<Path>,
136 format: ConfigFormat,
137 ) -> Result<T> {
138 let path = path.as_ref();
139 let content = fs::read_to_string(path)?;
140 Self::parse(&content, format)
141 }
142
143 pub fn load_from_reader<T: DeserializeOwned, R: Read>(
145 reader: &mut R,
146 format: ConfigFormat,
147 ) -> Result<T> {
148 let mut content = String::new();
149 reader.read_to_string(&mut content)?;
150 Self::parse(&content, format)
151 }
152
153 pub fn parse<T: DeserializeOwned>(content: &str, format: ConfigFormat) -> Result<T> {
155 match format {
156 ConfigFormat::Yaml => {
157 serde_yaml::from_str(content).map_err(|e| Error::Serialization(e.to_string()))
158 }
159 ConfigFormat::Json => {
160 serde_json::from_str(content).map_err(|e| Error::Serialization(e.to_string()))
161 }
162 ConfigFormat::Toml => {
163 toml::from_str(content).map_err(|e| Error::Serialization(e.to_string()))
164 }
165 }
166 }
167
168 pub fn serialize<T: Serialize>(config: &T, format: ConfigFormat) -> Result<String> {
170 match format {
171 ConfigFormat::Yaml => {
172 serde_yaml::to_string(config).map_err(|e| Error::Serialization(e.to_string()))
173 }
174 ConfigFormat::Json => serde_json::to_string_pretty(config)
175 .map_err(|e| Error::Serialization(e.to_string())),
176 ConfigFormat::Toml => {
177 toml::to_string_pretty(config).map_err(|e| Error::Serialization(e.to_string()))
178 }
179 }
180 }
181
182 pub fn save<T: Serialize>(config: &T, path: impl AsRef<Path>) -> Result<()> {
184 let path = path.as_ref();
185 let format = ConfigFormat::from_path(path).ok_or_else(|| {
186 Error::Config(format!(
187 "Cannot determine format from file extension: {}",
188 path.display()
189 ))
190 })?;
191
192 Self::save_with_format(config, path, format)
193 }
194
195 pub fn save_with_format<T: Serialize>(
197 config: &T,
198 path: impl AsRef<Path>,
199 format: ConfigFormat,
200 ) -> Result<()> {
201 let content = Self::serialize(config, format)?;
202 fs::write(path, content)?;
203 Ok(())
204 }
205
206 pub fn load_first<T: DeserializeOwned>(paths: &[impl AsRef<Path>]) -> Result<(T, PathBuf)> {
217 let mut last_error = None;
218
219 for path in paths {
220 let path = path.as_ref();
221 if path.exists() {
222 match Self::load(path) {
223 Ok(config) => return Ok((config, path.to_path_buf())),
224 Err(e) => last_error = Some(e),
225 }
226 }
227 }
228
229 Err(last_error.unwrap_or_else(|| {
230 Error::Config("No configuration file found in any of the specified paths".to_string())
231 }))
232 }
233
234 pub fn is_supported(path: impl AsRef<Path>) -> bool {
236 ConfigFormat::from_path(path).is_some()
237 }
238}
239
240pub struct ConfigDiscovery {
242 base_name: String,
244 search_dirs: Vec<PathBuf>,
246 formats: Vec<ConfigFormat>,
248}
249
250impl ConfigDiscovery {
251 pub fn new(base_name: impl Into<String>) -> Self {
253 Self {
254 base_name: base_name.into(),
255 search_dirs: Vec::new(),
256 formats: vec![ConfigFormat::Yaml, ConfigFormat::Toml, ConfigFormat::Json],
257 }
258 }
259
260 pub fn search_dir(mut self, dir: impl Into<PathBuf>) -> Self {
262 self.search_dirs.push(dir.into());
263 self
264 }
265
266 pub fn search_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
268 self.search_dirs.extend(dirs.into_iter().map(Into::into));
269 self
270 }
271
272 pub fn formats(mut self, formats: Vec<ConfigFormat>) -> Self {
274 self.formats = formats;
275 self
276 }
277
278 pub fn with_standard_dirs(mut self, app_name: &str) -> Self {
286 self.search_dirs.push(PathBuf::from("."));
288
289 self.search_dirs.push(PathBuf::from("./config"));
291
292 if let Some(home) = dirs_home() {
294 self.search_dirs.push(home.join(".config").join(app_name));
295 }
296
297 #[cfg(unix)]
299 {
300 self.search_dirs.push(PathBuf::from("/etc").join(app_name));
301 }
302
303 self
304 }
305
306 pub fn candidates(&self) -> Vec<PathBuf> {
308 let mut candidates = Vec::new();
309
310 for dir in &self.search_dirs {
311 for format in &self.formats {
312 for ext in format.extensions() {
313 let path = dir.join(format!("{}.{}", self.base_name, ext));
314 candidates.push(path);
315 }
316 }
317 }
318
319 candidates
320 }
321
322 pub fn find(&self) -> Option<PathBuf> {
324 self.candidates().into_iter().find(|p| p.exists())
325 }
326
327 pub fn load<T: DeserializeOwned>(&self) -> Result<(T, PathBuf)> {
329 let candidates = self.candidates();
330 ConfigLoader::load_first(&candidates)
331 }
332}
333
334fn dirs_home() -> Option<PathBuf> {
336 #[cfg(unix)]
337 {
338 std::env::var("HOME").ok().map(PathBuf::from)
339 }
340 #[cfg(windows)]
341 {
342 std::env::var("USERPROFILE").ok().map(PathBuf::from)
343 }
344 #[cfg(not(any(unix, windows)))]
345 {
346 None
347 }
348}
349
350pub struct LayeredConfigBuilder<T> {
355 base: T,
356 loaded_from: Vec<PathBuf>,
357}
358
359impl<T: DeserializeOwned + Default + Clone> LayeredConfigBuilder<T> {
360 pub fn new() -> Self {
362 Self {
363 base: T::default(),
364 loaded_from: Vec::new(),
365 }
366 }
367
368 pub fn with_base(base: T) -> Self {
370 Self {
371 base,
372 loaded_from: Vec::new(),
373 }
374 }
375}
376
377impl<T: DeserializeOwned + Clone> LayeredConfigBuilder<T> {
378 pub fn load_optional(mut self, path: impl AsRef<Path>) -> Self {
380 let path = path.as_ref();
381 if path.exists() {
382 if let Ok(config) = ConfigLoader::load::<T>(path) {
383 self.base = config;
384 self.loaded_from.push(path.to_path_buf());
385 }
386 }
387 self
388 }
389
390 pub fn load(mut self, path: impl AsRef<Path>) -> Result<Self> {
392 let path = path.as_ref();
393 self.base = ConfigLoader::load(path)?;
394 self.loaded_from.push(path.to_path_buf());
395 Ok(self)
396 }
397
398 pub fn loaded_from(&self) -> &[PathBuf] {
400 &self.loaded_from
401 }
402
403 pub fn build(self) -> T {
405 self.base
406 }
407}
408
409impl<T: DeserializeOwned + Default + Clone> Default for LayeredConfigBuilder<T> {
410 fn default() -> Self {
411 Self::new()
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use serde::{Deserialize, Serialize};
419 use std::io::Cursor;
420
421 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
422 struct TestConfig {
423 name: String,
424 #[serde(default)]
425 count: u32,
426 }
427
428 #[test]
429 fn test_format_from_extension() {
430 assert_eq!(
431 ConfigFormat::from_extension("yaml"),
432 Some(ConfigFormat::Yaml)
433 );
434 assert_eq!(
435 ConfigFormat::from_extension("yml"),
436 Some(ConfigFormat::Yaml)
437 );
438 assert_eq!(
439 ConfigFormat::from_extension("json"),
440 Some(ConfigFormat::Json)
441 );
442 assert_eq!(
443 ConfigFormat::from_extension("toml"),
444 Some(ConfigFormat::Toml)
445 );
446 assert_eq!(ConfigFormat::from_extension("txt"), None);
447 }
448
449 #[test]
450 fn test_format_from_path() {
451 assert_eq!(
452 ConfigFormat::from_path("config.yaml"),
453 Some(ConfigFormat::Yaml)
454 );
455 assert_eq!(
456 ConfigFormat::from_path("/etc/app/config.toml"),
457 Some(ConfigFormat::Toml)
458 );
459 assert_eq!(
460 ConfigFormat::from_path("data.json"),
461 Some(ConfigFormat::Json)
462 );
463 assert_eq!(ConfigFormat::from_path("noext"), None);
464 }
465
466 #[test]
467 fn test_parse_yaml() {
468 let yaml = r#"
469name: test
470count: 42
471"#;
472 let config: TestConfig = ConfigLoader::parse(yaml, ConfigFormat::Yaml).unwrap();
473 assert_eq!(config.name, "test");
474 assert_eq!(config.count, 42);
475 }
476
477 #[test]
478 fn test_parse_json() {
479 let json = r#"{"name": "test", "count": 42}"#;
480 let config: TestConfig = ConfigLoader::parse(json, ConfigFormat::Json).unwrap();
481 assert_eq!(config.name, "test");
482 assert_eq!(config.count, 42);
483 }
484
485 #[test]
486 fn test_parse_toml() {
487 let toml = r#"
488name = "test"
489count = 42
490"#;
491 let config: TestConfig = ConfigLoader::parse(toml, ConfigFormat::Toml).unwrap();
492 assert_eq!(config.name, "test");
493 assert_eq!(config.count, 42);
494 }
495
496 #[test]
497 fn test_serialize_yaml() {
498 let config = TestConfig {
499 name: "test".to_string(),
500 count: 42,
501 };
502 let yaml = ConfigLoader::serialize(&config, ConfigFormat::Yaml).unwrap();
503 assert!(yaml.contains("name: test"));
504 }
505
506 #[test]
507 fn test_serialize_json() {
508 let config = TestConfig {
509 name: "test".to_string(),
510 count: 42,
511 };
512 let json = ConfigLoader::serialize(&config, ConfigFormat::Json).unwrap();
513 assert!(json.contains("\"name\": \"test\""));
514 }
515
516 #[test]
517 fn test_serialize_toml() {
518 let config = TestConfig {
519 name: "test".to_string(),
520 count: 42,
521 };
522 let toml = ConfigLoader::serialize(&config, ConfigFormat::Toml).unwrap();
523 assert!(toml.contains("name = \"test\""));
524 }
525
526 #[test]
527 fn test_load_from_reader() {
528 let yaml = "name: reader_test\ncount: 100\n";
529 let mut reader = Cursor::new(yaml);
530 let config: TestConfig =
531 ConfigLoader::load_from_reader(&mut reader, ConfigFormat::Yaml).unwrap();
532 assert_eq!(config.name, "reader_test");
533 assert_eq!(config.count, 100);
534 }
535
536 #[test]
537 fn test_is_supported() {
538 assert!(ConfigLoader::is_supported("config.yaml"));
539 assert!(ConfigLoader::is_supported("config.yml"));
540 assert!(ConfigLoader::is_supported("config.json"));
541 assert!(ConfigLoader::is_supported("config.toml"));
542 assert!(!ConfigLoader::is_supported("config.txt"));
543 assert!(!ConfigLoader::is_supported("config"));
544 }
545
546 #[test]
547 fn test_config_discovery_candidates() {
548 let discovery = ConfigDiscovery::new("config")
549 .search_dir("/etc/app")
550 .search_dir(".");
551
552 let candidates = discovery.candidates();
553
554 assert!(candidates
556 .iter()
557 .any(|p| p.to_string_lossy().contains("config.yaml")));
558 assert!(candidates
559 .iter()
560 .any(|p| p.to_string_lossy().contains("config.toml")));
561 assert!(candidates
562 .iter()
563 .any(|p| p.to_string_lossy().contains("config.json")));
564 }
565
566 #[test]
567 fn test_layered_config_builder() {
568 let config = LayeredConfigBuilder::<TestConfig>::new().build();
569 assert_eq!(config.name, "");
570 assert_eq!(config.count, 0);
571 }
572
573 #[test]
574 fn test_format_display() {
575 assert_eq!(format!("{}", ConfigFormat::Yaml), "YAML");
576 assert_eq!(format!("{}", ConfigFormat::Json), "JSON");
577 assert_eq!(format!("{}", ConfigFormat::Toml), "TOML");
578 }
579
580 #[test]
581 fn test_format_mime_type() {
582 assert_eq!(ConfigFormat::Yaml.mime_type(), "application/x-yaml");
583 assert_eq!(ConfigFormat::Json.mime_type(), "application/json");
584 assert_eq!(ConfigFormat::Toml.mime_type(), "application/toml");
585 }
586}