Skip to main content

tanzim_load/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::error::Error as StdError;
4
5pub use tanzim_source::{OptionValue, Options, Source};
6
7pub mod closure;
8#[cfg(feature = "env")]
9pub mod env;
10#[cfg(feature = "file")]
11pub mod file;
12#[cfg(feature = "http")]
13pub mod http;
14
15/// Raw bytes for one configuration entry, with its declaring [`Source`].
16///
17/// A loader returns one `Payload` per entry it finds. Fields:
18///
19/// - `source` — the concrete resource this entry came from. When one [`Source`] expands to
20///   several entries (e.g. a directory of files), set this to the *specific* resource loaded,
21///   not the original directory — clone the incoming source and narrow it with
22///   [`Source::with_resource`]. Downstream stages surface it in diagnostics.
23/// - `maybe_name` — the entry's name, or `None` for an unnamed payload. All `None`-named
24///   payloads merge together into the root; distinct names stay separate. Named entries with the
25///   same name also merge.
26/// - `maybe_format` — a hint for the parser stage selecting the parser (`json`, `env`, …), or
27///   `None` to let the parser infer/default. It is a hint, not a guarantee.
28/// - `content` — the unparsed bytes, passed through verbatim to the parser.
29///
30/// **Lowercase convention:** built-in loaders lower-case `maybe_name` and `maybe_format` when
31/// their Source `lowercase` option is `true` (the default). Custom loaders are encouraged to
32/// follow the same convention so entry names merge predictably across sources.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Payload {
35    /// Concrete resource this entry was loaded from (narrowed from the incoming [`Source`]).
36    pub source: Source,
37    /// Entry name; `None` merges into the root alongside other unnamed payloads.
38    pub maybe_name: Option<String>,
39    /// Parser hint (e.g. `json`, `env`); `None` lets the parser infer or default.
40    pub maybe_format: Option<String>,
41    /// Unparsed bytes, forwarded verbatim to the parser stage.
42    pub content: Vec<u8>,
43}
44
45/// Errors a [`Load`] implementation can return.
46///
47/// Each variant carries a `loader` field (set to your [`Load::name`]) so messages identify the
48/// source. See [`Load`]'s "Choosing an error" section for guidance on which to pick.
49#[derive(Debug, thiserror::Error)]
50pub enum Error {
51    /// The requested resource or entry does not exist and was not configured to be ignored.
52    /// `item` names what was missing (e.g. `` `file "app.json"` ``).
53    #[error("{loader} configuration loader could not find {item} at `{resource}`")]
54    NotFound {
55        loader: String,
56        resource: String,
57        item: String,
58    },
59    /// Access was denied (e.g. filesystem permissions, HTTP 401/403). `source` carries the
60    /// underlying backend error.
61    #[error("{loader} configuration loader has no access to `{resource}`")]
62    NoAccess {
63        loader: String,
64        resource: String,
65        source: Box<dyn StdError + Send + Sync>,
66    },
67    /// The operation exceeded its deadline. `timeout_in_seconds` is the limit that was hit;
68    /// `source` carries the underlying backend error.
69    #[error(
70        "{loader} configuration loader reached timeout `{timeout_in_seconds}s` for `{resource}`"
71    )]
72    Timeout {
73        loader: String,
74        resource: String,
75        timeout_in_seconds: u64,
76        source: Box<dyn StdError + Send + Sync>,
77    },
78    /// A known option has the wrong type or value. `key` is the option name; `reason` explains the
79    /// problem (commonly built from [`OptionValue::type_name`] on a type mismatch). Loaders only
80    /// validate options they read — unknown keys are ignored.
81    #[error("{loader} configuration loader invalid option `{key}`: {reason}")]
82    InvalidOption {
83        loader: String,
84        key: String,
85        reason: String,
86    },
87    /// The resource string is empty or malformed for this loader (e.g. a required path is
88    /// missing). `reason` explains what was expected.
89    #[error("{loader} configuration loader invalid resource `{resource}`: {reason}")]
90    InvalidResource {
91        loader: String,
92        resource: String,
93        reason: String,
94    },
95    /// Two entries resolve to the same `name` with differing formats (`format_1` vs `format_2`),
96    /// so the loader cannot pick one unambiguously.
97    #[error(
98        "{loader} configuration loader found duplicate configurations `{resource}/{name}.({format_1}|{format_2})`"
99    )]
100    Duplicate {
101        loader: String,
102        resource: String,
103        name: String,
104        format_1: String,
105        format_2: String,
106    },
107    /// Catch-all backend failure that doesn't fit the variants above. `description` completes the
108    /// phrase "could not {description}" (e.g. `"read contents of file"`); `source` carries the
109    /// underlying error.
110    #[error("{loader} configuration loader could not {description} `{resource}`")]
111    Load {
112        loader: String,
113        resource: String,
114        description: String,
115        source: Box<dyn StdError + Send + Sync>,
116    },
117    /// Bridge for opaque errors via `?`/`From`, when none of the structured variants apply.
118    #[error(transparent)]
119    Other(#[from] Box<dyn StdError + Send + Sync>),
120}
121
122/// Loads raw configuration bytes from a declared source.
123///
124/// Implement this to add a new source kind (protocol, service, database, …). This is the first
125/// stage of the pipeline: it only *fetches bytes*, it does not parse them — [`Payload::content`]
126/// is handed to the parser stage unchanged.
127///
128/// # Contract
129///
130/// - [`load`](Load::load) takes ownership of one [`Source`] and returns one [`Payload`] per
131///   configuration entry found. A single source may expand to many entries (e.g. every file in a
132///   directory) → return many payloads; finding nothing is `Ok(vec![])`, not an error.
133/// - Set [`Payload::source`] on each entry to the *concrete* resource loaded, not the original
134///   source — clone it and narrow with [`Source::with_resource`]. This keeps diagnostics precise.
135/// - Use [`Payload::maybe_name`] for the entry name (`None` merges into the root with all other
136///   unnamed entries) and [`Payload::maybe_format`] as a parser hint (e.g. `json`).
137/// - Follow the lowercase convention: when your `lowercase` option is `true` (recommended
138///   default), lower-case names and formats so entries merge predictably across sources.
139///
140/// # Reading options
141///
142/// Options declared on the source (e.g. `file(ignore=[not-found])`) are available via
143/// [`Source::options`]. Look each up with [`Options::get`], convert with the typed accessors
144/// ([`OptionValue::as_bool`], [`OptionValue::as_string`], [`OptionValue::as_list`], …), and on a
145/// type mismatch build the `reason` from [`OptionValue::type_name`]. Only look up the options your
146/// loader understands; ignore any others. See the `file` loader's `load` for a complete worked
147/// pattern.
148///
149/// # Choosing an error
150///
151/// - [`Error::InvalidResource`] — the resource string is empty/malformed for this loader.
152/// - [`Error::InvalidOption`] — a known option has the wrong type or value.
153/// - [`Error::NotFound`] — the resource/entry doesn't exist (and isn't being ignored).
154/// - [`Error::NoAccess`] — permission denied by the backend.
155/// - [`Error::Timeout`] — a deadline was exceeded.
156/// - [`Error::Duplicate`] — two entries collide on the same name with different formats.
157/// - [`Error::Load`] — any other backend failure (`description` completes "could not …").
158/// - [`Error::Other`] — bridge for an opaque error via `?`.
159///
160/// # Registering
161///
162/// Pass an instance to `tanzim::Config::with_loader`. The pipeline dispatches each source to the
163/// first loader whose [`supported_source_list`](Load::supported_source_list) contains the source
164/// string, so it may advertise several (e.g. `["http", "https"]`). For a one-off loader you don't
165/// want to define a type for, use [`closure::Closure`] instead of implementing this trait.
166///
167/// # Example — collecting specific environment variables
168///
169/// A loader that reads the variable names listed in its `keys` option and returns them as one
170/// `env`-format payload. It shows the whole contract: reading a typed option, mapping failures to
171/// the right [`Error`] variant, and building a [`Payload`].
172///
173/// ```rust
174/// use std::env;
175/// use tanzim_load::{Error, Load, Payload, Source};
176///
177/// struct SelectedEnv;
178///
179/// impl Load for SelectedEnv {
180///     fn name(&self) -> &str { "selected-env" }
181///     fn supported_source_list(&self) -> Vec<String> { vec!["selected-env".into()] }
182///
183///     fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
184///         // Read the `keys` option — a required list of variable names.
185///         let value = source.options().get("keys").ok_or_else(|| Error::InvalidOption {
186///             loader: self.name().into(),
187///             key: "keys".into(),
188///             reason: "required".into(),
189///         })?;
190///         let keys = value.as_list().ok_or_else(|| Error::InvalidOption {
191///             loader: self.name().into(),
192///             key: "keys".into(),
193///             reason: format!("expected list, found {}", value.type_name()),
194///         })?;
195///
196///         // Collect each requested variable into a `KEY="value"` line.
197///         let mut lines = Vec::new();
198///         for item in keys {
199///             let key = item.as_string().ok_or_else(|| Error::InvalidOption {
200///                 loader: self.name().into(),
201///                 key: "keys".into(),
202///                 reason: format!("expected string, found {}", item.type_name()),
203///             })?;
204///             let val = env::var(key).map_err(|_| Error::NotFound {
205///                 loader: self.name().into(),
206///                 resource: source.resource().into(),
207///                 item: format!("environment variable `{key}`"),
208///             })?;
209///             lines.push(format!("{key}={val:?}"));
210///         }
211///
212///         Ok(vec![Payload {
213///             source,
214///             maybe_name: None,                 // unnamed → merges into the config root
215///             maybe_format: Some("env".into()), // parsed by the `env` parser
216///             content: lines.join("\n").into_bytes(),
217///         }])
218///     }
219/// }
220///
221/// // SAFETY: example-only; single-threaded doctest env vars.
222/// unsafe {
223///     env::set_var("DB_HOST", "localhost");
224///     env::set_var("DB_PORT", "5432");
225/// }
226///
227/// let source = Source::parse("selected-env(keys=[DB_HOST,DB_PORT])").unwrap();
228///
229/// let payloads = SelectedEnv.load(source).unwrap();
230/// let content = String::from_utf8_lossy(&payloads[0].content);
231/// assert!(content.contains(r#"DB_HOST="localhost""#));
232/// assert!(content.contains(r#"DB_PORT="5432""#));
233/// ```
234pub trait Load {
235    /// Human-readable name used in error messages.
236    fn name(&self) -> &str;
237    /// Source strings this loader handles (e.g. `["env"]`, `["file"]`, `["http", "https"]`).
238    fn supported_source_list(&self) -> Vec<String>;
239    /// Load raw bytes from the source. Returns one [`Payload`] per config entry found.
240    fn load(&self, source: Source) -> Result<Vec<Payload>, Error>;
241}