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}