Skip to main content

onetaskgraph_core/config/
mod.rs

1//! The configuration document, the three layers over it, and what they resolve to.
2//!
3//! Precedence is file, then environment, then command-line flags, lowest to highest,
4//! and every setting is reachable at all three — including every field of every named
5//! source. That is one mechanism rather than three: each layer is flattened to the
6//! same list of leaf settings (see [`layer`]), the stack is merged once, and the
7//! result is deserialized into [`Config`]. Nothing per-verb decides precedence, so
8//! nothing per-verb can get it wrong.
9//!
10//! Reading is [`discovery`]'s and nothing else's; everything else here is a function
11//! of its arguments.
12//!
13//! One thing a leaf setting carries besides its value is load-bearing past this layer:
14//! **a relative filesystem path a configuration document supplies is resolved against the
15//! directory holding that document**, while one the environment or a flag supplies keeps
16//! resolving against the process working directory. [`relative`] is where that happens and
17//! why it cannot happen in the plugin that reads the path; `README.md`, under "Relative
18//! paths in a configuration document", states it for a user.
19
20mod discovery;
21mod effective;
22mod environment_layer;
23mod error;
24mod layer;
25mod relative;
26
27use std::collections::BTreeMap;
28use std::num::NonZeroU32;
29use std::path::Path;
30
31use onetaskgraph_plugin_api::SourceName;
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34use serde_json::{Map, Value};
35
36use crate::secrets::Secrets;
37use crate::{Environment, PluginKind, plugin_kinds};
38
39pub use discovery::{
40    Document, PROJECT_DOCUMENT_NAME, SECRETS_RELATIVE_PATH, USER_DOCUMENT_RELATIVE_PATH, documents,
41    read_optional, readable_documents, secrets_path, user_document_path,
42};
43pub use effective::EffectiveConfig;
44pub use environment_layer::{ENVIRONMENT_PREFIX, variable_for};
45pub use error::ConfigError;
46pub use layer::{Layer, Merged, Origin, Setting, SettingPath, merge, unflatten, value_from_text};
47pub use relative::resolve_document_relative_paths;
48
49/// The variable that moves the credentials file somewhere else.
50pub const SECRETS_FILE_VARIABLE: &str = "ONETASKGRAPH_SECRETS_FILE";
51
52/// How many items a page holds when nothing sets `page_size`.
53pub const DEFAULT_PAGE_SIZE: NonZeroU32 = NonZeroU32::new(50).expect("50 is not zero");
54
55/// How output is rendered.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57#[serde(rename_all = "kebab-case")]
58pub enum OutputFormat {
59    /// For a person reading a terminal.
60    #[default]
61    Text,
62    /// For a program.
63    Json,
64}
65
66/// One named source, as a document configures it.
67///
68/// Built by [`Config::from_document`], never deserialized directly: `plugin` is a
69/// [`PluginKind`] rather than the string the document spelled, so a source naming a
70/// plugin this build does not have cannot be represented here at all.
71#[derive(Debug, Clone, PartialEq)]
72pub struct SourceConfig {
73    plugin: PluginKind,
74    config: Value,
75}
76
77impl SourceConfig {
78    /// The plugin kind that builds this source.
79    #[must_use]
80    pub fn plugin(&self) -> PluginKind {
81        self.plugin
82    }
83
84    /// The plugin's own block.
85    ///
86    /// Opaque here on purpose — a plugin's fields are the plugin's, and typing them in
87    /// the engine would put every plugin's shape in the engine. What holds instead is
88    /// that [`Config::from_document`] checks each block against the schema its own
89    /// plugin declares, and this field is not public, so no `SourceConfig` anybody can
90    /// reach carries a block that plugin would refuse.
91    #[must_use]
92    pub fn config(&self) -> &Value {
93        &self.config
94    }
95}
96
97/// One named source as a document spells it, before its plugin name is checked.
98#[derive(Debug, Clone, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct SourceShape {
101    plugin: String,
102    #[serde(default = "empty_block")]
103    config: Value,
104}
105
106/// A plugin block nobody wrote, which is different from one nobody may write.
107fn empty_block() -> Value {
108    Value::Object(Map::new())
109}
110
111/// A validated configuration.
112///
113/// Built by [`Config::from_document`], never deserialized directly: a source's name
114/// has to be checked against the pattern the environment mapping depends on, and
115/// `default_sources` has to name sources that exist, and both are worth a message
116/// that says which key is wrong rather than serde's own.
117///
118/// Its fields are read through the methods below rather than reached into, because
119/// "validated" is a claim about the whole value: a public `sources` would let a caller
120/// hold a `Config` whose `default_sources` names something it does not contain, and a
121/// public `SourceConfig` would let one hold a block its own plugin refuses. Neither
122/// state is representable while the only way in is [`Config::from_document`].
123#[derive(Debug, Clone, PartialEq)]
124pub struct Config {
125    default_sources: Option<Vec<SourceName>>,
126    page_size: NonZeroU32,
127    output: OutputFormat,
128    sources: BTreeMap<SourceName, SourceConfig>,
129}
130
131/// The document's own shape, before the checks serde cannot make.
132#[derive(Debug, Clone, Deserialize)]
133#[serde(default, deny_unknown_fields)]
134struct DocumentShape {
135    #[serde(deserialize_with = "one_or_many")]
136    default_sources: Option<Vec<String>>,
137    page_size: NonZeroU32,
138    output: OutputFormat,
139    sources: BTreeMap<String, SourceShape>,
140}
141
142impl Default for DocumentShape {
143    fn default() -> Self {
144        Self {
145            default_sources: None,
146            page_size: DEFAULT_PAGE_SIZE,
147            output: OutputFormat::default(),
148            sources: BTreeMap::new(),
149        }
150    }
151}
152
153/// Accept one name where a list is expected.
154///
155/// The environment layer reads a comma-separated value as a list, so
156/// `ONETASKGRAPH_DEFAULT_SOURCES=work,notes` is one; a single name has no comma to
157/// split on, and refusing `ONETASKGRAPH_DEFAULT_SOURCES=work` would make the layer
158/// hold for two sources and not for one.
159fn one_or_many<'de, D: serde::Deserializer<'de>>(
160    deserializer: D,
161) -> Result<Option<Vec<String>>, D::Error> {
162    #[derive(Deserialize)]
163    #[serde(untagged)]
164    enum OneOrMany {
165        One(String),
166        Many(Vec<String>),
167    }
168
169    Ok(match Option::<OneOrMany>::deserialize(deserializer)? {
170        None => None,
171        Some(OneOrMany::One(name)) => Some(vec![name]),
172        Some(OneOrMany::Many(names)) => Some(names),
173    })
174}
175
176impl Config {
177    /// Read one merged document into a validated configuration.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`ConfigError::Setting`] naming the offending key for an unknown
182    /// field, a value of the wrong shape, a `plugin:` this build does not have, a
183    /// source name that does not match
184    /// [`SOURCE_NAME_PATTERN`](onetaskgraph_plugin_api::SOURCE_NAME_PATTERN), a
185    /// `default_sources` entry naming a source nothing configures, or a `config:`
186    /// block the source's own plugin refuses.
187    pub fn from_document(document: Value) -> Result<Self, ConfigError> {
188        let shape: DocumentShape = serde_path_to_error::deserialize(document).map_err(|error| {
189            let key = error.path().to_string();
190            let key = if key.is_empty() || key == "." {
191                "the document's root".to_owned()
192            } else {
193                key
194            };
195            ConfigError::setting(
196                key,
197                error.into_inner().to_string(),
198                "correct that setting, or remove it — `onetaskgraph config show` lists \
199                     every setting this build reads and the layer each came from.",
200            )
201        })?;
202
203        let mut sources = BTreeMap::new();
204        for (name, source) in shape.sources {
205            let key = format!("sources.{name}");
206            let plugin = PluginKind::parse(&source.plugin).ok_or_else(|| {
207                ConfigError::setting(
208                    format!("{key}.plugin"),
209                    format!(
210                        "no plugin named {:?} is built into this binary",
211                        source.plugin
212                    ),
213                    format!("use one of: {}.", plugin_kinds().join(", ")),
214                )
215            })?;
216            let name = SourceName::new(name).map_err(|error| {
217                ConfigError::setting(
218                    &key,
219                    error.to_string(),
220                    "rename the source to lower-case letters, digits and hyphens — an \
221                     underscore would make the ONETASKGRAPH_SOURCES__<NAME>__ mapping \
222                     ambiguous.",
223                )
224            })?;
225            sources.insert(
226                name,
227                SourceConfig {
228                    plugin,
229                    config: source.config,
230                },
231            );
232        }
233
234        let default_sources = shape
235            .default_sources
236            .map(|names| resolve_default_sources(&names, &sources))
237            .transpose()?;
238
239        let config = Self {
240            default_sources,
241            page_size: shape.page_size,
242            output: shape.output,
243            sources,
244        };
245        // Here rather than at the call site, so "a `Config` exists" means "every block in
246        // it satisfies the schema its own plugin declares". Checked once at the boundary,
247        // a mistyped per-source field cannot survive as far as the HTTP call that would
248        // otherwise be the first thing to notice it.
249        crate::resolve::validate_sources(&config)?;
250        Ok(config)
251    }
252
253    /// How many items a page holds.
254    #[must_use]
255    pub fn page_size(&self) -> NonZeroU32 {
256        self.page_size
257    }
258
259    /// How output is rendered.
260    #[must_use]
261    pub fn output(&self) -> OutputFormat {
262        self.output
263    }
264
265    /// Every configured source, in name order.
266    #[must_use]
267    pub fn sources(&self) -> &BTreeMap<SourceName, SourceConfig> {
268        &self.sources
269    }
270
271    /// Which sources answer when a command names none, or `None` for every one.
272    #[must_use]
273    pub fn default_sources(&self) -> Option<&[SourceName]> {
274        self.default_sources.as_deref()
275    }
276
277    /// The sources a command answers from when it names none, in a stable order.
278    #[must_use]
279    pub fn selected_sources(&self) -> Vec<SourceName> {
280        self.default_sources
281            .clone()
282            .unwrap_or_else(|| self.sources.keys().cloned().collect())
283    }
284}
285
286/// Check every `default_sources` entry against the sources that exist.
287fn resolve_default_sources(
288    names: &[String],
289    sources: &BTreeMap<SourceName, SourceConfig>,
290) -> Result<Vec<SourceName>, ConfigError> {
291    names
292        .iter()
293        .map(|name| {
294            let selected = SourceName::new(name.clone()).map_err(|error| {
295                ConfigError::setting(
296                    "default_sources",
297                    error.to_string(),
298                    "name a configured source; `onetaskgraph config show` lists them.",
299                )
300            })?;
301            if sources.contains_key(&selected) {
302                Ok(selected)
303            } else {
304                Err(ConfigError::setting(
305                    "default_sources",
306                    format!("no source named {name:?} is configured"),
307                    format!(
308                        "name one of the configured sources ({}), or configure {name:?} under \
309                         `sources`.",
310                        source_list(sources)
311                    ),
312                ))
313            }
314        })
315        .collect()
316}
317
318/// The configured source names, for a message.
319fn source_list(sources: &BTreeMap<SourceName, SourceConfig>) -> String {
320    if sources.is_empty() {
321        "none are".to_owned()
322    } else {
323        sources
324            .keys()
325            .map(SourceName::as_str)
326            .collect::<Vec<_>>()
327            .join(", ")
328    }
329}
330
331/// A configuration, the credentials behind it, and where every setting came from.
332#[derive(Debug, Clone)]
333pub struct Loaded {
334    /// The configuration itself.
335    pub config: Config,
336    /// Where a plugin's named credential is looked up. Read before sources resolve.
337    pub secrets: Secrets,
338    /// Every setting with the layer it came from, for `config show`.
339    pub effective: EffectiveConfig,
340}
341
342/// The output format a run asked for, read from whichever layers can still be read.
343///
344/// For a run whose configuration did not load, which still owes its failure in the format
345/// it asked for: `--json` on a command line beside a document that will not parse asks
346/// for machine output as plainly as it does beside one that will. The same layers in the
347/// same precedence as [`load`], each skipped on its own when it cannot be read or parsed,
348/// so a layer that is itself the failure contributes nothing rather than hiding the others.
349/// `working_directory` is `None` when there is none to search from, and then no project
350/// document is read. Text when no readable layer sets a usable format.
351#[must_use]
352pub fn requested_output(
353    working_directory: Option<&Path>,
354    environment: &Environment,
355    flags: &Layer,
356) -> OutputFormat {
357    let mut layers: Vec<Layer> = readable_documents(working_directory, environment)
358        .into_iter()
359        .filter_map(|document| {
360            let parsed: Value = serde_norway::from_str(&document.text).ok()?;
361            Layer::from_document(document.path, &parsed).ok()
362        })
363        .collect();
364    layers.extend(environment_layer::layer(environment).ok());
365    layers.push(flags.clone());
366    merge(&layers)
367        .values()
368        .find(|setting| setting.key.segments() == ["output"])
369        .and_then(|setting| serde_json::from_value(setting.value.clone()).ok())
370        .unwrap_or_default()
371}
372
373/// Load the configuration: documents, then the environment, then `flags`.
374///
375/// Each source's `config` block is checked against its plugin's declared schema
376/// before this returns, so a mistyped per-source field is a load-time refusal rather
377/// than a surprise inside the first call that source makes.
378///
379/// # Errors
380///
381/// Returns [`ConfigError`] for a document that cannot be read or parsed, and for any
382/// setting that is unknown, unusable, or names a plugin this build does not have.
383pub fn load(
384    working_directory: &Path,
385    environment: &Environment,
386    flags: &Layer,
387) -> Result<Loaded, ConfigError> {
388    let mut layers = Vec::new();
389    for document in documents(working_directory, environment)? {
390        let parsed: Value =
391            serde_norway::from_str(&document.text).map_err(|error| ConfigError::Syntax {
392                path: document.path.clone(),
393                message: error.to_string(),
394            })?;
395        layers.push(Layer::from_document(document.path, &parsed)?);
396    }
397    layers.push(environment_layer::layer(environment)?);
398    layers.push(flags.clone());
399
400    let mut merged = merge(&layers);
401    // Before the block reaches a plugin, and before `config show` reports it: a plugin is
402    // handed values and no origins, so this is the only layer that can tell a path a
403    // document supplied from one the environment or a flag did. See [`relative`].
404    resolve_document_relative_paths(&mut merged)?;
405    let config = Config::from_document(unflatten(&merged))?;
406
407    // Before the sources are resolved, as the contract says: a plugin reads its
408    // credential through this resolver, so it has to exist by the time one is built.
409    let secrets = Secrets::load(environment.clone())?;
410
411    Ok(Loaded {
412        effective: EffectiveConfig::new(&merged, &config, secrets.report()),
413        config,
414        secrets,
415    })
416}