onetaskgraph_core/config/
mod.rs1mod 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
40pub const SECRETS_FILE_VARIABLE: &str = "ONETASKGRAPH_SECRETS_FILE";
42
43pub const DEFAULT_PAGE_SIZE: NonZeroU32 = NonZeroU32::new(50).expect("50 is not zero");
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "kebab-case")]
49pub enum OutputFormat {
50 #[default]
52 Text,
53 Json,
55}
56
57#[derive(Debug, Clone, PartialEq)]
63pub struct SourceConfig {
64 plugin: PluginKind,
65 config: Value,
66}
67
68impl SourceConfig {
69 #[must_use]
71 pub fn plugin(&self) -> PluginKind {
72 self.plugin
73 }
74
75 #[must_use]
83 pub fn config(&self) -> &Value {
84 &self.config
85 }
86}
87
88#[derive(Debug, Clone, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct SourceShape {
92 plugin: String,
93 #[serde(default = "empty_block")]
94 config: Value,
95}
96
97fn empty_block() -> Value {
99 Value::Object(Map::new())
100}
101
102#[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#[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
144fn 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 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 crate::resolve::validate_sources(&config)?;
241 Ok(config)
242 }
243
244 #[must_use]
246 pub fn page_size(&self) -> NonZeroU32 {
247 self.page_size
248 }
249
250 #[must_use]
252 pub fn output(&self) -> OutputFormat {
253 self.output
254 }
255
256 #[must_use]
258 pub fn sources(&self) -> &BTreeMap<SourceName, SourceConfig> {
259 &self.sources
260 }
261
262 #[must_use]
264 pub fn default_sources(&self) -> Option<&[SourceName]> {
265 self.default_sources.as_deref()
266 }
267
268 #[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
277fn 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
309fn 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#[derive(Debug, Clone)]
324pub struct Loaded {
325 pub config: Config,
327 pub secrets: Secrets,
329 pub effective: EffectiveConfig,
331}
332
333pub 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 let secrets = Secrets::load(environment.clone())?;
366
367 Ok(Loaded {
368 effective: EffectiveConfig::new(&merged, &config, secrets.report()),
369 config,
370 secrets,
371 })
372}