Skip to main content

runx_cli/
config.rs

1// rust-style-allow: large-file because the native config slice keeps parse,
2// execute, render, and parity tests together for one audited CLI surface.
3use 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
82// rust-style-allow: long-function because config set/get/list share one small
83// flag grammar and keeping it adjacent avoids divergent command parsing.
84pub 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    // rust-style-allow: long-function because one temp config lifecycle proves
334    // set/get/list masking against the same encrypted local state.
335    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}