cfgmatic_source/infrastructure/
file_source.rs1use std::path::{Path, PathBuf};
7
8use cfgmatic_merge::Merge;
9
10use crate::domain::{Format, RawContent, Result, Source, SourceError, SourceKind, SourceMetadata};
11
12#[derive(Debug)]
14pub struct FileSourceBuilder {
15 paths: Vec<PathBuf>,
16 required: bool,
17}
18
19impl FileSourceBuilder {
20 #[must_use]
22 pub const fn new() -> Self {
23 Self {
24 paths: Vec::new(),
25 required: true,
26 }
27 }
28
29 #[must_use]
31 pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
32 self.paths.push(path.into());
33 self
34 }
35
36 #[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 #[must_use]
45 pub const fn required(mut self, required: bool) -> Self {
46 self.required = required;
47 self
48 }
49
50 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#[derive(Debug)]
92pub struct FileSource {
93 pub(crate) paths: Vec<PathBuf>,
95 required: bool,
97}
98
99impl FileSource {
100 #[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 #[must_use]
111 pub const fn builder() -> FileSourceBuilder {
112 FileSourceBuilder::new()
113 }
114
115 fn detect_format_from_path(path: &Path) -> Option<Format> {
117 Format::from_path(path)
118 }
119
120 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 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 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 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}