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