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