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 for key in options.keys() {
172 if key != "ignore" && key != "lowercase" {
173 return Err(Error::InvalidOption {
174 loader: NAME.to_string(),
175 key: key.to_string(),
176 reason: "unknown option".into(),
177 });
178 }
179 }
180
181 let ignore =
182 match options.get("ignore") {
183 None => Vec::new(),
184 Some(value) => {
185 let list = value.as_list().ok_or_else(|| Error::InvalidOption {
186 loader: NAME.to_string(),
187 key: "ignore".to_string(),
188 reason: format!("expected list, found {}", value.type_name()),
189 })?;
190 let mut ignore = Vec::with_capacity(list.len());
191 for item in list {
192 ignore.push(item.as_string().cloned().ok_or_else(|| {
193 Error::InvalidOption {
194 loader: NAME.to_string(),
195 key: "ignore".to_string(),
196 reason: format!("expected string, found {}", item.type_name()),
197 }
198 })?);
199 }
200 ignore
201 }
202 };
203
204 for item in &ignore {
205 if item != IGNORE_NOT_FOUND && item != IGNORE_NO_ACCESS {
206 return Err(Error::InvalidOption {
207 loader: NAME.to_string(),
208 key: "ignore".into(),
209 reason: format!(
210 "unknown ignore value `{item}` (expected `not-found` or `no-access`)"
211 ),
212 });
213 }
214 }
215
216 let lowercase = match options.get("lowercase") {
217 None => true,
218 Some(value) => value.as_bool().ok_or_else(|| Error::InvalidOption {
219 loader: NAME.to_string(),
220 key: "lowercase".to_string(),
221 reason: format!("expected boolean, found {}", value.type_name()),
222 })?,
223 };
224
225 if resource.is_empty() {
226 return Err(Error::InvalidResource {
227 loader: NAME.to_string(),
228 resource: resource.to_string(),
229 reason: "resource (file or directory path) is required".into(),
230 });
231 }
232
233 cfg_if! {
234 if #[cfg(feature = "tracing")] {
235 tracing::debug!(msg = "Loading configuration from filesystem", resource = resource, lowercase = lowercase);
236 } else if #[cfg(feature = "logging")] {
237 log::debug!("msg=\"Loading configuration from filesystem\" resource={resource} lowercase={lowercase}");
238 }
239 }
240
241 let path = PathBuf::from(&resource);
242
243 let list: Vec<(Option<String>, Option<String>, PathBuf, Source)> = if path.is_dir() {
245 let entry_list = match fs::read_dir(&path) {
246 Ok(entry_list) => entry_list,
247 Err(error) if Self::should_ignore(&ignore, error.kind()) => {
248 cfg_if! {
249 if #[cfg(feature = "tracing")] {
250 tracing::warn!(msg = "Ignored configuration file directory", path = ?path, reason = ?error);
251 } else if #[cfg(feature = "logging")] {
252 log::debug!("msg=\"Ignored configuration file directory\" path={path:?} reason={error:?}");
253 }
254 }
255 return Ok(Vec::new());
256 }
257 Err(error) if error.kind() == io::ErrorKind::NotFound => {
258 return Err(Error::NotFound {
259 loader: NAME.to_string(),
260 resource: resource.to_string(),
261 item: format!("directory `{path:?}`"),
262 });
263 }
264 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
265 return Err(Error::NoAccess {
266 loader: NAME.to_string(),
267 resource: resource.to_string(),
268 source: error.into(),
269 });
270 }
271 Err(error) => {
272 return Err(Error::Load {
273 loader: NAME.to_string(),
274 resource: resource.to_string(),
275 description: "load directory file list".into(),
276 source: error.into(),
277 });
278 }
279 };
280
281 let mut filtered_entry_list = Vec::new();
282 for maybe_entry in entry_list {
283 let entry = match maybe_entry {
284 Ok(entry) => entry,
285 Err(error) if Self::should_ignore(&ignore, error.kind()) => {
286 cfg_if! {
287 if #[cfg(feature = "tracing")] {
288 tracing::warn!(msg = "Ignored configuration file directory entry", path = ?path, reason = ?error);
289 } else if #[cfg(feature = "logging")] {
290 log::warn!("msg=\"Ignored configuration file directory entry\" path={path:?} reason={error:?}");
291 }
292 }
293 continue;
294 }
295 Err(error) => {
296 return Err(Error::Load {
297 loader: NAME.to_string(),
298 resource: resource.to_string(),
299 description: "load directory file list".into(),
300 source: error.into(),
301 });
302 }
303 };
304
305 let entry_path = entry.path();
306 let (maybe_name, maybe_format) = if let Some((maybe_name, maybe_format)) =
307 Self::info(&entry_path, lowercase)
308 {
309 (maybe_name, maybe_format)
310 } else {
311 cfg_if! {
312 if #[cfg(feature = "tracing")] {
313 tracing::warn!(msg = "Ignored configuration file directory entry", path = ?entry_path, reason = "not a file");
314 } else if #[cfg(feature = "logging")] {
315 log::warn!("msg=\"Ignored configuration file directory entry\" path={entry_path:?} reason=\"not a file\"");
316 }
317 }
318 continue;
319 };
320 filtered_entry_list.push((
321 maybe_name,
322 maybe_format,
323 entry_path.clone(),
324 source
325 .clone()
326 .with_resource(entry_path.to_string_lossy().to_string()),
327 ));
328 }
329
330 filtered_entry_list
331 .sort_by_key(|(_name, _format, entry_path, _source)| entry_path.clone());
332 filtered_entry_list
333 } else if path.is_file() {
334 let (maybe_name, maybe_format) =
335 if let Some((maybe_name, maybe_format)) = Self::info(&path, lowercase) {
336 (maybe_name, maybe_format)
337 } else {
338 return Err(Error::InvalidResource {
340 loader: NAME.to_string(),
341 resource: resource.to_string(),
342 reason: "resource is not a regular file".into(),
343 });
344 };
345 Vec::from([(
346 maybe_name,
347 maybe_format,
348 path.clone(),
349 source
350 .clone()
351 .with_resource(path.to_string_lossy().to_string()),
352 )])
353 } else if path.exists() {
354 return Err(Error::InvalidResource {
355 loader: NAME.to_string(),
356 resource: resource.to_string(),
357 reason: "resource is not a directory or regular file".into(),
358 });
359 } else if Self::should_ignore(&ignore, io::ErrorKind::NotFound) {
360 return Ok(Vec::new());
361 } else {
362 return Err(Error::NotFound {
363 loader: NAME.to_string(),
364 resource: resource.to_string(),
365 item: format!("path `{path:?}`"),
366 });
367 };
368
369 let mut payload_list = Vec::with_capacity(list.len());
370 for (maybe_name, maybe_format, path, source) in list {
371 let content = match fs::read(&path) {
372 Ok(content) => Some(content),
373 Err(error) if Self::should_ignore(&ignore, error.kind()) => {
374 cfg_if! {
375 if #[cfg(feature = "tracing")] {
376 tracing::warn!(msg = "Ignored configuration file", path = ?path, reason = ?error);
377 } else if #[cfg(feature = "logging")] {
378 log::warn!("msg=\"Ignored configuration file\" path={path:?} reason={error:?}");
379 }
380 }
381 None
382 }
383 Err(error) if error.kind() == io::ErrorKind::NotFound => {
384 return Err(Error::NotFound {
385 loader: NAME.to_string(),
386 resource: resource.to_string(),
387 item: format!("file `{path:?}`"),
388 });
389 }
390 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
391 return Err(Error::NoAccess {
392 loader: NAME.to_string(),
393 resource: resource.to_string(),
394 source: error.into(),
395 });
396 }
397 Err(error) => {
398 return Err(Error::Load {
399 loader: NAME.to_string(),
400 resource: resource.to_string(),
401 description: format!("read contents of file `{path:?}`"),
402 source: error.into(),
403 });
404 }
405 };
406 if let Some(content) = content {
407 cfg_if! {
408 if #[cfg(feature = "tracing")] {
409 tracing::trace!(
410 msg = "Read configuration file",
411 name = ?maybe_name.as_deref().unwrap_or("<empty>"),
412 format = ?maybe_format.as_deref().unwrap_or("<empty>"),
413 path = ?path,
414 bytes = content.len(),
415 );
416 } else if #[cfg(feature = "logging")] {
417 log::trace!(
418 "msg=\"Read configuration file\" name={} format={} path={} bytes={}",
419 maybe_name.as_deref().unwrap_or("<empty>"),
420 maybe_format.as_deref().unwrap_or("<empty>"),
421 path.to_string_lossy(),
422 content.len(),
423 );
424 }
425 }
426 payload_list.push(Payload {
427 source,
428 maybe_name,
429 maybe_format,
430 content,
431 });
432 }
433 }
434 cfg_if! {
435 if #[cfg(feature = "tracing")] {
436 tracing::info!(msg = "Loaded configuration from filesystem", file_count = payload_list.len(), resource = resource);
437 } else if #[cfg(feature = "logging")] {
438 log::info!("msg=\"Loaded configuration from filesystem\" file_count={} resource={resource}", payload_list.len());
439 }
440 }
441 Ok(payload_list)
442 }
443}
444
445#[cfg(all(test, feature = "file"))]
446mod tests {
447 use super::*;
448 use std::fs;
449 use tanzim_source::SourceBuilder;
450 use tempdir::TempDir;
451
452 fn make_source(resource: &str) -> Source {
453 SourceBuilder::new()
454 .with_source("file")
455 .with_resource(resource)
456 .build()
457 .unwrap()
458 }
459
460 #[test]
461 fn load_resolves_name_and_format_from_path() {
462 let tmp = TempDir::new("tanzim-file-name-format").unwrap();
463 fs::write(tmp.path().join("foo.JSON"), b"{}").unwrap();
464 fs::write(tmp.path().join("README"), b"x").unwrap();
465 fs::write(tmp.path().join(".env"), b"x").unwrap();
466 let resource = tmp.path().display().to_string();
467 let loaded = File::new().load(make_source(&resource)).unwrap();
468
469 let mut foo = None;
470 let mut readme = None;
471 let mut dotenv = None;
472 for payload in &loaded {
473 if payload.maybe_name == Some("foo".to_string()) {
474 foo = Some(payload);
475 } else if payload.maybe_name == Some("readme".to_string()) {
476 readme = Some(payload);
477 } else if payload.maybe_name == Some(".env".to_string()) {
478 dotenv = Some(payload);
479 }
480 }
481
482 assert_eq!(foo.expect("foo").maybe_format, Some("json".to_string()));
483 assert!(readme.expect("readme").maybe_format.is_none());
484 assert!(dotenv.expect(".env").maybe_format.is_none());
485 }
486
487 #[test]
488 fn load_reads_files_with_and_without_extension() {
489 let tmp = TempDir::new("tanzim-file-edge-names").unwrap();
490 fs::write(tmp.path().join("foo.json"), br#"{"hello":"world"}"#).unwrap();
491 fs::write(tmp.path().join("README"), b"no extension").unwrap();
492 fs::write(tmp.path().join(".env"), b"KEY=value").unwrap();
493 let resource = tmp.path().display().to_string();
494 let loaded = File::new().load(make_source(&resource)).unwrap();
495 assert_eq!(loaded.len(), 3);
496
497 let mut foo = None;
498 let mut readme = None;
499 let mut dotenv = None;
500 for payload in &loaded {
501 if payload.maybe_name == Some("foo".to_string()) {
502 foo = Some(payload);
503 } else if payload.maybe_name == Some("readme".to_string()) {
504 readme = Some(payload);
505 } else if payload.maybe_name == Some(".env".to_string()) {
506 dotenv = Some(payload);
507 }
508 }
509
510 let foo = foo.expect("foo payload");
511 assert_eq!(foo.maybe_format, Some("json".to_string()));
512
513 let readme = readme.expect("readme payload");
514 assert!(readme.maybe_format.is_none());
515
516 let dotenv = dotenv.expect(".env payload");
517 assert!(dotenv.maybe_format.is_none());
518 }
519
520 #[test]
521 fn load_reads_files_from_directory() {
522 let tmp = TempDir::new("tanzim-file").unwrap();
523 fs::write(tmp.path().join("foo.json"), br#"{"hello":"world"}"#).unwrap();
524 let resource = tmp.path().display().to_string();
525 let loaded = File::new().load(make_source(&resource)).unwrap();
526 assert_eq!(loaded.len(), 1);
527 let payload = &loaded[0];
528 assert_eq!(payload.maybe_name, Some("foo".to_string()));
529 assert_eq!(payload.maybe_format, Some("json".to_string()));
530 assert!(payload.source.resource().ends_with("foo.json"));
532 }
533
534 #[test]
535 fn load_ignores_not_found_when_configured() {
536 let source = SourceBuilder::new()
537 .with_source("file")
538 .with_resource("/no/such/path")
539 .with_option("ignore", vec!["not-found"])
540 .build()
541 .unwrap();
542 let loaded = File::new().load(source).unwrap();
543 assert!(loaded.is_empty());
544 }
545
546 #[test]
547 fn load_requires_resource() {
548 let source = SourceBuilder::new().with_source("file").build().unwrap();
549 let error = File::new().load(source).unwrap_err();
550 assert!(matches!(error, Error::InvalidResource { .. }));
551 }
552
553 #[test]
554 fn load_single_file_path() {
555 let tmp = TempDir::new("tanzim-file-single").unwrap();
556 let file_path = tmp.path().join("solo.json");
557 fs::write(&file_path, br#"{"ok":true}"#).unwrap();
558 let loaded = File::new()
559 .load(make_source(&file_path.display().to_string()))
560 .unwrap();
561 assert_eq!(loaded.len(), 1);
562 assert_eq!(loaded[0].maybe_name.as_deref(), Some("solo"));
563 assert_eq!(loaded[0].source.resource(), file_path.display().to_string());
564 }
565
566 #[test]
567 fn load_rejects_unknown_option() {
568 let source = SourceBuilder::new()
569 .with_source("file")
570 .with_resource("/tmp")
571 .with_option("bogus", true)
572 .build()
573 .unwrap();
574 let error = File::new().load(source).unwrap_err();
575 assert!(matches!(error, Error::InvalidOption { .. }));
576 }
577
578 #[test]
579 fn load_rejects_invalid_ignore_list() {
580 let source = SourceBuilder::new()
581 .with_source("file")
582 .with_resource("/tmp")
583 .with_option("ignore", "not-a-list")
584 .build()
585 .unwrap();
586 let error = File::new().load(source).unwrap_err();
587 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "ignore"));
588 }
589
590 #[test]
591 fn load_rejects_unknown_ignore_value() {
592 let source = SourceBuilder::new()
593 .with_source("file")
594 .with_resource("/tmp")
595 .with_option("ignore", vec!["bogus"])
596 .build()
597 .unwrap();
598 let error = File::new().load(source).unwrap_err();
599 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "ignore"));
600 }
601
602 #[test]
603 fn load_preserves_case_when_lowercase_disabled() {
604 let tmp = TempDir::new("tanzim-file-case").unwrap();
605 fs::write(tmp.path().join("Demo.JSON"), b"{}").unwrap();
606 let source = SourceBuilder::new()
607 .with_source("file")
608 .with_resource(tmp.path().display().to_string())
609 .with_option("lowercase", false)
610 .build()
611 .unwrap();
612 let loaded = File::new().load(source).unwrap();
613 assert_eq!(loaded[0].maybe_name.as_deref(), Some("Demo"));
614 assert_eq!(loaded[0].maybe_format.as_deref(), Some("JSON"));
615 }
616
617 #[test]
618 fn load_reports_not_found_for_missing_path() {
619 let source = SourceBuilder::new()
620 .with_source("file")
621 .with_resource("/no/such/tanzim-file-path")
622 .build()
623 .unwrap();
624 let error = File::new().load(source).unwrap_err();
625 assert!(matches!(error, Error::NotFound { .. }));
626 }
627}