Skip to main content

confex_core/
source.rs

1use std::{
2    collections::BTreeMap,
3    path::{Path, PathBuf},
4};
5
6use crate::{error::SourceError, format::Format, value::ConfigValue};
7
8/// A source that can produce a `ConfigValue` tree.
9pub trait Source {
10    /// Collect config values from this source.
11    ///
12    /// # Errors
13    /// Returns [`SourceError`] on I/O, parse, or environment variable errors.
14    fn collect(&self) -> Result<ConfigValue, SourceError>;
15
16    /// Inject the application name derived from [`crate::config::ConfigSchema::APP_NAME`].
17    ///
18    /// Called automatically by [`crate::config::ConfigLoader::with_source`] and
19    /// [`crate::config::ConfigLoader::with_watched_source`] before the source is
20    /// stored. The default is a no-op; override it for sources that use the app
21    /// name to scope their data (e.g. `RedbSource`, `EnvSource`). The name
22    /// must not override an explicit value already set on the source.
23    #[must_use]
24    fn with_app_name(self, _name: &str) -> Self
25    where
26        Self: Sized,
27    {
28        self
29    }
30}
31
32/// Reads config from environment variables with a given prefix.
33///
34/// `PREFIX_WINDOW__WIDTH=1024` → `{ "window": { "width": 1024 } }`
35///
36/// Double-underscore (`__`) is the nested key separator.
37#[derive(Default)]
38pub struct EnvSource {
39    prefix: String,
40}
41
42impl From<String> for EnvSource {
43    fn from(prefix: String) -> Self {
44        Self { prefix }
45    }
46}
47
48impl From<&str> for EnvSource {
49    fn from(prefix: &str) -> Self {
50        Self {
51            prefix: prefix.to_owned(),
52        }
53    }
54}
55
56impl Source for EnvSource {
57    fn with_app_name(mut self, name: &str) -> Self {
58        if self.prefix.is_empty() {
59            name.clone_into(&mut self.prefix);
60        }
61        self
62    }
63
64    fn collect(&self) -> Result<ConfigValue, SourceError> {
65        if self.prefix.is_empty() {
66            return Ok(ConfigValue::Table(BTreeMap::new()));
67        }
68        let prefix = format!("{}_", self.prefix.to_uppercase());
69        let mut table = BTreeMap::new();
70
71        for (key, val) in std::env::vars() {
72            let Some(stripped) = key.strip_prefix(&prefix) else {
73                continue;
74            };
75            let path: Vec<String> = stripped
76                .to_lowercase()
77                .split("__")
78                .map(String::from)
79                .collect();
80            insert_nested(&mut table, &path, val.parse().unwrap());
81        }
82
83        Ok(ConfigValue::Table(table))
84    }
85}
86
87fn insert_nested(table: &mut BTreeMap<String, ConfigValue>, path: &[String], value: ConfigValue) {
88    let Some((head, tail)) = path.split_first() else {
89        return;
90    };
91    if tail.is_empty() {
92        table.insert(head.clone(), value);
93        return;
94    }
95    let entry = table
96        .entry(head.clone())
97        .or_insert_with(|| ConfigValue::Table(BTreeMap::new()));
98    if let ConfigValue::Table(inner) = entry {
99        insert_nested(inner, tail, value);
100    }
101}
102
103/// Source backed by `(key, value)` pairs — parser-agnostic CLI bridge.
104///
105/// Any CLI parser (clap, pico-args, raw argv) produces `(String, ConfigValue)` pairs;
106/// wrap them here to plug into `ConfigLoader`.
107pub struct CliSource {
108    values: BTreeMap<String, ConfigValue>,
109}
110
111impl FromIterator<(String, ConfigValue)> for CliSource {
112    fn from_iter<I: IntoIterator<Item = (String, ConfigValue)>>(iter: I) -> Self {
113        Self {
114            values: iter.into_iter().collect(),
115        }
116    }
117}
118
119impl Source for CliSource {
120    fn collect(&self) -> Result<ConfigValue, SourceError> {
121        Ok(ConfigValue::Table(self.values.clone()))
122    }
123}
124
125/// Reads config from the first file in `candidates` that exists on disk.
126pub struct FileSource<F: Format> {
127    candidates: Vec<PathBuf>,
128    format: F,
129}
130
131impl<F: Format> FileSource<F> {
132    #[must_use]
133    pub fn new(candidates: impl IntoIterator<Item = PathBuf>, format: F) -> Self {
134        Self {
135            candidates: candidates.into_iter().collect(),
136            format,
137        }
138    }
139
140    /// Returns the first candidate path that exists, or `None`.
141    #[must_use]
142    pub fn resolve(&self) -> Option<&Path> {
143        self.candidates
144            .iter()
145            .find(|p| p.exists())
146            .map(PathBuf::as_path)
147    }
148}
149
150impl<F: Format> Source for FileSource<F> {
151    fn collect(&self) -> Result<ConfigValue, SourceError> {
152        let Some(path) = self.resolve() else {
153            return Ok(ConfigValue::Table(BTreeMap::new()));
154        };
155        let bytes = std::fs::read(path).map_err(|e| SourceError::Io {
156            path: path.to_owned(),
157            source: e,
158        })?;
159        self.format.parse(path, &bytes)
160    }
161}
162
163/// A [`Source`] that can declare which filesystem paths it monitors.
164///
165/// Implemented by [`FileSource`] and [`SqliteSource`]. Passive sources (env
166/// vars, CLI args) do not implement this — their values are fixed at load time
167/// and never need a reload trigger.
168#[cfg(feature = "hot-reload")]
169pub trait WatchableSource: Source + Send + Sync {
170    /// Filesystem paths (files or directories) whose changes should trigger a
171    /// reload of this source. An empty vec means "not currently watchable"
172    /// (e.g. no candidate file exists yet).
173    fn watch_paths(&self) -> Vec<std::path::PathBuf>;
174}
175
176#[cfg(feature = "hot-reload")]
177impl<F: Format + Send + Sync> WatchableSource for FileSource<F> {
178    fn watch_paths(&self) -> Vec<std::path::PathBuf> {
179        self.candidates.clone()
180    }
181}
182
183/// Owned, boxed future returned by [`AsyncSource::collect`].
184#[cfg(feature = "async")]
185pub type CollectFuture<'a> = std::pin::Pin<
186    Box<dyn std::future::Future<Output = Result<ConfigValue, SourceError>> + Send + 'a>,
187>;
188
189/// Async variant of [`Source`]. Implement this when collection requires I/O
190/// that must not block the async executor (e.g. HTTP fetches, cloud APIs).
191///
192/// # Implementing
193///
194/// ```rust,ignore
195/// use confex::{AsyncSource, CollectFuture, ConfigValue, SourceError};
196///
197/// struct RemoteSource { url: String }
198///
199/// impl AsyncSource for RemoteSource {
200///     fn collect(&self) -> CollectFuture<'_> {
201///         Box::pin(async move {
202///             // async HTTP fetch, parse, return ConfigValue
203///             todo!()
204///         })
205///     }
206/// }
207/// ```
208#[cfg(feature = "async")]
209pub trait AsyncSource: Send + Sync {
210    /// Asynchronously collect config values from this source.
211    ///
212    /// # Errors
213    /// Returns [`SourceError`] on I/O, parse, or network failure.
214    fn collect(&self) -> CollectFuture<'_>;
215}
216
217/// Every sync `Source` that is `Send + Sync` is also an `AsyncSource`.
218#[cfg(feature = "async")]
219impl<T: Source + Send + Sync> AsyncSource for T {
220    fn collect(&self) -> CollectFuture<'_> {
221        Box::pin(std::future::ready(Source::collect(self)))
222    }
223}