Skip to main content

tanzim_load/
file.rs

1//! Filesystem loader (`file` feature).
2//!
3//! Reads a single configuration file, or every file in a directory.
4//!
5//! **Source:** `file` (the resource is the file or directory path and is required; an empty
6//! resource is rejected with [`Error::InvalidResource`])
7//!
8//! # Behaviour
9//!
10//! - If the resource is a **directory**, each regular file in it becomes one entry; sub-entries
11//!   that are not regular files are skipped (with a warning). Entries are returned in a
12//!   deterministic order (sorted by path).
13//! - If the resource is a **single file**, it becomes one entry.
14//! - `maybe_name` comes from the filename stem and `maybe_format` from the extension; either may
15//!   be `None` (e.g. `README` has no format, `.env` has no name). Both are lower-cased when
16//!   `lowercase = true` (the default).
17//! - Each entry's [`Payload::source`] is narrowed to that file's path, so
18//!   diagnostics point at the exact file rather than the directory.
19//! - Missing paths and permission errors normally surface as
20//!   [`Error::NotFound`] / [`Error::NoAccess`];
21//!   the `ignore` option downgrades them to a skipped entry instead.
22//!
23//! # Options
24//!
25//! - `ignore` — list of `not-found` and/or `no-access` (default `[]`)
26//! - `lowercase` — boolean (default `true`; whether to lowercase entry names and formats)
27//!
28//! # Example
29//!
30//! ```text
31//! file:/path/to/config.json
32//! file(ignore=[not-found]):/optional/config
33//! ```
34
35use 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/// Loader for the `file` source: reads a single file or every file in a directory.
48///
49/// See the [module docs](self) for how names/formats are derived and how the `ignore` and
50/// `lowercase` options behave. Stateless — construct with [`File::new`].
51///
52/// # Example
53///
54/// ```
55/// use tanzim_load::{file::File, Load};
56/// use tanzim_source::SourceBuilder;
57///
58/// // `ignore=[not-found]` turns a missing path into an empty result instead of an error,
59/// // so this example is self-contained.
60/// let source = SourceBuilder::new()
61///     .with_source("file")
62///     .with_resource("/path/to/config") // a file or a directory
63///     .with_option("ignore", vec!["not-found"])
64///     .build()
65///     .unwrap();
66///
67/// let payloads = File::new().load(source).unwrap();
68/// assert!(payloads.is_empty()); // nothing at that path, and not-found is ignored
69/// ```
70#[derive(Default, Clone, Debug)]
71pub struct File;
72
73impl File {
74    /// Create a filesystem loader. Configuration comes from the source's options, not the type.
75    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        // Each entry: (name, format, path, source_for_this_entry)
244        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                    // unreachable
339                    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        // Source resource updated to full file path
531        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}