Skip to main content

cfgmatic_source/infrastructure/
file_source.rs

1//! File source implementation.
2//!
3//! [`FileSource`] loads configuration from files in various formats
4//! (TOML, JSON, YAML). It supports single files and directories.
5
6use std::path::{Path, PathBuf};
7
8use cfgmatic_merge::Merge;
9
10use crate::domain::{Format, RawContent, Result, Source, SourceError, SourceKind, SourceMetadata};
11
12/// Builder for [`FileSource`].
13#[derive(Debug)]
14pub struct FileSourceBuilder {
15    paths: Vec<PathBuf>,
16    required: bool,
17}
18
19impl FileSourceBuilder {
20    /// Create a new builder.
21    #[must_use]
22    pub fn new() -> Self {
23        Self {
24            paths: Vec::new(),
25            required: true,
26        }
27    }
28
29    /// Add a file path.
30    #[must_use]
31    pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
32        self.paths.push(path.into());
33        self
34    }
35
36    /// Add multiple file paths.
37    #[must_use]
38    pub fn paths(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
39        self.paths.extend(paths.into_iter().map(|p| p.into()));
40        self
41    }
42
43    /// Set whether the source is required.
44    #[must_use]
45    pub fn required(mut self, required: bool) -> Self {
46        self.required = required;
47        self
48    }
49
50    /// Build the file source.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if no paths are configured.
55    pub fn build(self) -> Result<FileSource> {
56        if self.paths.is_empty() {
57            return Err(SourceError::validation("No file paths configured"));
58        }
59        Ok(FileSource {
60            paths: self.paths,
61            required: self.required,
62        })
63    }
64}
65
66impl Default for FileSourceBuilder {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72/// File-based configuration source.
73///
74/// Loads configuration from files in various formats.
75///
76/// # Example
77///
78/// ```rust,ignore
79/// use cfgmatic_source::FileSource;
80///
81/// let source = FileSource::builder()
82///     .path("config.toml")
83///     .path("config.local.toml")
84///     .required(false)
85///     .build()?;
86///
87/// let raw = source.load_raw()?;
88/// ```
89#[derive(Debug)]
90pub struct FileSource {
91    /// File paths to load.
92    pub(crate) paths: Vec<PathBuf>,
93    /// Whether the source is required.
94    required: bool,
95}
96
97impl FileSource {
98    /// Create a new file source.
99    #[must_use]
100    pub fn new(path: impl Into<PathBuf>) -> Self {
101        Self {
102            paths: vec![path.into()],
103            required: true,
104        }
105    }
106
107    /// Create a builder for file source.
108    #[must_use]
109    pub fn builder() -> FileSourceBuilder {
110        FileSourceBuilder::new()
111    }
112
113    /// Detect format from file extension.
114    fn detect_format_from_path(path: &Path) -> Option<Format> {
115        Format::from_path(path)
116    }
117
118    /// Read file contents.
119    fn read_file(path: &Path) -> Result<String> {
120        std::fs::read_to_string(path)
121            .map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))
122    }
123
124    /// Parse content based on format and convert to JSON value.
125    fn parse_to_json_value(
126        content: &str,
127        format: Format,
128        path: &Path,
129    ) -> Result<serde_json::Value> {
130        match format {
131            #[cfg(feature = "toml")]
132            Format::Toml => {
133                let v: toml::Value = toml::from_str(content).map_err(|e| {
134                    SourceError::parse_failed(&path.display().to_string(), "toml", &e.to_string())
135                })?;
136                serde_json::to_value(v).map_err(|e| SourceError::serialization(&e.to_string()))
137            }
138
139            #[cfg(feature = "json")]
140            Format::Json => serde_json::from_str(content).map_err(|e| {
141                SourceError::parse_failed(&path.display().to_string(), "json", &e.to_string())
142            }),
143
144            #[cfg(feature = "yaml")]
145            Format::Yaml => serde_yaml::from_str(content).map_err(|e| {
146                SourceError::parse_failed(&path.display().to_string(), "yaml", &e.to_string())
147            }),
148
149            Format::Unknown => Err(SourceError::unsupported("unknown format")),
150        }
151    }
152}
153
154impl Source for FileSource {
155    fn kind(&self) -> SourceKind {
156        SourceKind::File
157    }
158
159    fn metadata(&self) -> SourceMetadata {
160        let path = self.paths.first().cloned().unwrap_or_default();
161        SourceMetadata::new("file")
162            .with_path(path)
163            .with_priority(100)
164            .with_optional(!self.required)
165    }
166
167    fn load_raw(&self) -> Result<RawContent> {
168        // For single file, just load it
169        if self.paths.len() == 1 {
170            let path = &self.paths[0];
171            if !path.exists() {
172                if self.required {
173                    return Err(SourceError::not_found(&path.display().to_string()));
174                }
175                return Ok(RawContent::from_string(""));
176            }
177
178            let content = Self::read_file(path)?;
179            return Ok(RawContent::from_string(content).with_source_path(path.clone()));
180        }
181
182        // For multiple files, merge them
183        let mut merged = serde_json::Value::Object(serde_json::Map::new());
184        let mut any_loaded = false;
185
186        for path in &self.paths {
187            if !path.exists() {
188                if self.required {
189                    return Err(SourceError::not_found(&path.display().to_string()));
190                }
191                continue;
192            }
193
194            let content = Self::read_file(path)?;
195            let format = Self::detect_format_from_path(path).ok_or_else(|| {
196                SourceError::invalid_format(
197                    "config",
198                    &path.extension().unwrap_or_default().to_string_lossy(),
199                )
200            })?;
201
202            let value = Self::parse_to_json_value(&content, format, path)?;
203
204            merged = merged
205                .merge_deep(value)
206                .map_err(|e| SourceError::serialization(&e.to_string()))?;
207            any_loaded = true;
208        }
209
210        if !any_loaded && self.required {
211            return Err(SourceError::not_found("No configuration files found"));
212        }
213
214        let content = serde_json::to_string(&merged)
215            .map_err(|e| SourceError::serialization(&e.to_string()))?;
216
217        Ok(RawContent::from_string(content))
218    }
219
220    fn detect_format(&self) -> Option<Format> {
221        self.paths.first().and_then(|p| Format::from_path(p))
222    }
223
224    fn is_required(&self) -> bool {
225        self.required
226    }
227}
228
229#[cfg(feature = "async")]
230mod async_impl {
231    use super::*;
232    use async_trait::async_trait;
233
234    #[async_trait]
235    impl Source for FileSource {
236        async fn load_raw_async(&self) -> Result<RawContent> {
237            // For async, we use tokio::fs
238            if self.paths.len() == 1 {
239                let path = &self.paths[0];
240                let path_exists = tokio::fs::try_exists(path)
241                    .await
242                    .map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))?;
243
244                if !path_exists {
245                    if self.required {
246                        return Err(SourceError::not_found(&path.display().to_string()));
247                    }
248                    return Ok(RawContent::from_string(""));
249                }
250
251                let content = tokio::fs::read_to_string(path)
252                    .await
253                    .map_err(|e| SourceError::read_failed(&e.to_string()))?;
254                return Ok(RawContent::from_string(content).with_source_path(path.clone()));
255            }
256
257            // For multiple files, merge them
258            let mut merged = serde_json::Value::Object(serde_json::Map::new());
259            let mut any_loaded = false;
260
261            for path in &self.paths {
262                let path_exists = tokio::fs::try_exists(path)
263                    .await
264                    .map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))?;
265
266                if !path_exists {
267                    if self.required {
268                        return Err(SourceError::not_found(&path.display().to_string()));
269                    }
270                    continue;
271                }
272
273                let content = tokio::fs::read_to_string(path)
274                    .await
275                    .map_err(|e| SourceError::read_failed(&e.to_string()))?;
276
277                let format = Self::detect_format_from_path(path).ok_or_else(|| {
278                    SourceError::invalid_format(
279                        "config",
280                        &path.extension().unwrap_or_default().to_string_lossy(),
281                    )
282                })?;
283
284                let value = Self::parse_to_json_value(&content, format, path)?;
285
286                merged = merged
287                    .merge_deep(value)
288                    .map_err(|e| SourceError::serialization(&e.to_string()))?;
289                any_loaded = true;
290            }
291
292            if !any_loaded && self.required {
293                return Err(SourceError::not_found("No configuration files found"));
294            }
295
296            let content = serde_json::to_string(&merged)
297                .map_err(|e| SourceError::serialization(&e.to_string()))?;
298
299            Ok(RawContent::from_string(content))
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::io::Write;
308    use tempfile::NamedTempFile;
309
310    fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
311        let mut file = NamedTempFile::with_suffix(extension).unwrap();
312        write!(file, "{}", content).unwrap();
313        file
314    }
315
316    #[test]
317    fn test_file_source_builder() {
318        let source = FileSource::builder()
319            .path("/etc/config.toml")
320            .required(false)
321            .build()
322            .unwrap();
323
324        assert!(!source.is_required());
325        assert_eq!(source.paths.len(), 1);
326    }
327
328    #[test]
329    fn test_file_source_builder_no_paths() {
330        let result = FileSource::builder().build();
331        assert!(result.is_err());
332    }
333
334    #[test]
335    fn test_detect_format() {
336        assert_eq!(
337            FileSource::detect_format_from_path(Path::new("config.toml")),
338            Some(Format::Toml)
339        );
340        assert_eq!(
341            FileSource::detect_format_from_path(Path::new("config.json")),
342            Some(Format::Json)
343        );
344        #[cfg(feature = "yaml")]
345        {
346            assert_eq!(
347                FileSource::detect_format_from_path(Path::new("config.yaml")),
348                Some(Format::Yaml)
349            );
350            assert_eq!(
351                FileSource::detect_format_from_path(Path::new("config.yml")),
352                Some(Format::Yaml)
353            );
354        }
355        assert_eq!(
356            FileSource::detect_format_from_path(Path::new("config.txt")),
357            None
358        );
359    }
360
361    #[cfg(feature = "toml")]
362    #[test]
363    fn test_load_raw_toml() {
364        let content = r#"
365        name = "test"
366        value = 42
367        "#;
368        let file = create_temp_file(content, ".toml");
369
370        let source = FileSource::new(file.path());
371        let raw = source.load_raw().unwrap();
372        let str_content = raw.as_str().unwrap();
373        assert!(str_content.as_ref().contains("test"));
374    }
375
376    #[cfg(feature = "json")]
377    #[test]
378    fn test_load_raw_json() {
379        let content = r#"{"name": "test", "value": 42}"#;
380        let file = create_temp_file(content, ".json");
381
382        let source = FileSource::new(file.path());
383        let raw = source.load_raw().unwrap();
384        let str_content = raw.as_str().unwrap();
385        assert!(str_content.as_ref().contains("test"));
386    }
387
388    #[test]
389    fn test_load_file_not_found() {
390        let source = FileSource::new("/nonexistent/config.toml");
391        let result = source.load_raw();
392        assert!(result.is_err());
393    }
394
395    #[test]
396    fn test_load_optional_file_not_found() {
397        let source = FileSource::builder()
398            .path("/nonexistent/config.toml")
399            .required(false)
400            .build()
401            .unwrap();
402
403        let raw = source.load_raw().unwrap();
404        assert!(raw.is_empty());
405    }
406
407    #[test]
408    fn test_metadata() {
409        let source = FileSource::new("config.toml");
410        let meta = source.metadata();
411
412        assert_eq!(meta.name, "file");
413        assert_eq!(source.kind(), SourceKind::File);
414        assert_eq!(meta.priority, 100);
415    }
416
417    #[cfg(feature = "async")]
418    #[cfg(feature = "toml")]
419    #[tokio::test]
420    async fn test_load_raw_async_toml() {
421        let content = r#"
422        name = "async_test"
423        value = 123
424        "#;
425        let file = create_temp_file(content, ".toml");
426
427        let source = FileSource::new(file.path());
428        let raw = source.load_raw_async().await.unwrap();
429        let str_content = raw.as_str().unwrap();
430        assert!(str_content.as_ref().contains("async_test"));
431    }
432
433    #[cfg(feature = "async")]
434    #[cfg(feature = "json")]
435    #[tokio::test]
436    async fn test_load_raw_async_json() {
437        let content = r#"{"name": "async_test", "value": 123}"#;
438        let file = create_temp_file(content, ".json");
439
440        let source = FileSource::new(file.path());
441        let raw = source.load_raw_async().await.unwrap();
442        let str_content = raw.as_str().unwrap();
443        assert!(str_content.as_ref().contains("async_test"));
444    }
445
446    #[cfg(feature = "async")]
447    #[tokio::test]
448    async fn test_load_raw_async_file_not_found() {
449        let source = FileSource::new("/nonexistent/async_config.toml");
450        let result = source.load_raw_async().await;
451        assert!(result.is_err());
452    }
453
454    #[cfg(feature = "async")]
455    #[tokio::test]
456    async fn test_load_raw_async_optional_not_found() {
457        let source = FileSource::builder()
458            .path("/nonexistent/async_config.toml")
459            .required(false)
460            .build()
461            .unwrap();
462
463        let raw = source.load_raw_async().await.unwrap();
464        assert!(raw.is_empty());
465    }
466
467    #[cfg(feature = "async")]
468    #[cfg(feature = "toml")]
469    #[tokio::test]
470    async fn test_load_raw_async_multiple_files() {
471        let content1 = r#"[section1]
472key1 = "value1""#;
473        let content2 = r#"[section2]
474key2 = "value2""#;
475        let file1 = create_temp_file(content1, ".toml");
476        let file2 = create_temp_file(content2, ".toml");
477
478        let source = FileSource::builder()
479            .path(file1.path())
480            .path(file2.path())
481            .build()
482            .unwrap();
483
484        let raw = source.load_raw_async().await.unwrap();
485        let str_content = raw.as_str().unwrap();
486        assert!(str_content.as_ref().contains("section1"));
487        assert!(str_content.as_ref().contains("section2"));
488    }
489}