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 const 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(Into::into));
40        self
41    }
42
43    /// Set whether the source is required.
44    #[must_use]
45    pub const 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,no_run
79/// use cfgmatic_source::{FileSource, Source};
80///
81/// let source = FileSource::builder()
82///     .path("config.toml")
83///     .path("config.local.toml")
84///     .required(false)
85///     .build()
86///     .unwrap();
87///
88/// let raw = source.load_raw()?;
89/// # Ok::<(), cfgmatic_source::SourceError>(())
90/// ```
91#[derive(Debug)]
92pub struct FileSource {
93    /// File paths to load.
94    pub(crate) paths: Vec<PathBuf>,
95    /// Whether the source is required.
96    required: bool,
97}
98
99impl FileSource {
100    /// Create a new file source.
101    #[must_use]
102    pub fn new(path: impl Into<PathBuf>) -> Self {
103        Self {
104            paths: vec![path.into()],
105            required: true,
106        }
107    }
108
109    /// Create a builder for file source.
110    #[must_use]
111    pub const fn builder() -> FileSourceBuilder {
112        FileSourceBuilder::new()
113    }
114
115    /// Detect format from file extension.
116    fn detect_format_from_path(path: &Path) -> Option<Format> {
117        Format::from_path(path)
118    }
119
120    /// Read file contents.
121    fn read_file(path: &Path) -> Result<String> {
122        std::fs::read_to_string(path)
123            .map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))
124    }
125
126    /// Parse content based on format and convert to JSON value.
127    fn parse_to_json_value(
128        content: &str,
129        format: Format,
130        path: &Path,
131    ) -> Result<serde_json::Value> {
132        match format {
133            #[cfg(feature = "toml")]
134            Format::Toml => {
135                let v: toml::Value = toml::from_str(content).map_err(|e| {
136                    SourceError::parse_failed(&path.display().to_string(), "toml", &e.to_string())
137                })?;
138                serde_json::to_value(v).map_err(|e| SourceError::serialization(&e.to_string()))
139            }
140
141            #[cfg(feature = "json")]
142            Format::Json => serde_json::from_str(content).map_err(|e| {
143                SourceError::parse_failed(&path.display().to_string(), "json", &e.to_string())
144            }),
145
146            #[cfg(feature = "yaml")]
147            Format::Yaml => serde_yaml::from_str(content).map_err(|e| {
148                SourceError::parse_failed(&path.display().to_string(), "yaml", &e.to_string())
149            }),
150
151            Format::Unknown => Err(SourceError::unsupported("unknown format")),
152        }
153    }
154}
155
156impl Source for FileSource {
157    fn kind(&self) -> SourceKind {
158        SourceKind::File
159    }
160
161    fn metadata(&self) -> SourceMetadata {
162        let path = self.paths.first().cloned().unwrap_or_default();
163        SourceMetadata::new("file")
164            .with_path(path)
165            .with_priority(100)
166            .with_optional(!self.required)
167    }
168
169    fn load_raw(&self) -> Result<RawContent> {
170        // For single file, just load it
171        if self.paths.len() == 1 {
172            let path = &self.paths[0];
173            if !path.exists() {
174                if self.required {
175                    return Err(SourceError::not_found(&path.display().to_string()));
176                }
177                return Ok(RawContent::from_string(""));
178            }
179
180            let content = Self::read_file(path)?;
181            return Ok(RawContent::from_string(content).with_source_path(path.clone()));
182        }
183
184        // For multiple files, merge them
185        let mut merged = serde_json::Value::Object(serde_json::Map::new());
186        let mut any_loaded = false;
187
188        for path in &self.paths {
189            if !path.exists() {
190                if self.required {
191                    return Err(SourceError::not_found(&path.display().to_string()));
192                }
193                continue;
194            }
195
196            let content = Self::read_file(path)?;
197            let format = Self::detect_format_from_path(path).ok_or_else(|| {
198                SourceError::invalid_format(
199                    "config",
200                    &path.extension().unwrap_or_default().to_string_lossy(),
201                )
202            })?;
203
204            let value = Self::parse_to_json_value(&content, format, path)?;
205
206            merged = merged
207                .merge_deep(value)
208                .map_err(|e| SourceError::serialization(&e.to_string()))?;
209            any_loaded = true;
210        }
211
212        if !any_loaded && self.required {
213            return Err(SourceError::not_found("No configuration files found"));
214        }
215
216        let content = serde_json::to_string(&merged)
217            .map_err(|e| SourceError::serialization(&e.to_string()))?;
218
219        Ok(RawContent::from_string(content))
220    }
221
222    fn detect_format(&self) -> Option<Format> {
223        self.paths.first().and_then(|p| Format::from_path(p))
224    }
225
226    fn is_required(&self) -> bool {
227        self.required
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use std::io::Write;
235    use tempfile::NamedTempFile;
236
237    fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
238        let mut file = NamedTempFile::with_suffix(extension).unwrap();
239        write!(file, "{content}").unwrap();
240        file
241    }
242
243    #[test]
244    fn test_file_source_builder() {
245        let source = FileSource::builder()
246            .path("/etc/config.toml")
247            .required(false)
248            .build()
249            .unwrap();
250
251        assert!(!source.is_required());
252        assert_eq!(source.paths.len(), 1);
253    }
254
255    #[test]
256    fn test_file_source_builder_no_paths() {
257        let result = FileSource::builder().build();
258        assert!(result.is_err());
259    }
260
261    #[test]
262    fn test_detect_format() {
263        assert_eq!(
264            FileSource::detect_format_from_path(Path::new("config.toml")),
265            Some(Format::Toml)
266        );
267        assert_eq!(
268            FileSource::detect_format_from_path(Path::new("config.json")),
269            Some(Format::Json)
270        );
271        #[cfg(feature = "yaml")]
272        {
273            assert_eq!(
274                FileSource::detect_format_from_path(Path::new("config.yaml")),
275                Some(Format::Yaml)
276            );
277            assert_eq!(
278                FileSource::detect_format_from_path(Path::new("config.yml")),
279                Some(Format::Yaml)
280            );
281        }
282        assert_eq!(
283            FileSource::detect_format_from_path(Path::new("config.txt")),
284            None
285        );
286    }
287
288    #[cfg(feature = "toml")]
289    #[test]
290    fn test_load_raw_toml() {
291        let content = r#"
292        name = "test"
293        value = 42
294        "#;
295        let file = create_temp_file(content, ".toml");
296
297        let source = FileSource::new(file.path());
298        let raw = source.load_raw().unwrap();
299        let str_content = raw.as_str().unwrap();
300        assert!(str_content.as_ref().contains("test"));
301    }
302
303    #[cfg(feature = "json")]
304    #[test]
305    fn test_load_raw_json() {
306        let content = r#"{"name": "test", "value": 42}"#;
307        let file = create_temp_file(content, ".json");
308
309        let source = FileSource::new(file.path());
310        let raw = source.load_raw().unwrap();
311        let str_content = raw.as_str().unwrap();
312        assert!(str_content.as_ref().contains("test"));
313    }
314
315    #[test]
316    fn test_load_file_not_found() {
317        let source = FileSource::new("/nonexistent/config.toml");
318        let result = source.load_raw();
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn test_load_optional_file_not_found() {
324        let source = FileSource::builder()
325            .path("/nonexistent/config.toml")
326            .required(false)
327            .build()
328            .unwrap();
329
330        let raw = source.load_raw().unwrap();
331        assert!(raw.is_empty());
332    }
333
334    #[test]
335    fn test_metadata() {
336        let source = FileSource::new("config.toml");
337        let meta = source.metadata();
338
339        assert_eq!(meta.name, "file");
340        assert_eq!(source.kind(), SourceKind::File);
341        assert_eq!(meta.priority, 100);
342    }
343
344    #[cfg(all(feature = "json", feature = "toml"))]
345    #[test]
346    fn test_load_raw_multiple_files_merges_content() {
347        let base = create_temp_file(r#"{"server": {"host": "base", "port": 8080}}"#, ".json");
348        let local = create_temp_file(
349            r"
350            [server]
351            port = 9090
352            ",
353            ".toml",
354        );
355
356        let source = FileSource::builder()
357            .paths([base.path(), local.path()])
358            .build()
359            .unwrap();
360
361        let raw = source.load_raw().unwrap();
362        let content = raw.as_str().unwrap();
363
364        assert!(content.contains("\"host\":\"base\""));
365        assert!(content.contains("\"port\":9090"));
366    }
367
368    #[cfg(feature = "toml")]
369    #[test]
370    fn test_load_raw_multiple_files_skips_missing_optional_paths() {
371        let local = create_temp_file(
372            r#"
373            [server]
374            host = "local"
375            "#,
376            ".toml",
377        );
378
379        let source = FileSource::builder()
380            .paths([
381                PathBuf::from("/nonexistent/missing.toml"),
382                local.path().to_path_buf(),
383            ])
384            .required(false)
385            .build()
386            .unwrap();
387
388        let raw = source.load_raw().unwrap();
389        let content = raw.as_str().unwrap();
390
391        assert!(content.contains("local"));
392    }
393
394    #[test]
395    fn test_load_raw_multiple_files_required_missing_fails() {
396        let source = FileSource::builder()
397            .paths([
398                PathBuf::from("/nonexistent/base.toml"),
399                PathBuf::from("/nonexistent/local.toml"),
400            ])
401            .build()
402            .unwrap();
403
404        let error = source.load_raw().unwrap_err();
405
406        assert!(error.is_not_found());
407    }
408
409    #[test]
410    fn test_load_raw_multiple_files_rejects_unknown_format() {
411        let valid = create_temp_file(r#"{"name":"test"}"#, ".json");
412        let invalid = create_temp_file("name=test", ".txt");
413        let source = FileSource::builder()
414            .paths([valid.path(), invalid.path()])
415            .build()
416            .unwrap();
417
418        let error = source.load_raw().unwrap_err();
419
420        assert!(matches!(error, SourceError::InvalidFormat { .. }));
421    }
422
423    #[cfg(feature = "json")]
424    #[test]
425    fn test_load_raw_multiple_files_reports_parse_error() {
426        let valid = create_temp_file(r#"{"name":"test"}"#, ".json");
427        let invalid = create_temp_file("{invalid json", ".json");
428        let source = FileSource::builder()
429            .paths([valid.path(), invalid.path()])
430            .build()
431            .unwrap();
432
433        let error = source.load_raw().unwrap_err();
434
435        assert!(error.is_parse_failed());
436    }
437}