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