Skip to main content

outrig_cli/cli/
env_arg.rs

1//! Parse and validate `--env` CLI entries for `outrig run` and `outrig mcp`.
2//!
3//! Each `--env` value is either:
4//! - `KEY=VALUE` -- applied to every MCP server (global).
5//! - `SERVER:KEY=VALUE` -- applied only to the named MCP server.
6//!
7//! The right-hand side is processed through [`EnvValue::from_raw`] so that
8//! `${VAR}` references resolve from the host environment at MCP startup,
9//! identically to config-file values.
10
11use std::collections::BTreeMap;
12
13use thiserror::Error;
14
15use outrig::config::EnvValue;
16
17/// Errors specific to `--env` parsing.
18#[derive(Debug, Error)]
19pub enum CliEnvParseError {
20    #[error("invalid value '{raw}' for '--env <KEY=VALUE>': missing '='")]
21    MissingEquals { raw: String },
22
23    #[error("invalid value '{raw}' for '--env <KEY=VALUE>': empty key")]
24    EmptyKey { raw: String },
25}
26
27/// Parsed `--env` entries, split into global and per-server maps.
28#[derive(Debug, Clone, Default)]
29pub struct CliEnvEntries {
30    /// `KEY=VALUE` entries applied to every MCP server.
31    pub global: BTreeMap<String, EnvValue>,
32    /// `SERVER:KEY=VALUE` entries keyed by server name.
33    pub per_server: BTreeMap<String, BTreeMap<String, EnvValue>>,
34}
35
36impl CliEnvEntries {
37    /// Parse raw `--env` strings into classified entries.
38    ///
39    /// Within a scope (global or per-server), last-wins for duplicate keys --
40    /// entries are inserted in order, so later values replace earlier ones.
41    pub fn parse(raw: &[String]) -> Result<Self, CliEnvParseError> {
42        let mut result = Self::default();
43
44        for entry in raw {
45            // Find the first `=` to split key-side from value.
46            let eq_pos = entry
47                .find('=')
48                .ok_or_else(|| CliEnvParseError::MissingEquals { raw: entry.clone() })?;
49
50            let key_side = &entry[..eq_pos];
51            let value_side = &entry[eq_pos + 1..];
52
53            if key_side.is_empty() {
54                return Err(CliEnvParseError::EmptyKey { raw: entry.clone() });
55            }
56
57            let env_value = EnvValue::from_raw(value_side.to_string());
58
59            // Check for `SERVER:KEY` form. A colon in the key-side means
60            // per-server -- but only if there's text on both sides of the
61            // colon.
62            if let Some(colon_pos) = key_side.find(':') {
63                let server = &key_side[..colon_pos];
64                let key = &key_side[colon_pos + 1..];
65
66                if server.is_empty() || key.is_empty() {
67                    // Treat as global if the colon form is malformed (e.g.
68                    // `:KEY=VAL` or `SERVER:=VAL`). The empty-key case will
69                    // be caught, the empty-server-but-nonempty-key case is
70                    // treated as a literal key including the leading colon
71                    // (matches env-var naming flexibility). Actually, let's
72                    // be strict: empty server or empty key after colon is an
73                    // error.
74                    if key.is_empty() {
75                        return Err(CliEnvParseError::EmptyKey { raw: entry.clone() });
76                    }
77                    // Empty server (`:KEY=VAL`) -- treat as global with key
78                    // `:KEY` would be confusing; error out.
79                    return Err(CliEnvParseError::EmptyKey { raw: entry.clone() });
80                }
81
82                result
83                    .per_server
84                    .entry(server.to_string())
85                    .or_default()
86                    .insert(key.to_string(), env_value);
87            } else {
88                result.global.insert(key_side.to_string(), env_value);
89            }
90        }
91
92        Ok(result)
93    }
94
95    /// Produce the merged env overlay for a specific server: global entries
96    /// with per-server entries layered on top (per-server wins on conflict).
97    pub fn for_server(&self, name: &str) -> BTreeMap<String, EnvValue> {
98        let mut merged = self.global.clone();
99        if let Some(per) = self.per_server.get(name) {
100            for (k, v) in per {
101                merged.insert(k.clone(), v.clone());
102            }
103        }
104        merged
105    }
106
107    /// Return `true` if there are no entries at all.
108    pub fn is_empty(&self) -> bool {
109        self.global.is_empty() && self.per_server.is_empty()
110    }
111
112    /// Return the set of server names referenced in per-server entries.
113    pub fn per_server_names(&self) -> impl Iterator<Item = &str> {
114        self.per_server.keys().map(String::as_str)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parse_global_entry() {
124        let entries = CliEnvEntries::parse(&["FOO=bar".to_string()]).unwrap();
125        assert_eq!(entries.global.len(), 1);
126        assert_eq!(entries.global["FOO"], EnvValue::Literal("bar".to_string()));
127        assert!(entries.per_server.is_empty());
128    }
129
130    #[test]
131    fn parse_per_server_entry() {
132        let entries = CliEnvEntries::parse(&["fs:DEBUG=1".to_string()]).unwrap();
133        assert!(entries.global.is_empty());
134        assert_eq!(entries.per_server.len(), 1);
135        assert_eq!(
136            entries.per_server["fs"]["DEBUG"],
137            EnvValue::Literal("1".to_string())
138        );
139    }
140
141    #[test]
142    fn parse_mixed_entries() {
143        let raw = vec![
144            "RUST_LOG=info".to_string(),
145            "build:CARGO_TERM_COLOR=always".to_string(),
146            "fs:DEBUG=1".to_string(),
147        ];
148        let entries = CliEnvEntries::parse(&raw).unwrap();
149        assert_eq!(entries.global.len(), 1);
150        assert_eq!(entries.per_server.len(), 2);
151        assert_eq!(
152            entries.global["RUST_LOG"],
153            EnvValue::Literal("info".to_string())
154        );
155        assert_eq!(
156            entries.per_server["build"]["CARGO_TERM_COLOR"],
157            EnvValue::Literal("always".to_string())
158        );
159    }
160
161    #[test]
162    fn parse_env_ref_in_value() {
163        let entries = CliEnvEntries::parse(&["GH_TOKEN=${GITHUB_TOKEN}".to_string()]).unwrap();
164        assert_eq!(
165            entries.global["GH_TOKEN"],
166            EnvValue::EnvRef("GITHUB_TOKEN".to_string())
167        );
168    }
169
170    #[test]
171    fn parse_last_wins_within_scope() {
172        let raw = vec!["FOO=first".to_string(), "FOO=second".to_string()];
173        let entries = CliEnvEntries::parse(&raw).unwrap();
174        assert_eq!(
175            entries.global["FOO"],
176            EnvValue::Literal("second".to_string())
177        );
178    }
179
180    #[test]
181    fn parse_last_wins_per_server() {
182        let raw = vec!["fs:DEBUG=0".to_string(), "fs:DEBUG=1".to_string()];
183        let entries = CliEnvEntries::parse(&raw).unwrap();
184        assert_eq!(
185            entries.per_server["fs"]["DEBUG"],
186            EnvValue::Literal("1".to_string())
187        );
188    }
189
190    #[test]
191    fn parse_rejects_missing_equals() {
192        let err = CliEnvEntries::parse(&["FOO".to_string()]).unwrap_err();
193        let msg = err.to_string();
194        assert!(msg.contains("missing '='"), "got: {msg}");
195    }
196
197    #[test]
198    fn parse_rejects_empty_key() {
199        let err = CliEnvEntries::parse(&["=value".to_string()]).unwrap_err();
200        let msg = err.to_string();
201        assert!(msg.contains("empty key"), "got: {msg}");
202    }
203
204    #[test]
205    fn parse_rejects_empty_key_after_colon() {
206        let err = CliEnvEntries::parse(&["fs:=value".to_string()]).unwrap_err();
207        let msg = err.to_string();
208        assert!(msg.contains("empty key"), "got: {msg}");
209    }
210
211    #[test]
212    fn parse_rejects_empty_server_before_colon() {
213        let err = CliEnvEntries::parse(&[":KEY=value".to_string()]).unwrap_err();
214        let msg = err.to_string();
215        assert!(msg.contains("empty key"), "got: {msg}");
216    }
217
218    #[test]
219    fn parse_allows_empty_value() {
220        let entries = CliEnvEntries::parse(&["FOO=".to_string()]).unwrap();
221        assert_eq!(entries.global["FOO"], EnvValue::Literal(String::new()));
222    }
223
224    #[test]
225    fn parse_allows_value_containing_equals() {
226        let entries = CliEnvEntries::parse(&["OPTS=--flag=val".to_string()]).unwrap();
227        assert_eq!(
228            entries.global["OPTS"],
229            EnvValue::Literal("--flag=val".to_string())
230        );
231    }
232
233    #[test]
234    fn for_server_merges_global_and_per_server() {
235        let raw = vec![
236            "GLOBAL=yes".to_string(),
237            "SHARED=global_val".to_string(),
238            "fs:SHARED=fs_val".to_string(),
239            "fs:LOCAL=only_fs".to_string(),
240        ];
241        let entries = CliEnvEntries::parse(&raw).unwrap();
242        let merged = entries.for_server("fs");
243
244        assert_eq!(merged["GLOBAL"], EnvValue::Literal("yes".to_string()));
245        assert_eq!(merged["SHARED"], EnvValue::Literal("fs_val".to_string()));
246        assert_eq!(merged["LOCAL"], EnvValue::Literal("only_fs".to_string()));
247    }
248
249    #[test]
250    fn for_server_returns_only_global_when_no_per_server() {
251        let raw = vec!["GLOBAL=yes".to_string()];
252        let entries = CliEnvEntries::parse(&raw).unwrap();
253        let merged = entries.for_server("unknown");
254
255        assert_eq!(merged.len(), 1);
256        assert_eq!(merged["GLOBAL"], EnvValue::Literal("yes".to_string()));
257    }
258
259    #[test]
260    fn is_empty_on_default() {
261        assert!(CliEnvEntries::default().is_empty());
262    }
263
264    #[test]
265    fn is_empty_false_with_global() {
266        let entries = CliEnvEntries::parse(&["X=1".to_string()]).unwrap();
267        assert!(!entries.is_empty());
268    }
269
270    #[test]
271    fn per_server_names_lists_servers() {
272        let raw = vec!["fs:A=1".to_string(), "build:B=2".to_string()];
273        let entries = CliEnvEntries::parse(&raw).unwrap();
274        let mut names: Vec<&str> = entries.per_server_names().collect();
275        names.sort();
276        assert_eq!(names, vec!["build", "fs"]);
277    }
278}