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(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}