Skip to main content

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}