Skip to main content

onetaskgraph_core/config/
environment_layer.rs

1//! The environment read as a configuration layer.
2//!
3//! This is a *layer*, not a set of per-command overrides: it parses into the same
4//! shape a document parses into and is appended after the documents, so a setting
5//! reached this way flows through every verb without any verb knowing it exists.
6
7use crate::Environment;
8
9use super::layer::{Layer, Origin, Setting, SettingPath, value_from_text};
10use super::{ConfigError, SECRETS_FILE_VARIABLE};
11
12/// What every configuration variable's name begins with.
13pub const ENVIRONMENT_PREFIX: &str = "ONETASKGRAPH_";
14
15/// Variables that begin with [`ENVIRONMENT_PREFIX`] and are *not* settings.
16///
17/// `ONETASKGRAPH_SECRETS_FILE` points at the credentials file, which is read before
18/// sources are resolved and is nowhere in the configuration document. Without this
19/// list it would decode to a setting called `secrets_file` and be refused as an
20/// unknown field — turning a documented variable into an error.
21const RESERVED: &[&str] = &[SECRETS_FILE_VARIABLE];
22
23/// The separator between path segments in a variable name.
24const SEGMENT_SEPARATOR: &str = "__";
25
26/// Every `ONETASKGRAPH_`-prefixed variable, as one configuration layer.
27///
28/// The rule, and its inverse. A variable's name is [`ENVIRONMENT_PREFIX`] followed by
29/// the setting's path, each segment upper-cased with `-` replaced by `_`, segments
30/// joined by [`SEGMENT_SEPARATOR`]. Decoding lower-cases each segment, and turns `_`
31/// back into `-` in exactly one position: the segment naming a source, immediately
32/// after `sources`. That is the only place a `-` can occur, because a
33/// [`SourceName`](onetaskgraph_plugin_api::SourceName) may not contain `_` while
34/// every other key in the document is `snake_case` — which is what makes the forward
35/// mapping injective and this inverse exact rather than a guess.
36///
37/// # Errors
38///
39/// Returns [`ConfigError::Setting`] when a variable's name decodes to no path at all,
40/// as `ONETASKGRAPH_` and `ONETASKGRAPH_SOURCES__` do, and when one of these variables
41/// holds a value that is not valid Unicode — a setting this build cannot read is
42/// refused by name rather than quietly left unset.
43pub fn layer(environment: &Environment) -> Result<Layer, ConfigError> {
44    for variable in environment.unusable() {
45        if variable.starts_with(ENVIRONMENT_PREFIX) {
46            return Err(ConfigError::setting(
47                variable,
48                "this variable's value is not valid Unicode, so it cannot be read as a \
49                 setting",
50                "export it again with a value this shell and this process agree on, or \
51                 unset it and set the setting in a configuration document instead.",
52            ));
53        }
54    }
55
56    let mut settings = Vec::new();
57    for (variable, raw) in environment.iter() {
58        let Some(encoded) = variable.strip_prefix(ENVIRONMENT_PREFIX) else {
59            continue;
60        };
61        if RESERVED.contains(&variable) {
62            continue;
63        }
64        settings.push(Setting {
65            key: path_from(encoded, variable)?,
66            value: value_from_text(raw),
67            origin: Origin::Environment {
68                variable: variable.to_owned(),
69            },
70        });
71    }
72    Ok(Layer::new(settings))
73}
74
75/// Decode one variable name's suffix into the setting path it addresses.
76fn path_from(encoded: &str, variable: &str) -> Result<SettingPath, ConfigError> {
77    let segments: Vec<String> = encoded
78        .split(SEGMENT_SEPARATOR)
79        .enumerate()
80        .map(|(index, segment)| decode_segment(segment, index, encoded))
81        .collect();
82    SettingPath::new(segments, variable)
83}
84
85/// One segment, lower-cased, with `_` restored to `-` where a source name sits.
86fn decode_segment(segment: &str, index: usize, encoded: &str) -> String {
87    let lowered = segment.to_ascii_lowercase();
88    if index == 1 && encoded.starts_with("SOURCES__") {
89        lowered.replace('_', "-")
90    } else {
91        lowered
92    }
93}
94
95/// The variable that sets `key`, for a message that tells a user what to export.
96#[must_use]
97pub fn variable_for(key: &SettingPath) -> String {
98    let segments: Vec<String> = key
99        .segments()
100        .iter()
101        .map(|segment| segment.to_ascii_uppercase().replace('-', "_"))
102        .collect();
103    format!("{ENVIRONMENT_PREFIX}{}", segments.join(SEGMENT_SEPARATOR))
104}