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    /// An option is unknown, or has the wrong type or value. `key` is the option name; `reason`
79    /// explains the problem (commonly built from [`OptionValue::type_name`] on a type mismatch).
80    #[error("{loader} configuration loader invalid option `{key}`: {reason}")]
81    InvalidOption {
82        loader: String,
83        key: String,
84        reason: String,
85    },
86    /// The resource string is empty or malformed for this loader (e.g. a required path is
87    /// missing). `reason` explains what was expected.
88    #[error("{loader} configuration loader invalid resource `{resource}`: {reason}")]
89    InvalidResource {
90        loader: String,
91        resource: String,
92        reason: String,
93    },
94    /// Two entries resolve to the same `name` with differing formats (`format_1` vs `format_2`),
95    /// so the loader cannot pick one unambiguously.
96    #[error(
97        "{loader} configuration loader found duplicate configurations `{resource}/{name}.({format_1}|{format_2})`"
98    )]
99    Duplicate {
100        loader: String,
101        resource: String,
102        name: String,
103        format_1: String,
104        format_2: String,
105    },
106    /// Catch-all backend failure that doesn't fit the variants above. `description` completes the
107    /// phrase "could not {description}" (e.g. `"read contents of file"`); `source` carries the
108    /// underlying error.
109    #[error("{loader} configuration loader could not {description} `{resource}`")]
110    Load {
111        loader: String,
112        resource: String,
113        description: String,
114        source: Box<dyn StdError + Send + Sync>,
115    },
116    /// Bridge for opaque errors via `?`/`From`, when none of the structured variants apply.
117    #[error(transparent)]
118    Other(#[from] Box<dyn StdError + Send + Sync>),
119}
120
121/// Loads raw configuration bytes from a declared source.
122///
123/// Implement this to add a new source kind (protocol, service, database, …). This is the first
124/// stage of the pipeline: it only *fetches bytes*, it does not parse them — [`Payload::content`]
125/// is handed to the parser stage unchanged.
126///
127/// # Contract
128///
129/// - [`load`](Load::load) takes ownership of one [`Source`] and returns one [`Payload`] per
130///   configuration entry found. A single source may expand to many entries (e.g. every file in a
131///   directory) → return many payloads; finding nothing is `Ok(vec![])`, not an error.
132/// - Set [`Payload::source`] on each entry to the *concrete* resource loaded, not the original
133///   source — clone it and narrow with [`Source::with_resource`]. This keeps diagnostics precise.
134/// - Use [`Payload::maybe_name`] for the entry name (`None` merges into the root with all other
135///   unnamed entries) and [`Payload::maybe_format`] as a parser hint (e.g. `json`).
136/// - Follow the lowercase convention: when your `lowercase` option is `true` (recommended
137///   default), lower-case names and formats so entries merge predictably across sources.
138///
139/// # Reading options
140///
141/// Options declared on the source (e.g. `file(ignore=[not-found])`) are available via
142/// [`Source::options`]. Look each up with [`Options::get`], convert with the typed accessors
143/// ([`OptionValue::as_bool`], [`OptionValue::as_string`], [`OptionValue::as_list`], …), and on a
144/// type mismatch build the `reason` from [`OptionValue::type_name`]. It is good practice to reject
145/// unknown keys by iterating [`Options::keys`] and returning [`Error::InvalidOption`] — see the
146/// `file` loader's `load` for a complete worked pattern.
147///
148/// # Choosing an error
149///
150/// - [`Error::InvalidResource`] — the resource string is empty/malformed for this loader.
151/// - [`Error::InvalidOption`] — an option is unknown, or has the wrong type/value.
152/// - [`Error::NotFound`] — the resource/entry doesn't exist (and isn't being ignored).
153/// - [`Error::NoAccess`] — permission denied by the backend.
154/// - [`Error::Timeout`] — a deadline was exceeded.
155/// - [`Error::Duplicate`] — two entries collide on the same name with different formats.
156/// - [`Error::Load`] — any other backend failure (`description` completes "could not …").
157/// - [`Error::Other`] — bridge for an opaque error via `?`.
158///
159/// # Registering
160///
161/// Pass an instance to `tanzim::Config::with_loader`. The pipeline dispatches each source to the
162/// first loader whose [`supported_source_list`](Load::supported_source_list) contains the source
163/// string, so it may advertise several (e.g. `["http", "https"]`). For a one-off loader you don't
164/// want to define a type for, use [`closure::Closure`] instead of implementing this trait.
165///
166/// # Example — collecting specific environment variables
167///
168/// A loader that reads the variable names listed in its `keys` option and returns them as one
169/// `env`-format payload. It shows the whole contract: reading a typed option, mapping failures to
170/// the right [`Error`] variant, and building a [`Payload`].
171///
172/// ```rust
173/// use std::env;
174/// use tanzim_load::{Error, Load, Payload, Source};
175///
176/// struct SelectedEnv;
177///
178/// impl Load for SelectedEnv {
179///     fn name(&self) -> &str { "selected-env" }
180///     fn supported_source_list(&self) -> Vec<String> { vec!["selected-env".into()] }
181///
182///     fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
183///         // Read the `keys` option — a required list of variable names.
184///         let value = source.options().get("keys").ok_or_else(|| Error::InvalidOption {
185///             loader: self.name().into(),
186///             key: "keys".into(),
187///             reason: "required".into(),
188///         })?;
189///         let keys = value.as_list().ok_or_else(|| Error::InvalidOption {
190///             loader: self.name().into(),
191///             key: "keys".into(),
192///             reason: format!("expected list, found {}", value.type_name()),
193///         })?;
194///
195///         // Collect each requested variable into a `KEY="value"` line.
196///         let mut lines = Vec::new();
197///         for item in keys {
198///             let key = item.as_string().ok_or_else(|| Error::InvalidOption {
199///                 loader: self.name().into(),
200///                 key: "keys".into(),
201///                 reason: format!("expected string, found {}", item.type_name()),
202///             })?;
203///             let val = env::var(key).map_err(|_| Error::NotFound {
204///                 loader: self.name().into(),
205///                 resource: source.resource().into(),
206///                 item: format!("environment variable `{key}`"),
207///             })?;
208///             lines.push(format!("{key}={val:?}"));
209///         }
210///
211///         Ok(vec![Payload {
212///             source,
213///             maybe_name: None,                 // unnamed → merges into the config root
214///             maybe_format: Some("env".into()), // parsed by the `env` parser
215///             content: lines.join("\n").into_bytes(),
216///         }])
217///     }
218/// }
219///
220/// // SAFETY: example-only; single-threaded doctest env vars.
221/// unsafe {
222///     env::set_var("DB_HOST", "localhost");
223///     env::set_var("DB_PORT", "5432");
224/// }
225///
226/// let source = Source::parse("selected-env(keys=[DB_HOST,DB_PORT])").unwrap();
227///
228/// let payloads = SelectedEnv.load(source).unwrap();
229/// let content = String::from_utf8_lossy(&payloads[0].content);
230/// assert!(content.contains(r#"DB_HOST="localhost""#));
231/// assert!(content.contains(r#"DB_PORT="5432""#));
232/// ```
233pub trait Load {
234    /// Human-readable name used in error messages.
235    fn name(&self) -> &str;
236    /// Source strings this loader handles (e.g. `["env"]`, `["file"]`, `["http", "https"]`).
237    fn supported_source_list(&self) -> Vec<String>;
238    /// Load raw bytes from the source. Returns one [`Payload`] per config entry found.
239    fn load(&self, source: Source) -> Result<Vec<Payload>, Error>;
240}