Skip to main content

onetaskgraph_core/
environment.rs

1//! The process environment, captured once.
2//!
3//! Everything downstream — the environment configuration layer, document discovery,
4//! and secret resolution — reads this snapshot rather than calling
5//! [`std::env::var`] itself. Two reasons, and only the second is about tests. A
6//! command that read the live environment twice could answer one question two ways
7//! if something changed between the reads. And a snapshot is a value, so the pure
8//! parsing and merging below it take one as an argument instead of reaching out of
9//! process.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::ffi::OsString;
13use std::fmt;
14
15/// A snapshot of the environment a process was started with.
16///
17/// # Debug redacts
18///
19/// This holds whatever the process was given, which on a configured host includes
20/// `LINEAR_API_KEY` and `GH_PROJECTS_TOKEN`. Deriving `Debug` would put both in
21/// any log line, panic message or `{:?}` that ever touched one, so the
22/// implementation below prints the *names* and never a value.
23#[derive(Clone, PartialEq, Eq, Default)]
24pub struct Environment {
25    variables: BTreeMap<String, String>,
26    unusable: BTreeSet<String>,
27}
28
29impl Environment {
30    /// Capture the environment this process was started with.
31    ///
32    /// Read as OS strings rather than through [`std::env::vars`], which panics on an
33    /// entry that is not valid Unicode — and a process is handed its environment by
34    /// whoever spawned it, so that is an input from outside rather than an invariant.
35    ///
36    /// A *name* that is not Unicode is dropped: no configuration document, no
37    /// `ONETASKGRAPH_` variable and no `api_key_env:` can spell one, so nothing in this
38    /// product could ever ask for it. A *value* that is not Unicode keeps its name in
39    /// [`unusable`](Self::unusable), where the environment layer turns a
40    /// `ONETASKGRAPH_`-prefixed one into a refusal naming the variable — because a
41    /// setting this product cannot read is exactly the thing it may not quietly ignore.
42    #[must_use]
43    pub fn from_process() -> Self {
44        Self::from_os_pairs(std::env::vars_os())
45    }
46
47    /// Build a snapshot from OS strings, sorting out what this product can read.
48    ///
49    /// Separate from [`from_process`](Self::from_process) so that the sorting is a
50    /// function of its argument: a hand-built pair is the only way to drive the two
51    /// not-valid-Unicode cases without one process spawning another.
52    #[must_use]
53    pub fn from_os_pairs(pairs: impl IntoIterator<Item = (OsString, OsString)>) -> Self {
54        let mut variables = BTreeMap::new();
55        let mut unusable = BTreeSet::new();
56        for (name, value) in pairs {
57            let Ok(name) = name.into_string() else {
58                continue;
59            };
60            match value.into_string() {
61                Ok(value) => {
62                    variables.insert(name, value);
63                }
64                Err(_) => {
65                    unusable.insert(name);
66                }
67            }
68        }
69        Self {
70            variables,
71            unusable,
72        }
73    }
74
75    /// Build a snapshot from explicit pairs.
76    #[must_use]
77    pub fn from_pairs<K, V>(pairs: impl IntoIterator<Item = (K, V)>) -> Self
78    where
79        K: Into<String>,
80        V: Into<String>,
81    {
82        Self {
83            variables: pairs
84                .into_iter()
85                .map(|(name, value)| (name.into(), value.into()))
86                .collect(),
87            unusable: BTreeSet::new(),
88        }
89    }
90
91    /// Every name this process was given whose value is not valid Unicode.
92    pub fn unusable(&self) -> impl Iterator<Item = &str> {
93        self.unusable.iter().map(String::as_str)
94    }
95
96    /// The value of `name`, or `None` when the snapshot does not define it.
97    #[must_use]
98    pub fn get(&self, name: &str) -> Option<&str> {
99        self.variables.get(name).map(String::as_str)
100    }
101
102    /// The value of `name` when it is set to something other than the empty string.
103    ///
104    /// An exported-but-empty variable is how a shell says "unset this for the child"
105    /// in practice, and treating `XDG_CONFIG_HOME=` as a path to the filesystem root
106    /// would send discovery somewhere nobody asked for.
107    #[must_use]
108    pub fn non_empty(&self, name: &str) -> Option<&str> {
109        self.get(name).filter(|value| !value.is_empty())
110    }
111
112    /// Every variable, name and value, in name order.
113    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
114        self.variables
115            .iter()
116            .map(|(name, value)| (name.as_str(), value.as_str()))
117    }
118
119    /// Every variable name, in order.
120    pub fn names(&self) -> impl Iterator<Item = &str> {
121        self.variables.keys().map(String::as_str)
122    }
123}
124
125impl fmt::Debug for Environment {
126    /// Names only. See the type's own note: a value here may be a live credential.
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.debug_struct("Environment")
129            .field("variables", &self.names().collect::<Vec<_>>())
130            .field("unusable", &self.unusable().collect::<Vec<_>>())
131            .field("values", &"<redacted>")
132            .finish()
133    }
134}