Skip to main content

agent_doc_config/
env.rs

1//! # Module: env
2//!
3//! ## Spec
4//! - `expand_values(env)` takes an order-preserving map of env var name → optional
5//!   raw value (`$(command)` / `$VAR` expressions). Returns a
6//!   `Vec<(String, Option<String>)>` of resolved entries in document order. A
7//!   `None` input value stays `None` in the output and means "unset this key in
8//!   the child env".
9//! - Each set value is expanded through a single `sh -c` invocation that exports
10//!   vars sequentially so later values can reference earlier keys. Unset entries
11//!   are NOT exported into the expansion shell — cross-references only see keys
12//!   that are being set.
13//! - Returns an empty `Vec` when the input is empty (no shell spawn).
14//! - `shell_export_prefix(env)` returns a string of the form
15//!   `export K1="V1" && unset K2 && ...` using **unexpanded** values, suitable for
16//!   prepending to a `tmux send-keys` command so the target pane's shell expands
17//!   `$(passage ...)` internally — keeps secrets out of the tmux visual layer.
18//!
19//! ## Agentic Contracts
20//! - `expand_values` performs synchronous shell I/O; failures propagate via
21//!   `anyhow::Result`.
22//! - The order of returned pairs matches the input `IndexMap` iteration order,
23//!   including unset entries.
24//! - `shell_export_prefix` is purely textual — it does NOT touch the shell or
25//!   expand anything. It escapes double quotes in values with `\"`.
26//!
27//! ## Evals
28//! - expand_values_plain: `{FOO: Some("bar"), BAZ: Some("qux")}` → ordered set pairs
29//! - expand_values_shell_expansion: `{GREETING: Some("$(echo hello)")}` → `hello`
30//! - expand_values_cross_reference: chained `$VAR` references resolve in order
31//! - expand_values_unset_preserved: `{FOO: None}` → `[(FOO, None)]`, no shell spawn for unsets alone
32//! - expand_values_mixed: set + unset preserved in document order
33//! - expand_values_empty: empty map → empty Vec, no shell spawned
34//! - shell_export_prefix_empty: empty map → empty string
35//! - shell_export_prefix_basic: two sets → `export K1="v1" && export K2="v2" && `
36//! - shell_export_prefix_unset: `{K: None}` → `unset K && `
37//! - shell_export_prefix_quotes_in_value: value with `"` → escaped with `\"`
38
39use anyhow::{Context, Result};
40use indexmap::IndexMap;
41
42/// Order-preserving env map. `None` values mean "unset this key".
43pub type EnvMap = IndexMap<String, Option<String>>;
44
45/// Expand an env var map through the shell, returning pairs in document order.
46///
47/// Values may contain `$(command)` and `$VAR` — the shell expands them. Later
48/// values can reference earlier keys because all set vars are exported
49/// sequentially within the same shell invocation before being printed.
50/// `None` entries are passed through unchanged; they signal "unset" to callers.
51pub fn expand_values(env: &EnvMap) -> Result<Vec<(String, Option<String>)>> {
52    if env.is_empty() {
53        return Ok(Vec::new());
54    }
55    // Partition: build a script that only exports & prints the Some entries,
56    // but preserve document order so None entries can be re-interleaved.
57    let set_keys: Vec<&String> = env
58        .iter()
59        .filter_map(|(k, v)| v.as_ref().map(|_| k))
60        .collect();
61
62    let expanded: Vec<String> = if set_keys.is_empty() {
63        Vec::new()
64    } else {
65        let mut script = String::new();
66        for (key, value) in env {
67            if let Some(v) = value {
68                script.push_str(&format!("export {}={}\n", key, v));
69            }
70        }
71        for key in &set_keys {
72            script.push_str(&format!("printf '%s\\0' \"${{{}}}\"\n", key));
73        }
74        let output = std::process::Command::new("sh")
75            .arg("-c")
76            .arg(&script)
77            .output()
78            .context("failed to spawn shell for env expansion")?;
79        if !output.status.success() {
80            let stderr = String::from_utf8_lossy(&output.stderr);
81            anyhow::bail!("env expansion failed: {}", stderr.trim());
82        }
83        let stdout =
84            String::from_utf8(output.stdout).context("env expansion output is not valid UTF-8")?;
85        stdout
86            .split('\0')
87            .take(set_keys.len())
88            .map(|s| s.to_string())
89            .collect()
90    };
91
92    let mut set_iter = expanded.into_iter();
93    let mut result = Vec::with_capacity(env.len());
94    for (key, value) in env {
95        match value {
96            Some(_) => {
97                let v = set_iter.next().unwrap_or_default();
98                result.push((key.clone(), Some(v)));
99            }
100            None => {
101                result.push((key.clone(), None));
102            }
103        }
104    }
105    Ok(result)
106}
107
108/// Build a shell `export K="V" && unset K2 && ...` prefix suitable for
109/// prepending to a `tmux send-keys` command. Values are passed **unexpanded**
110/// so secrets like `$(passage ...)` are resolved by the target pane's shell,
111/// never appearing in the send-keys argument list or tmux scrollback.
112pub fn shell_export_prefix(env: &EnvMap) -> String {
113    if env.is_empty() {
114        return String::new();
115    }
116    let mut prefix = String::new();
117    for (key, value) in env {
118        match value {
119            Some(v) => {
120                let escaped = v.replace('"', "\\\"");
121                prefix.push_str(&format!("export {}=\"{}\" && ", key, escaped));
122            }
123            None => {
124                prefix.push_str(&format!("unset {} && ", key));
125            }
126        }
127    }
128    prefix
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn set(k: &str, v: &str) -> (String, Option<String>) {
136        (k.to_string(), Some(v.to_string()))
137    }
138    fn unset(k: &str) -> (String, Option<String>) {
139        (k.to_string(), None)
140    }
141
142    #[test]
143    fn expand_values_plain() {
144        let env: EnvMap = [set("FOO", "bar"), set("BAZ", "qux")].into_iter().collect();
145        let result = expand_values(&env).unwrap();
146        assert_eq!(
147            result,
148            vec![
149                ("FOO".to_string(), Some("bar".to_string())),
150                ("BAZ".to_string(), Some("qux".to_string())),
151            ]
152        );
153    }
154
155    #[test]
156    fn expand_values_shell_expansion() {
157        let env: EnvMap = [set("GREETING", "$(echo hello)")].into_iter().collect();
158        let result = expand_values(&env).unwrap();
159        assert_eq!(result[0].1, Some("hello".to_string()));
160    }
161
162    #[test]
163    fn expand_values_cross_reference() {
164        let env: EnvMap = [set("BASE", "world"), set("DERIVED", "$BASE")]
165            .into_iter()
166            .collect();
167        let result = expand_values(&env).unwrap();
168        assert_eq!(result[0], ("BASE".to_string(), Some("world".to_string())));
169        assert_eq!(
170            result[1],
171            ("DERIVED".to_string(), Some("world".to_string()))
172        );
173    }
174
175    #[test]
176    fn expand_values_unset_preserved() {
177        let env: EnvMap = [unset("FOO")].into_iter().collect();
178        let result = expand_values(&env).unwrap();
179        assert_eq!(result, vec![("FOO".to_string(), None)]);
180    }
181
182    #[test]
183    fn expand_values_mixed_set_and_unset_document_order() {
184        let env: EnvMap = [
185            set("FIRST", "1"),
186            unset("MIDDLE"),
187            set("LAST", "$FIRST-tail"),
188        ]
189        .into_iter()
190        .collect();
191        let result = expand_values(&env).unwrap();
192        assert_eq!(result[0], ("FIRST".to_string(), Some("1".to_string())));
193        assert_eq!(result[1], ("MIDDLE".to_string(), None));
194        assert_eq!(result[2], ("LAST".to_string(), Some("1-tail".to_string())));
195    }
196
197    #[test]
198    fn expand_values_empty() {
199        let env: EnvMap = IndexMap::new();
200        let result = expand_values(&env).unwrap();
201        assert!(result.is_empty());
202    }
203
204    #[test]
205    fn shell_export_prefix_empty() {
206        let env: EnvMap = IndexMap::new();
207        assert_eq!(shell_export_prefix(&env), "");
208    }
209
210    #[test]
211    fn shell_export_prefix_basic() {
212        let env: EnvMap = [set("K1", "v1"), set("K2", "v2")].into_iter().collect();
213        assert_eq!(
214            shell_export_prefix(&env),
215            "export K1=\"v1\" && export K2=\"v2\" && "
216        );
217    }
218
219    #[test]
220    fn shell_export_prefix_unset() {
221        let env: EnvMap = [unset("K1")].into_iter().collect();
222        assert_eq!(shell_export_prefix(&env), "unset K1 && ");
223    }
224
225    #[test]
226    fn shell_export_prefix_mixed() {
227        let env: EnvMap = [set("K1", "v1"), unset("K2"), set("K3", "v3")]
228            .into_iter()
229            .collect();
230        assert_eq!(
231            shell_export_prefix(&env),
232            "export K1=\"v1\" && unset K2 && export K3=\"v3\" && "
233        );
234    }
235
236    #[test]
237    fn shell_export_prefix_quotes_in_value() {
238        let env: EnvMap = [set("K", "a\"b")].into_iter().collect();
239        assert_eq!(shell_export_prefix(&env), "export K=\"a\\\"b\" && ");
240    }
241
242    #[test]
243    fn shell_export_prefix_unexpanded_passage() {
244        let env: EnvMap = [set("TOKEN", "$(passage btak/secret)")]
245            .into_iter()
246            .collect();
247        let prefix = shell_export_prefix(&env);
248        assert!(prefix.contains("$(passage btak/secret)"));
249        assert!(!prefix.contains("export TOKEN=\"\""));
250    }
251}