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        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        // Each entry: (name, format, path, source_for_this_entry)
234        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                    // unreachable
329                    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        // Source resource updated to full file path
521        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}