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(test)]
230mod tests {
231    use super::*;
232    use std::io::Write;
233    use tempfile::NamedTempFile;
234
235    fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
236        let mut file = NamedTempFile::with_suffix(extension).unwrap();
237        write!(file, "{}", content).unwrap();
238        file
239    }
240
241    #[test]
242    fn test_file_source_builder() {
243        let source = FileSource::builder()
244            .path("/etc/config.toml")
245            .required(false)
246            .build()
247            .unwrap();
248
249        assert!(!source.is_required());
250        assert_eq!(source.paths.len(), 1);
251    }
252
253    #[test]
254    fn test_file_source_builder_no_paths() {
255        let result = FileSource::builder().build();
256        assert!(result.is_err());
257    }
258
259    #[test]
260    fn test_detect_format() {
261        assert_eq!(
262            FileSource::detect_format_from_path(Path::new("config.toml")),
263            Some(Format::Toml)
264        );
265        assert_eq!(
266            FileSource::detect_format_from_path(Path::new("config.json")),
267            Some(Format::Json)
268        );
269        #[cfg(feature = "yaml")]
270        {
271            assert_eq!(
272                FileSource::detect_format_from_path(Path::new("config.yaml")),
273                Some(Format::Yaml)
274            );
275            assert_eq!(
276                FileSource::detect_format_from_path(Path::new("config.yml")),
277                Some(Format::Yaml)
278            );
279        }
280        assert_eq!(
281            FileSource::detect_format_from_path(Path::new("config.txt")),
282            None
283        );
284    }
285
286    #[cfg(feature = "toml")]
287    #[test]
288    fn test_load_raw_toml() {
289        let content = r#"
290        name = "test"
291        value = 42
292        "#;
293        let file = create_temp_file(content, ".toml");
294
295        let source = FileSource::new(file.path());
296        let raw = source.load_raw().unwrap();
297        let str_content = raw.as_str().unwrap();
298        assert!(str_content.as_ref().contains("test"));
299    }
300
301    #[cfg(feature = "json")]
302    #[test]
303    fn test_load_raw_json() {
304        let content = r#"{"name": "test", "value": 42}"#;
305        let file = create_temp_file(content, ".json");
306
307        let source = FileSource::new(file.path());
308        let raw = source.load_raw().unwrap();
309        let str_content = raw.as_str().unwrap();
310        assert!(str_content.as_ref().contains("test"));
311    }
312
313    #[test]
314    fn test_load_file_not_found() {
315        let source = FileSource::new("/nonexistent/config.toml");
316        let result = source.load_raw();
317        assert!(result.is_err());
318    }
319
320    #[test]
321    fn test_load_optional_file_not_found() {
322        let source = FileSource::builder()
323            .path("/nonexistent/config.toml")
324            .required(false)
325            .build()
326            .unwrap();
327
328        let raw = source.load_raw().unwrap();
329        assert!(raw.is_empty());
330    }
331
332    #[test]
333    fn test_metadata() {
334        let source = FileSource::new("config.toml");
335        let meta = source.metadata();
336
337        assert_eq!(meta.name, "file");
338        assert_eq!(source.kind(), SourceKind::File);
339        assert_eq!(meta.priority, 100);
340    }
341}