1use std::collections::BTreeMap;
4use std::ffi::OsString;
5use std::fmt;
6use std::path::Path;
7
8use crate::cli_args::{os_arg, split_flag};
9use runx_runtime::{
10 ConfigError, RunxConfigFile, load_runx_config_file, lookup_runx_config_value,
11 mask_runx_config_file, parse_config_key, resolve_runx_home_dir, update_runx_config_value,
12 write_runx_config_file,
13};
14use serde::Serialize;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum ConfigAction {
19 Set,
20 Get,
21 List,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ConfigPlan {
26 pub action: ConfigAction,
27 pub key: Option<String>,
28 pub value: Option<String>,
29 pub json: bool,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
33#[serde(untagged)]
34pub enum ConfigResult {
35 Set {
36 action: ConfigAction,
37 key: String,
38 value: Option<String>,
39 },
40 Get {
41 action: ConfigAction,
42 key: String,
43 value: Option<String>,
44 },
45 List {
46 action: ConfigAction,
47 values: RunxConfigFile,
48 },
49}
50
51#[derive(Debug)]
52pub enum ConfigCliError {
53 InvalidArgs(String),
54 Config(ConfigError),
55 Serialize(serde_json::Error),
56}
57
58impl fmt::Display for ConfigCliError {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::InvalidArgs(message) => formatter.write_str(message),
62 Self::Config(error) => write!(formatter, "{error}"),
63 Self::Serialize(error) => write!(formatter, "failed to serialize config: {error}"),
64 }
65 }
66}
67
68impl std::error::Error for ConfigCliError {}
69
70impl From<ConfigError> for ConfigCliError {
71 fn from(error: ConfigError) -> Self {
72 Self::Config(error)
73 }
74}
75
76impl From<serde_json::Error> for ConfigCliError {
77 fn from(error: serde_json::Error) -> Self {
78 Self::Serialize(error)
79 }
80}
81
82pub fn parse_config_plan(args: &[OsString]) -> Result<ConfigPlan, String> {
85 let command = os_arg(args, 0, "config")?;
86 if command != "config" {
87 return Err("config parser requires the config command".to_owned());
88 }
89
90 let Some(subcommand) = args.get(1).and_then(|arg| arg.to_str()) else {
91 return Err("runx config requires set, get, or list".to_owned());
92 };
93 let action = match subcommand {
94 "set" => ConfigAction::Set,
95 "get" => ConfigAction::Get,
96 "list" => ConfigAction::List,
97 _ => return Err(format!("unknown config subcommand {subcommand}")),
98 };
99
100 let mut json = false;
101 let mut positionals = Vec::new();
102 let mut index = 2;
103 while index < args.len() {
104 let token = os_arg(args, index, "config")?;
105 if !token.starts_with('-') {
106 positionals.push(token.to_owned());
107 index += 1;
108 continue;
109 }
110
111 let (flag, inline_value) = split_flag(token);
112 match flag {
113 "--json" | "-j" => {
114 if inline_value.is_some() {
115 return Err("--json does not take a value".to_owned());
116 }
117 json = true;
118 index += 1;
119 }
120 _ => return Err(format!("unknown config flag {flag}")),
121 }
122 }
123
124 match action {
125 ConfigAction::List => {
126 if !positionals.is_empty() {
127 return Err("runx config list does not accept extra arguments".to_owned());
128 }
129 Ok(ConfigPlan {
130 action,
131 key: None,
132 value: None,
133 json,
134 })
135 }
136 ConfigAction::Get => {
137 let [key] = positionals.as_slice() else {
138 return Err("runx config get requires exactly one key".to_owned());
139 };
140 Ok(ConfigPlan {
141 action,
142 key: Some(normalize_config_key(key).to_owned()),
143 value: None,
144 json,
145 })
146 }
147 ConfigAction::Set => {
148 let [key, values @ ..] = positionals.as_slice() else {
149 return Err("runx config set requires a key and value".to_owned());
150 };
151 if values.is_empty() {
152 return Err("runx config set requires a value".to_owned());
153 }
154 Ok(ConfigPlan {
155 action,
156 key: Some(normalize_config_key(key).to_owned()),
157 value: Some(values.join(" ")),
158 json,
159 })
160 }
161 }
162}
163
164fn normalize_config_key(key: &str) -> &str {
165 match key {
166 "provider" => "agent.provider",
167 "model" => "agent.model",
168 "api-key" | "agent-key" => "agent.api_key",
169 "public-token" => "public.api_token",
170 _ => key,
171 }
172}
173
174pub fn run_config_command(
175 plan: &ConfigPlan,
176 env: &BTreeMap<String, String>,
177 cwd: &Path,
178) -> Result<String, ConfigCliError> {
179 let result = execute_config_plan(plan, env, cwd)?;
180 if plan.json {
181 return Ok(format!(
182 "{}\n",
183 serde_json::to_string_pretty(&ConfigJsonResult {
184 status: "success",
185 config: &result,
186 })?
187 ));
188 }
189 Ok(render_config_result(&result))
190}
191
192fn execute_config_plan(
193 plan: &ConfigPlan,
194 env: &BTreeMap<String, String>,
195 cwd: &Path,
196) -> Result<ConfigResult, ConfigCliError> {
197 let config_dir = resolve_runx_home_dir(env, cwd);
198 let config_path = config_dir.join("config.json");
199 let config = load_runx_config_file(&config_path)?;
200
201 match plan.action {
202 ConfigAction::List => Ok(ConfigResult::List {
203 action: ConfigAction::List,
204 values: mask_runx_config_file(&config),
205 }),
206 ConfigAction::Get => {
207 let key = required_key(plan)?;
208 let parsed_key = parse_config_key(key)?;
209 Ok(ConfigResult::Get {
210 action: ConfigAction::Get,
211 key: key.to_owned(),
212 value: lookup_runx_config_value(&config, parsed_key),
213 })
214 }
215 ConfigAction::Set => {
216 let key = required_key(plan)?;
217 let value = plan.value.as_deref().ok_or_else(|| {
218 ConfigCliError::InvalidArgs("config value is required.".to_owned())
219 })?;
220 let parsed_key = parse_config_key(key)?;
221 let next = update_runx_config_value(config, parsed_key, value, &config_dir)?;
222 write_runx_config_file(&config_path, &next)?;
223 Ok(ConfigResult::Set {
224 action: ConfigAction::Set,
225 key: key.to_owned(),
226 value: lookup_runx_config_value(&mask_runx_config_file(&next), parsed_key),
227 })
228 }
229 }
230}
231
232fn required_key(plan: &ConfigPlan) -> Result<&str, ConfigCliError> {
233 plan.key
234 .as_deref()
235 .ok_or_else(|| ConfigCliError::InvalidArgs("config key is required.".to_owned()))
236}
237
238fn render_config_result(result: &ConfigResult) -> String {
239 match result {
240 ConfigResult::List { values, .. } => {
241 let entries = flatten_config(values);
242 if entries.is_empty() {
243 return "\n No config values set.\n\n".to_owned();
244 }
245 let rows = entries
246 .iter()
247 .map(|(key, value)| (*key, Some(*value)))
248 .collect::<Vec<_>>();
249 render_key_value("config", "success", &rows)
250 }
251 ConfigResult::Get { key, value, .. } | ConfigResult::Set { key, value, .. } => {
252 render_key_value("config", "success", &[(key.as_str(), value.as_deref())])
253 }
254 }
255}
256
257fn flatten_config(config: &RunxConfigFile) -> Vec<(&'static str, &str)> {
258 let mut rows = Vec::new();
259 if let Some(agent) = config.agent.as_ref() {
260 if let Some(provider) = agent.provider.as_deref() {
261 rows.push(("agent.provider", provider));
262 }
263 if let Some(model) = agent.model.as_deref() {
264 rows.push(("agent.model", model));
265 }
266 if let Some(api_key_ref) = agent.api_key_ref.as_deref() {
267 rows.push(("agent.api_key", api_key_ref));
268 }
269 }
270 if let Some(public) = config.public.as_ref()
271 && let Some(api_token_ref) = public.api_token_ref.as_deref()
272 {
273 rows.push(("public.api_token", api_token_ref));
274 }
275 rows
276}
277
278fn render_key_value(title: &str, status: &str, rows: &[(&str, Option<&str>)]) -> String {
279 let visible = rows
280 .iter()
281 .filter(|(_label, value)| value.is_some_and(|value| !value.is_empty()))
282 .collect::<Vec<_>>();
283 let width = visible
284 .iter()
285 .map(|(label, _value)| label.len())
286 .max()
287 .unwrap_or(0);
288 let mut lines = vec![String::new(), format!(" ✓ {title} {status}")];
289 lines.extend(
290 visible
291 .into_iter()
292 .map(|(label, value)| format!(" {label:<width$} {}", value.unwrap_or_default())),
293 );
294 lines.push(String::new());
295 lines.join("\n")
296}
297
298#[derive(Serialize)]
299struct ConfigJsonResult<'a> {
300 status: &'static str,
301 config: &'a ConfigResult,
302}
303
304#[cfg(test)]
305mod tests {
306 use std::fs;
307 use std::io;
308 use std::path::PathBuf;
309
310 use super::*;
311
312 #[test]
313 fn parses_config_set_with_multi_word_value() {
314 assert_eq!(
315 parse_config_plan(&[
316 "config".into(),
317 "set".into(),
318 "model".into(),
319 "gpt".into(),
320 "test".into(),
321 "-j".into(),
322 ]),
323 Ok(ConfigPlan {
324 action: ConfigAction::Set,
325 key: Some("agent.model".to_owned()),
326 value: Some("gpt test".to_owned()),
327 json: true,
328 })
329 );
330 }
331
332 #[test]
333 fn config_set_get_list_masks_api_key() -> Result<(), ConfigTestError> {
336 let temp = tempfile_dir()?;
337 let runx_home = temp.join(".runx");
338 let env = BTreeMap::from([(
339 "RUNX_HOME".to_owned(),
340 runx_home.to_string_lossy().to_string(),
341 )]);
342
343 let set_provider = ConfigPlan {
344 action: ConfigAction::Set,
345 key: Some("agent.provider".to_owned()),
346 value: Some("openai".to_owned()),
347 json: true,
348 };
349 let set_key = ConfigPlan {
350 action: ConfigAction::Set,
351 key: Some("agent.api_key".to_owned()),
352 value: Some("sk-secret-test".to_owned()),
353 json: true,
354 };
355 let set_public_token = ConfigPlan {
356 action: ConfigAction::Set,
357 key: Some("public.api_token".to_owned()),
358 value: Some("rxk-secret-test".to_owned()),
359 json: true,
360 };
361 run_config_command(&set_provider, &env, &temp)?;
362 let key_output = run_config_command(&set_key, &env, &temp)?;
363 let public_output = run_config_command(&set_public_token, &env, &temp)?;
364 assert!(key_output.contains("\"value\": \"[encrypted]\""));
365 assert!(public_output.contains("\"value\": \"[encrypted]\""));
366 assert!(!key_output.contains("sk-secret-test"));
367 assert!(!public_output.contains("rxk-secret-test"));
368
369 let get_output = run_config_command(
370 &ConfigPlan {
371 action: ConfigAction::Get,
372 key: Some("agent.api_key".to_owned()),
373 value: None,
374 json: false,
375 },
376 &env,
377 &temp,
378 )?;
379 assert!(get_output.contains("agent.api_key"));
380 assert!(get_output.contains("[encrypted]"));
381 assert!(!get_output.contains("sk-secret-test"));
382
383 let list_output = run_config_command(
384 &ConfigPlan {
385 action: ConfigAction::List,
386 key: None,
387 value: None,
388 json: false,
389 },
390 &env,
391 &temp,
392 )?;
393 assert!(list_output.contains("agent.provider"));
394 assert!(list_output.contains("openai"));
395 assert!(list_output.contains("agent.api_key"));
396 assert!(list_output.contains("public.api_token"));
397 assert!(list_output.contains("[encrypted]"));
398 assert!(!list_output.contains("sk-secret-test"));
399 assert!(!list_output.contains("rxk-secret-test"));
400
401 let config_contents = fs::read_to_string(runx_home.join("config.json"))?;
402 assert!(config_contents.contains("api_key_ref"));
403 assert!(config_contents.contains("api_token_ref"));
404 assert!(!config_contents.contains("sk-secret-test"));
405 assert!(!config_contents.contains("rxk-secret-test"));
406 fs::remove_dir_all(temp)?;
407 Ok(())
408 }
409
410 #[derive(Debug)]
411 enum ConfigTestError {
412 Io(io::Error),
413 Cli(ConfigCliError),
414 }
415
416 impl std::fmt::Display for ConfigTestError {
417 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418 match self {
419 Self::Io(error) => write!(formatter, "{error}"),
420 Self::Cli(error) => write!(formatter, "{error}"),
421 }
422 }
423 }
424
425 impl std::error::Error for ConfigTestError {}
426
427 impl From<io::Error> for ConfigTestError {
428 fn from(error: io::Error) -> Self {
429 Self::Io(error)
430 }
431 }
432
433 impl From<ConfigCliError> for ConfigTestError {
434 fn from(error: ConfigCliError) -> Self {
435 Self::Cli(error)
436 }
437 }
438
439 fn tempfile_dir() -> Result<PathBuf, io::Error> {
440 let path = std::env::temp_dir().join(format!(
441 "runx-cli-config-{}-{}",
442 std::process::id(),
443 std::time::SystemTime::now()
444 .duration_since(std::time::UNIX_EPOCH)
445 .unwrap_or_default()
446 .as_nanos()
447 ));
448 fs::create_dir_all(&path)?;
449 Ok(path)
450 }
451}