1use crate::{Error, Load, Payload, Source};
36use cfg_if::cfg_if;
37use std::{
38 fs, io,
39 path::{Path, PathBuf},
40};
41
42pub const NAME: &str = "File";
43pub const SOURCE: &str = "file";
44const IGNORE_NOT_FOUND: &str = "not-found";
45const IGNORE_NO_ACCESS: &str = "no-access";
46
47#[derive(Default, Clone, Debug)]
71pub struct File;
72
73impl File {
74 pub fn new() -> Self {
76 Default::default()
77 }
78
79 fn should_ignore(ignore: &[String], kind: io::ErrorKind) -> bool {
80 match kind {
81 io::ErrorKind::NotFound => ignore.iter().any(|item| item == IGNORE_NOT_FOUND),
82 io::ErrorKind::PermissionDenied => ignore.iter().any(|item| item == IGNORE_NO_ACCESS),
83 _ => false,
84 }
85 }
86
87 fn info<P: AsRef<Path>>(path: P, lowercase: bool) -> Option<(Option<String>, Option<String>)> {
88 let path = path.as_ref();
89 if !path.is_file() {
90 cfg_if! {
91 if #[cfg(feature = "tracing")] {
92 tracing::warn!(msg = "Ignored configuration file directory entry", path = ?path, reason = "not a file");
93 } else if #[cfg(feature = "logging")] {
94 log::warn!("msg=\"Ignored configuration file directory entry\" path={path:?} reason=\"not a file\"");
95 }
96 }
97 return None;
98 }
99
100 let maybe_name = if let Some(stem) = path.file_stem() {
101 let trimmed = stem.to_str().unwrap_or_default().trim();
102 if trimmed.is_empty() {
103 None
104 } else {
105 if lowercase {
106 let lower = trimmed.to_lowercase();
107 if lower != trimmed {
108 cfg_if! {
109 if #[cfg(feature = "tracing")] {
110 tracing::debug!(msg = "Lowercased configuration file entry name", from = trimmed, to = lower.as_str(), path = ?path);
111 } else if #[cfg(feature = "logging")] {
112 log::debug!("msg=\"Lowercased configuration file entry name\" from={trimmed} to={lower} path={path:?}");
113 }
114 }
115 }
116 Some(lower)
117 } else {
118 Some(trimmed.to_string())
119 }
120 }
121 } else {
122 None
123 };
124
125 let maybe_format = if let Some(extension) = path.extension() {
126 if let Some(extension_str) = extension.to_str() {
127 let trimmed = extension_str.trim();
128 if trimmed.is_empty() {
129 None
130 } else {
131 if lowercase {
132 let lower = trimmed.to_lowercase();
133 if lower != trimmed {
134 cfg_if! {
135 if #[cfg(feature = "tracing")] {
136 tracing::debug!(msg = "Lowercased configuration file entry format", from = trimmed, to = lower.as_str(), path = ?path);
137 } else if #[cfg(feature = "logging")] {
138 log::debug!("msg=\"Lowercased configuration file entry format\" from={trimmed} to={lower} path={path:?}");
139 }
140 }
141 }
142 Some(lower)
143 } else {
144 Some(trimmed.to_string())
145 }
146 }
147 } else {
148 None
149 }
150 } else {
151 None
152 };
153
154 Some((maybe_name, maybe_format))
155 }
156}
157
158impl Load for File {
159 fn name(&self) -> &str {
160 NAME
161 }
162
163 fn supported_source_list(&self) -> Vec<String> {
164 vec![SOURCE.to_string()]
165 }
166
167 fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
168 let options = source.options().clone();
169 let resource = source.resource().to_string();
170
171 let ignore =
172 match options.get("ignore") {
173 None => Vec::new(),
174 Some(value) => {
175 let list = value.as_list().ok_or_else(|| Error::InvalidOption {
176 loader: NAME.to_string(),
177 key: "ignore".to_string(),
178 reason: format!("expected list, found {}", value.type_name()),
179 })?;
180 let mut ignore = Vec::with_capacity(list.len());
181 for item in list {
182 ignore.push(item.as_string().cloned().ok_or_else(|| {
183 Error::InvalidOption {
184 loader: NAME.to_string(),
185 key: "ignore".to_string(),
186 reason: format!("expected string, found {}", item.type_name()),
187 }
188 })?);
189 }
190 ignore
191 }
192 };
193
194 for item in &ignore {
195 if item != IGNORE_NOT_FOUND && item != IGNORE_NO_ACCESS {
196 return Err(Error::InvalidOption {
197 loader: NAME.to_string(),
198 key: "ignore".into(),
199 reason: format!(
200 "unknown ignore value `{item}` (expected `not-found` or `no-access`)"
201 ),
202 });
203 }
204 }
205
206 let lowercase = match options.get("lowercase") {
207 None => true,
208 Some(value) => value.as_bool().ok_or_else(|| Error::InvalidOption {
209 loader: NAME.to_string(),
210 key: "lowercase".to_string(),
211 reason: format!("expected boolean, found {}", value.type_name()),
212 })?,
213 };
214
215 if resource.is_empty() {
216 return Err(Error::InvalidResource {
217 loader: NAME.to_string(),
218 resource: resource.to_string(),
219 reason: "resource (file or directory path) is required".into(),
220 });
221 }
222
223 cfg_if! {
224 if #[cfg(feature = "tracing")] {
225 tracing::debug!(msg = "Loading configuration from filesystem", resource = resource, lowercase = lowercase);
226 } else if #[cfg(feature = "logging")] {
227 log::debug!("msg=\"Loading configuration from filesystem\" resource={resource} lowercase={lowercase}");
228 }
229 }
230
231 let path = PathBuf::from(&resource);
232
233 let list: Vec<(Option<String>, Option<String>, PathBuf, Source)> = if path.is_dir() {
235 let entry_list = match fs::read_dir(&path) {
236 Ok(entry_list) => entry_list,
237 Err(error) if Self::should_ignore(&ignore, error.kind()) => {
238 cfg_if! {
239 if #[cfg(feature = "tracing")] {
240 tracing::warn!(msg = "Ignored configuration file directory", path = ?path, reason = ?error);
241 } else if #[cfg(feature = "logging")] {
242 log::debug!("msg=\"Ignored configuration file directory\" path={path:?} reason={error:?}");
243 }
244 }
245 return Ok(Vec::new());
246 }
247 Err(error) if error.kind() == io::ErrorKind::NotFound => {
248 return Err(Error::NotFound {
249 loader: NAME.to_string(),
250 resource: resource.to_string(),
251 item: format!("directory `{path:?}`"),
252 });
253 }
254 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
255 return Err(Error::NoAccess {
256 loader: NAME.to_string(),
257 resource: resource.to_string(),
258 source: error.into(),
259 });
260 }
261 Err(error) => {
262 return Err(Error::Load {
263 loader: NAME.to_string(),
264 resource: resource.to_string(),
265 description: "load directory file list".into(),
266 source: error.into(),
267 });
268 }
269 };
270
271 let mut filtered_entry_list = Vec::new();
272 for maybe_entry in entry_list {
273 let entry = match maybe_entry {
274 Ok(entry) => entry,
275 Err(error) if Self::should_ignore(&ignore, error.kind()) => {
276 cfg_if! {
277 if #[cfg(feature = "tracing")] {
278 tracing::warn!(msg = "Ignored configuration file directory entry", path = ?path, reason = ?error);
279 } else if #[cfg(feature = "logging")] {
280 log::warn!("msg=\"Ignored configuration file directory entry\" path={path:?} reason={error:?}");
281 }
282 }
283 continue;
284 }
285 Err(error) => {
286 return Err(Error::Load {
287 loader: NAME.to_string(),
288 resource: resource.to_string(),
289 description: "load directory file list".into(),
290 source: error.into(),
291 });
292 }
293 };
294
295 let entry_path = entry.path();
296 let (maybe_name, maybe_format) = if let Some((maybe_name, maybe_format)) =
297 Self::info(&entry_path, lowercase)
298 {
299 (maybe_name, maybe_format)
300 } else {
301 cfg_if! {
302 if #[cfg(feature = "tracing")] {
303 tracing::warn!(msg = "Ignored configuration file directory entry", path = ?entry_path, reason = "not a file");
304 } else if #[cfg(feature = "logging")] {
305 log::warn!("msg=\"Ignored configuration file directory entry\" path={entry_path:?} reason=\"not a file\"");
306 }
307 }
308 continue;
309 };
310 filtered_entry_list.push((
311 maybe_name,
312 maybe_format,
313 entry_path.clone(),
314 source
315 .clone()
316 .with_resource(entry_path.to_string_lossy().to_string()),
317 ));
318 }
319
320 filtered_entry_list
321 .sort_by_key(|(_name, _format, entry_path, _source)| entry_path.clone());
322 filtered_entry_list
323 } else if path.is_file() {
324 let (maybe_name, maybe_format) =
325 if let Some((maybe_name, maybe_format)) = Self::info(&path, lowercase) {
326 (maybe_name, maybe_format)
327 } else {
328 return Err(Error::InvalidResource {
330 loader: NAME.to_string(),
331 resource: resource.to_string(),
332 reason: "resource is not a regular file".into(),
333 });
334 };
335 Vec::from([(
336 maybe_name,
337 maybe_format,
338 path.clone(),
339 source
340 .clone()
341 .with_resource(path.to_string_lossy().to_string()),
342 )])
343 } else if path.exists() {
344 return Err(Error::InvalidResource {
345 loader: NAME.to_string(),
346 resource: resource.to_string(),
347 reason: "resource is not a directory or regular file".into(),
348 });
349 } else if Self::should_ignore(&ignore, io::ErrorKind::NotFound) {
350 return Ok(Vec::new());
351 } else {
352 return Err(Error::NotFound {
353 loader: NAME.to_string(),
354 resource: resource.to_string(),
355 item: format!("path `{path:?}`"),
356 });
357 };
358
359 let mut payload_list = Vec::with_capacity(list.len());
360 for (maybe_name, maybe_format, path, source) in list {
361 let content = match fs::read(&path) {
362 Ok(content) => Some(content),
363 Err(error) if Self::should_ignore(&ignore, error.kind()) => {
364 cfg_if! {
365 if #[cfg(feature = "tracing")] {
366 tracing::warn!(msg = "Ignored configuration file", path = ?path, reason = ?error);
367 } else if #[cfg(feature = "logging")] {
368 log::warn!("msg=\"Ignored configuration file\" path={path:?} reason={error:?}");
369 }
370 }
371 None
372 }
373 Err(error) if error.kind() == io::ErrorKind::NotFound => {
374 return Err(Error::NotFound {
375 loader: NAME.to_string(),
376 resource: resource.to_string(),
377 item: format!("file `{path:?}`"),
378 });
379 }
380 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
381 return Err(Error::NoAccess {
382 loader: NAME.to_string(),
383 resource: resource.to_string(),
384 source: error.into(),
385 });
386 }
387 Err(error) => {
388 return Err(Error::Load {
389 loader: NAME.to_string(),
390 resource: resource.to_string(),
391 description: format!("read contents of file `{path:?}`"),
392 source: error.into(),
393 });
394 }
395 };
396 if let Some(content) = content {
397 cfg_if! {
398 if #[cfg(feature = "tracing")] {
399 tracing::trace!(
400 msg = "Read configuration file",
401 name = ?maybe_name.as_deref().unwrap_or("<empty>"),
402 format = ?maybe_format.as_deref().unwrap_or("<empty>"),
403 path = ?path,
404 bytes = content.len(),
405 );
406 } else if #[cfg(feature = "logging")] {
407 log::trace!(
408 "msg=\"Read configuration file\" name={} format={} path={} bytes={}",
409 maybe_name.as_deref().unwrap_or("<empty>"),
410 maybe_format.as_deref().unwrap_or("<empty>"),
411 path.to_string_lossy(),
412 content.len(),
413 );
414 }
415 }
416 payload_list.push(Payload {
417 source,
418 maybe_name,
419 maybe_format,
420 content,
421 });
422 }
423 }
424 cfg_if! {
425 if #[cfg(feature = "tracing")] {
426 tracing::info!(msg = "Loaded configuration from filesystem", file_count = payload_list.len(), resource = resource);
427 } else if #[cfg(feature = "logging")] {
428 log::info!("msg=\"Loaded configuration from filesystem\" file_count={} resource={resource}", payload_list.len());
429 }
430 }
431 Ok(payload_list)
432 }
433}
434
435#[cfg(all(test, feature = "file"))]
436mod tests {
437 use super::*;
438 use std::fs;
439 use tanzim_source::SourceBuilder;
440 use tempdir::TempDir;
441
442 fn make_source(resource: &str) -> Source {
443 SourceBuilder::new()
444 .with_source("file")
445 .with_resource(resource)
446 .build()
447 .unwrap()
448 }
449
450 #[test]
451 fn load_resolves_name_and_format_from_path() {
452 let tmp = TempDir::new("tanzim-file-name-format").unwrap();
453 fs::write(tmp.path().join("foo.JSON"), b"{}").unwrap();
454 fs::write(tmp.path().join("README"), b"x").unwrap();
455 fs::write(tmp.path().join(".env"), b"x").unwrap();
456 let resource = tmp.path().display().to_string();
457 let loaded = File::new().load(make_source(&resource)).unwrap();
458
459 let mut foo = None;
460 let mut readme = None;
461 let mut dotenv = None;
462 for payload in &loaded {
463 if payload.maybe_name == Some("foo".to_string()) {
464 foo = Some(payload);
465 } else if payload.maybe_name == Some("readme".to_string()) {
466 readme = Some(payload);
467 } else if payload.maybe_name == Some(".env".to_string()) {
468 dotenv = Some(payload);
469 }
470 }
471
472 assert_eq!(foo.expect("foo").maybe_format, Some("json".to_string()));
473 assert!(readme.expect("readme").maybe_format.is_none());
474 assert!(dotenv.expect(".env").maybe_format.is_none());
475 }
476
477 #[test]
478 fn load_reads_files_with_and_without_extension() {
479 let tmp = TempDir::new("tanzim-file-edge-names").unwrap();
480 fs::write(tmp.path().join("foo.json"), br#"{"hello":"world"}"#).unwrap();
481 fs::write(tmp.path().join("README"), b"no extension").unwrap();
482 fs::write(tmp.path().join(".env"), b"KEY=value").unwrap();
483 let resource = tmp.path().display().to_string();
484 let loaded = File::new().load(make_source(&resource)).unwrap();
485 assert_eq!(loaded.len(), 3);
486
487 let mut foo = None;
488 let mut readme = None;
489 let mut dotenv = None;
490 for payload in &loaded {
491 if payload.maybe_name == Some("foo".to_string()) {
492 foo = Some(payload);
493 } else if payload.maybe_name == Some("readme".to_string()) {
494 readme = Some(payload);
495 } else if payload.maybe_name == Some(".env".to_string()) {
496 dotenv = Some(payload);
497 }
498 }
499
500 let foo = foo.expect("foo payload");
501 assert_eq!(foo.maybe_format, Some("json".to_string()));
502
503 let readme = readme.expect("readme payload");
504 assert!(readme.maybe_format.is_none());
505
506 let dotenv = dotenv.expect(".env payload");
507 assert!(dotenv.maybe_format.is_none());
508 }
509
510 #[test]
511 fn load_reads_files_from_directory() {
512 let tmp = TempDir::new("tanzim-file").unwrap();
513 fs::write(tmp.path().join("foo.json"), br#"{"hello":"world"}"#).unwrap();
514 let resource = tmp.path().display().to_string();
515 let loaded = File::new().load(make_source(&resource)).unwrap();
516 assert_eq!(loaded.len(), 1);
517 let payload = &loaded[0];
518 assert_eq!(payload.maybe_name, Some("foo".to_string()));
519 assert_eq!(payload.maybe_format, Some("json".to_string()));
520 assert!(payload.source.resource().ends_with("foo.json"));
522 }
523
524 #[test]
525 fn load_ignores_not_found_when_configured() {
526 let source = SourceBuilder::new()
527 .with_source("file")
528 .with_resource("/no/such/path")
529 .with_option("ignore", vec!["not-found"])
530 .build()
531 .unwrap();
532 let loaded = File::new().load(source).unwrap();
533 assert!(loaded.is_empty());
534 }
535
536 #[test]
537 fn load_requires_resource() {
538 let source = SourceBuilder::new().with_source("file").build().unwrap();
539 let error = File::new().load(source).unwrap_err();
540 assert!(matches!(error, Error::InvalidResource { .. }));
541 }
542
543 #[test]
544 fn load_single_file_path() {
545 let tmp = TempDir::new("tanzim-file-single").unwrap();
546 let file_path = tmp.path().join("solo.json");
547 fs::write(&file_path, br#"{"ok":true}"#).unwrap();
548 let loaded = File::new()
549 .load(make_source(&file_path.display().to_string()))
550 .unwrap();
551 assert_eq!(loaded.len(), 1);
552 assert_eq!(loaded[0].maybe_name.as_deref(), Some("solo"));
553 assert_eq!(loaded[0].source.resource(), file_path.display().to_string());
554 }
555
556 #[test]
557 fn load_ignores_unknown_option() {
558 let tmp = TempDir::new("tanzim-file-unknown-opt").unwrap();
559 fs::write(tmp.path().join("foo.json"), b"{}").unwrap();
560 let source = SourceBuilder::new()
561 .with_source("file")
562 .with_resource(tmp.path().display().to_string())
563 .with_option("bogus", true)
564 .build()
565 .unwrap();
566 let loaded = File::new().load(source).unwrap();
567 assert_eq!(loaded.len(), 1);
568 }
569
570 #[test]
571 fn load_rejects_invalid_ignore_list() {
572 let source = SourceBuilder::new()
573 .with_source("file")
574 .with_resource("/tmp")
575 .with_option("ignore", "not-a-list")
576 .build()
577 .unwrap();
578 let error = File::new().load(source).unwrap_err();
579 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "ignore"));
580 }
581
582 #[test]
583 fn load_rejects_unknown_ignore_value() {
584 let source = SourceBuilder::new()
585 .with_source("file")
586 .with_resource("/tmp")
587 .with_option("ignore", vec!["bogus"])
588 .build()
589 .unwrap();
590 let error = File::new().load(source).unwrap_err();
591 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "ignore"));
592 }
593
594 #[test]
595 fn load_preserves_case_when_lowercase_disabled() {
596 let tmp = TempDir::new("tanzim-file-case").unwrap();
597 fs::write(tmp.path().join("Demo.JSON"), b"{}").unwrap();
598 let source = SourceBuilder::new()
599 .with_source("file")
600 .with_resource(tmp.path().display().to_string())
601 .with_option("lowercase", false)
602 .build()
603 .unwrap();
604 let loaded = File::new().load(source).unwrap();
605 assert_eq!(loaded[0].maybe_name.as_deref(), Some("Demo"));
606 assert_eq!(loaded[0].maybe_format.as_deref(), Some("JSON"));
607 }
608
609 #[test]
610 fn load_reports_not_found_for_missing_path() {
611 let source = SourceBuilder::new()
612 .with_source("file")
613 .with_resource("/no/such/tanzim-file-path")
614 .build()
615 .unwrap();
616 let error = File::new().load(source).unwrap_err();
617 assert!(matches!(error, Error::NotFound { .. }));
618 }
619}