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