onetaskgraph_core/config/layer.rs
1//! Layers, and the pure merge that turns a stack of them into one configuration.
2//!
3//! Every layer — a document, the environment, the command line — is reduced to the
4//! same thing: a list of *leaf* settings, each a dotted path, a value, and the
5//! [`Origin`] it came from. Precedence is then one rule applied once, rather than a
6//! per-verb `if flag.is_some()` at every call site, and "which layer did this come
7//! from" is an answer the merge already holds instead of one reconstructed later.
8//!
9//! Nothing here touches the filesystem or the environment: a layer arrives as text
10//! or as pairs, and [`merge`] is a function of its arguments. Reading is
11//! [`super::discovery`]'s job and nothing else's.
12
13use std::collections::BTreeMap;
14use std::fmt;
15use std::path::PathBuf;
16
17use schemars::JsonSchema;
18use serde::Serialize;
19use serde_json::{Map, Value};
20
21use super::ConfigError;
22
23/// Where one setting's value came from.
24///
25/// This is what makes precedence provable rather than asserted: `config show`
26/// renders it per setting, so a user sees the same answer a test does.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
28#[serde(tag = "layer", rename_all = "kebab-case")]
29pub enum Origin {
30 /// Nothing set it; this is the built-in value.
31 Default,
32 /// A configuration document, and which one.
33 File {
34 /// The document that set it.
35 path: PathBuf,
36 },
37 /// The process environment, and which variable.
38 Environment {
39 /// The variable that set it.
40 variable: String,
41 },
42 /// The command line, and which flag.
43 Flag {
44 /// The flag that set it, as a user typed it.
45 flag: String,
46 },
47}
48
49impl fmt::Display for Origin {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::Default => f.write_str("default"),
53 Self::File { path } => write!(f, "file {}", path.display()),
54 Self::Environment { variable } => write!(f, "environment {variable}"),
55 Self::Flag { flag } => write!(f, "flag {flag}"),
56 }
57 }
58}
59
60/// A dotted path to one setting, such as `sources.work.config.root`.
61#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema)]
62#[serde(into = "String")]
63// `serde(into)` is not one of the attributes schemars reads, so without this the
64// generated schema would describe the segments this holds rather than the dotted
65// string it serializes as — a bundle that misdescribes the output the binary emits.
66#[schemars(with = "String")]
67pub struct SettingPath(Vec<String>);
68
69impl SettingPath {
70 /// Build a path from segments, refusing an empty one.
71 ///
72 /// # Errors
73 ///
74 /// Returns [`ConfigError::Setting`] when there are no segments or one is empty —
75 /// `--set .plugin=x` and `--set a..b=x` both address nothing.
76 pub fn new(segments: Vec<String>, source: &str) -> Result<Self, ConfigError> {
77 if segments.is_empty() || segments.iter().any(String::is_empty) {
78 return Err(ConfigError::setting(
79 source,
80 "that is not a setting path; a path is one or more dot-separated names, \
81 none of them empty",
82 "write it as a dotted path, for example `page_size` or \
83 `sources.work.config.root`.",
84 ));
85 }
86 Ok(Self(segments))
87 }
88
89 /// Parse a dotted path such as `sources.work.config.root`.
90 ///
91 /// # Errors
92 ///
93 /// Returns [`ConfigError::Setting`] when `dotted` has an empty segment.
94 pub fn parse(dotted: &str) -> Result<Self, ConfigError> {
95 Self::new(dotted.split('.').map(str::to_owned).collect(), dotted)
96 }
97
98 /// The segments, outermost first.
99 #[must_use]
100 pub fn segments(&self) -> &[String] {
101 &self.0
102 }
103
104 /// Whether `self` is `other`, or is an ancestor or descendant of it.
105 ///
106 /// Two settings that overlap this way cannot both survive a merge: one of them
107 /// addresses a subtree the other addresses as a whole.
108 fn overlaps(&self, other: &Self) -> bool {
109 let shared = self.0.len().min(other.0.len());
110 self.0[..shared] == other.0[..shared]
111 }
112}
113
114impl fmt::Display for SettingPath {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(&self.0.join("."))
117 }
118}
119
120impl From<SettingPath> for String {
121 fn from(value: SettingPath) -> Self {
122 value.to_string()
123 }
124}
125
126/// One setting: what it is called, what it is set to, and where that came from.
127#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
128pub struct Setting {
129 /// The dotted path of the setting.
130 pub key: SettingPath,
131 /// Its value.
132 pub value: Value,
133 /// Where the value came from.
134 pub origin: Origin,
135}
136
137/// One layer of configuration, flattened to its leaf settings.
138#[derive(Debug, Clone, Default, PartialEq)]
139pub struct Layer {
140 settings: Vec<Setting>,
141}
142
143impl Layer {
144 /// A layer holding exactly `settings`, in the order given.
145 #[must_use]
146 pub fn new(settings: Vec<Setting>) -> Self {
147 Self { settings }
148 }
149
150 /// The settings this layer carries.
151 #[must_use]
152 pub fn settings(&self) -> &[Setting] {
153 &self.settings
154 }
155
156 /// Flatten one parsed document into a layer attributed to `path`.
157 ///
158 /// An empty document contributes nothing, which is what a file holding only
159 /// comments should do.
160 ///
161 /// # Errors
162 ///
163 /// Returns [`ConfigError::Setting`] when the document's root is not a mapping.
164 /// A document that is a list or a bare scalar sets no setting at all, and
165 /// accepting it silently would make a whole misplaced file read as "unset".
166 pub fn from_document(path: PathBuf, document: &Value) -> Result<Self, ConfigError> {
167 let origin = Origin::File { path };
168 let fields = match document {
169 Value::Null => return Ok(Self::default()),
170 Value::Object(fields) => fields,
171 other => {
172 return Err(ConfigError::setting(
173 "the document's root",
174 format!(
175 "a configuration document must be a mapping of settings, but {origin} \
176 holds {}",
177 kind_of(other)
178 ),
179 "write the document as `key: value` pairs — see `onetaskgraph config show \
180 --help` for the settings it may hold.",
181 ));
182 }
183 };
184
185 let mut settings = Vec::new();
186 flatten(&mut Vec::new(), fields, &origin, &mut settings);
187 Ok(Self { settings })
188 }
189}
190
191/// What a value is, for a message a user reads.
192fn kind_of(value: &Value) -> &'static str {
193 match value {
194 Value::Bool(_) => "a boolean",
195 Value::Number(_) => "a number",
196 Value::String(_) => "a string",
197 // Nothing at all and a mapping are both answered before this is reached.
198 _ => "a list",
199 }
200}
201
202/// Walk `fields`, emitting one [`Setting`] per leaf.
203///
204/// A non-empty object is a branch; everything else — a scalar, a list, and an
205/// *empty* object — is a leaf. Empty stays a leaf on purpose: `config: {}` is a
206/// deliberate "this plugin takes no options", and flattening it to nothing would
207/// silently turn it into "unset".
208fn flatten(
209 prefix: &mut Vec<String>,
210 fields: &Map<String, Value>,
211 origin: &Origin,
212 out: &mut Vec<Setting>,
213) {
214 for (name, value) in fields {
215 prefix.push(name.clone());
216 match value {
217 Value::Object(nested) if !nested.is_empty() => flatten(prefix, nested, origin, out),
218 leaf => out.push(Setting {
219 key: SettingPath(prefix.clone()),
220 value: leaf.clone(),
221 origin: origin.clone(),
222 }),
223 }
224 prefix.pop();
225 }
226}
227
228/// Apply `layers` lowest precedence first, returning every effective setting.
229///
230/// A setting from a later layer replaces one from an earlier layer at the same path,
231/// *and* replaces any earlier setting above or below it in the tree: an environment
232/// variable naming `sources.work.config.root` supersedes a document that set
233/// `sources.work.config` whole, and vice versa. That leaves the result prefix-free,
234/// which is what lets [`unflatten`] rebuild a document from it without conflict.
235#[must_use]
236pub fn merge(layers: &[Layer]) -> Merged {
237 let mut merged: BTreeMap<SettingPath, Setting> = BTreeMap::new();
238 for layer in layers {
239 for setting in &layer.settings {
240 merged.retain(|key, _| !key.overlaps(&setting.key));
241 merged.insert(setting.key.clone(), setting.clone());
242 }
243 }
244 Merged(merged)
245}
246
247/// The result of a [`merge`]: settings no one of which is an ancestor of another.
248///
249/// A newtype rather than a bare map, because that prefix-free property is the whole
250/// reason [`unflatten`] can be infallible. Handed a map anybody could build, it would
251/// have to decide what a leaf that is also a branch means — and the honest answers are
252/// a panic or a silently dropped setting. Only `merge` constructs one of these, so
253/// neither case can be reached.
254#[derive(Debug, Clone, Default, PartialEq)]
255pub struct Merged(BTreeMap<SettingPath, Setting>);
256
257impl std::ops::Deref for Merged {
258 type Target = BTreeMap<SettingPath, Setting>;
259
260 fn deref(&self) -> &Self::Target {
261 &self.0
262 }
263}
264
265/// Rebuild one document from merged settings.
266///
267/// Infallible because [`merge`] leaves no setting that is an ancestor of another, so
268/// no leaf is ever asked to also be a branch — and [`Merged`] is the type that says so.
269#[must_use]
270pub fn unflatten(settings: &Merged) -> Value {
271 let mut root = Map::new();
272 for setting in settings.values() {
273 let segments = setting.key.segments();
274 let mut cursor = &mut root;
275 for segment in &segments[..segments.len() - 1] {
276 cursor = cursor
277 .entry(segment.clone())
278 .or_insert_with(|| Value::Object(Map::new()))
279 .as_object_mut()
280 .expect("`Merged` holds no setting that is an ancestor of another");
281 }
282 cursor.insert(segments[segments.len() - 1].clone(), setting.value.clone());
283 }
284 Value::Object(root)
285}
286
287/// Read one textual setting value the way the environment and `--set` both read it.
288///
289/// The two layers deliberately share this so a setting behaves the same whichever of
290/// them supplies it. The rule is small and stated rather than inferred:
291///
292/// - a value containing a comma is a list, its parts read individually — this is the
293/// contract's "a list is comma-separated";
294/// - a part that reads as an integer or a decimal is a number, and `true`/`false` is
295/// a boolean, so `page_size` set here is the same number the document would give;
296/// - everything else is a string, verbatim.
297///
298/// Nothing here consults the schema of the setting being written, so a value that
299/// reads as a number reaches a string field as a number and is refused by name. That
300/// is the trade for one rule that holds at every path, including inside a plugin's
301/// own `config` block, which the engine cannot type on its own.
302#[must_use]
303pub fn value_from_text(raw: &str) -> Value {
304 if raw.contains(',') {
305 Value::Array(raw.split(',').map(|part| scalar(part.trim())).collect())
306 } else {
307 scalar(raw)
308 }
309}
310
311/// One scalar, typed as far as it reads.
312fn scalar(raw: &str) -> Value {
313 if let Ok(integer) = raw.parse::<i64>() {
314 return Value::from(integer);
315 }
316 if let Ok(unsigned) = raw.parse::<u64>() {
317 return Value::from(unsigned);
318 }
319 if let Ok(number) = raw.parse::<f64>()
320 && let Some(value) = serde_json::Number::from_f64(number)
321 {
322 return Value::Number(value);
323 }
324 match raw {
325 "true" => Value::Bool(true),
326 "false" => Value::Bool(false),
327 other => Value::String(other.to_owned()),
328 }
329}