outrig_cli/cli/
env_arg.rs1use std::collections::BTreeMap;
12
13use thiserror::Error;
14
15use outrig::config::EnvValue;
16
17#[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#[derive(Debug, Clone, Default)]
29pub struct CliEnvEntries {
30 pub global: BTreeMap<String, EnvValue>,
32 pub per_server: BTreeMap<String, BTreeMap<String, EnvValue>>,
34}
35
36impl CliEnvEntries {
37 pub fn parse(raw: &[String]) -> Result<Self, CliEnvParseError> {
42 let mut result = Self::default();
43
44 for entry in raw {
45 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 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 if key.is_empty() {
75 return Err(CliEnvParseError::EmptyKey { raw: entry.clone() });
76 }
77 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 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 pub fn is_empty(&self) -> bool {
109 self.global.is_empty() && self.per_server.is_empty()
110 }
111
112 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}