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}