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 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(|p| p.into()));
40 self
41 }
42
43 #[must_use]
45 pub 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)]
90pub struct FileSource {
91 pub(crate) paths: Vec<PathBuf>,
93 required: bool,
95}
96
97impl FileSource {
98 #[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 #[must_use]
109 pub fn builder() -> FileSourceBuilder {
110 FileSourceBuilder::new()
111 }
112
113 fn detect_format_from_path(path: &Path) -> Option<Format> {
115 Format::from_path(path)
116 }
117
118 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 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 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 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 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 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}