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/// A whole namespace under [`ENVIRONMENT_PREFIX`] that holds no settings at all.
24///
25/// `onetaskgraph-live` spells this repository's own live-test variables here:
26/// `ONETASKGRAPH_LIVE_REQUIRED` says a live session is expected rather than optional,
27/// and `ONETASKGRAPH_LIVE_SEAT_DIR` says where that session's seat goes. Neither is a
28/// setting, and `.github/workflows/ci.yml` exports the first of them on the very step
29/// that runs the SDK generator and the journeys — so without this reservation every
30/// invocation of the binary on that step is refused for an unknown field called
31/// `live_required`. That is the failure [`RESERVED`] exists to prevent, one namespace
32/// wider.
33///
34/// A namespace rather than those two names, because the reservation has to hold for the
35/// next variable that lane adds; `scripts/check-live-lane.sh` fails when a live variable
36/// falls outside it, so the two cannot drift apart.
37const RESERVED_NAMESPACE: &str = "ONETASKGRAPH_LIVE_";
38
39/// The separator between path segments in a variable name.
40const SEGMENT_SEPARATOR: &str = "__";
41
42/// Whether a prefixed variable names something this layer must leave alone.
43fn reserved(variable: &str) -> bool {
44    RESERVED.contains(&variable) || variable.starts_with(RESERVED_NAMESPACE)
45}
46
47/// Every `ONETASKGRAPH_`-prefixed variable, as one configuration layer.
48///
49/// The rule, and its inverse. A variable's name is [`ENVIRONMENT_PREFIX`] followed by
50/// the setting's path, each segment upper-cased with `-` replaced by `_`, segments
51/// joined by [`SEGMENT_SEPARATOR`]. Decoding lower-cases each segment, and turns `_`
52/// back into `-` in exactly one position: the segment naming a source, immediately
53/// after `sources`. That is the only place a `-` can occur, because a
54/// [`SourceName`](onetaskgraph_plugin_api::SourceName) may not contain `_` while
55/// every other key in the document is `snake_case` — which is what makes the forward
56/// mapping injective and this inverse exact rather than a guess.
57///
58/// # Errors
59///
60/// Returns [`ConfigError::Setting`] when a variable's name decodes to no path at all,
61/// as `ONETASKGRAPH_` and `ONETASKGRAPH_SOURCES__` do, and when one of these variables
62/// holds a value that is not valid Unicode — a setting this build cannot read is
63/// refused by name rather than quietly left unset. A variable in [`RESERVED`] or under
64/// [`RESERVED_NAMESPACE`] is neither, on either count: it is not a setting, so this
65/// layer does not decode it and does not refuse it for a value it never reads.
66pub fn layer(environment: &Environment) -> Result<Layer, ConfigError> {
67    for variable in environment.unusable() {
68        if variable.starts_with(ENVIRONMENT_PREFIX) && !reserved(variable) {
69            return Err(ConfigError::setting(
70                variable,
71                "this variable's value is not valid Unicode, so it cannot be read as a \
72                 setting",
73                "export it again with a value this shell and this process agree on, or \
74                 unset it and set the setting in a configuration document instead.",
75            ));
76        }
77    }
78
79    let mut settings = Vec::new();
80    for (variable, raw) in environment.iter() {
81        let Some(encoded) = variable.strip_prefix(ENVIRONMENT_PREFIX) else {
82            continue;
83        };
84        if reserved(variable) {
85            continue;
86        }
87        settings.push(Setting {
88            key: path_from(encoded, variable)?,
89            value: value_from_text(raw),
90            origin: Origin::Environment {
91                variable: variable.to_owned(),
92            },
93        });
94    }
95    Ok(Layer::new(settings))
96}
97
98/// Decode one variable name's suffix into the setting path it addresses.
99fn path_from(encoded: &str, variable: &str) -> Result<SettingPath, ConfigError> {
100    let segments: Vec<String> = encoded
101        .split(SEGMENT_SEPARATOR)
102        .enumerate()
103        .map(|(index, segment)| decode_segment(segment, index, encoded))
104        .collect();
105    SettingPath::new(segments, variable)
106}
107
108/// One segment, lower-cased, with `_` restored to `-` where a source name sits.
109fn decode_segment(segment: &str, index: usize, encoded: &str) -> String {
110    let lowered = segment.to_ascii_lowercase();
111    if index == 1 && encoded.starts_with("SOURCES__") {
112        lowered.replace('_', "-")
113    } else {
114        lowered
115    }
116}
117
118/// The variable that sets `key`, for a message that tells a user what to export.
119#[must_use]
120pub fn variable_for(key: &SettingPath) -> String {
121    let segments: Vec<String> = key
122        .segments()
123        .iter()
124        .map(|segment| segment.to_ascii_uppercase().replace('-', "_"))
125        .collect();
126    format!("{ENVIRONMENT_PREFIX}{}", segments.join(SEGMENT_SEPARATOR))
127}