use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::Path;
use crate::cli_args::{os_arg, split_flag};
use runx_runtime::{
ConfigError, RunxConfigFile, load_runx_config_file, lookup_runx_config_value,
mask_runx_config_file, parse_config_key, resolve_runx_home_dir, update_runx_config_value,
write_runx_config_file,
};
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigAction {
Set,
Get,
List,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigPlan {
pub action: ConfigAction,
pub key: Option<String>,
pub value: Option<String>,
pub json: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(untagged)]
pub enum ConfigResult {
Set {
action: ConfigAction,
key: String,
value: Option<String>,
},
Get {
action: ConfigAction,
key: String,
value: Option<String>,
},
List {
action: ConfigAction,
values: RunxConfigFile,
},
}
#[derive(Debug)]
pub enum ConfigCliError {
InvalidArgs(String),
Config(ConfigError),
Serialize(serde_json::Error),
}
impl fmt::Display for ConfigCliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidArgs(message) => formatter.write_str(message),
Self::Config(error) => write!(formatter, "{error}"),
Self::Serialize(error) => write!(formatter, "failed to serialize config: {error}"),
}
}
}
impl std::error::Error for ConfigCliError {}
impl From<ConfigError> for ConfigCliError {
fn from(error: ConfigError) -> Self {
Self::Config(error)
}
}
impl From<serde_json::Error> for ConfigCliError {
fn from(error: serde_json::Error) -> Self {
Self::Serialize(error)
}
}
pub fn parse_config_plan(args: &[OsString]) -> Result<ConfigPlan, String> {
let command = os_arg(args, 0, "config")?;
if command != "config" {
return Err("config parser requires the config command".to_owned());
}
let Some(subcommand) = args.get(1).and_then(|arg| arg.to_str()) else {
return Err("runx config requires set, get, or list".to_owned());
};
let action = match subcommand {
"set" => ConfigAction::Set,
"get" => ConfigAction::Get,
"list" => ConfigAction::List,
_ => return Err(format!("unknown config subcommand {subcommand}")),
};
let mut json = false;
let mut positionals = Vec::new();
let mut index = 2;
while index < args.len() {
let token = os_arg(args, index, "config")?;
if !token.starts_with('-') {
positionals.push(token.to_owned());
index += 1;
continue;
}
let (flag, inline_value) = split_flag(token);
match flag {
"--json" | "-j" => {
if inline_value.is_some() {
return Err("--json does not take a value".to_owned());
}
json = true;
index += 1;
}
_ => return Err(format!("unknown config flag {flag}")),
}
}
match action {
ConfigAction::List => {
if !positionals.is_empty() {
return Err("runx config list does not accept extra arguments".to_owned());
}
Ok(ConfigPlan {
action,
key: None,
value: None,
json,
})
}
ConfigAction::Get => {
let [key] = positionals.as_slice() else {
return Err("runx config get requires exactly one key".to_owned());
};
Ok(ConfigPlan {
action,
key: Some(normalize_config_key(key).to_owned()),
value: None,
json,
})
}
ConfigAction::Set => {
let [key, values @ ..] = positionals.as_slice() else {
return Err("runx config set requires a key and value".to_owned());
};
if values.is_empty() {
return Err("runx config set requires a value".to_owned());
}
Ok(ConfigPlan {
action,
key: Some(normalize_config_key(key).to_owned()),
value: Some(values.join(" ")),
json,
})
}
}
}
fn normalize_config_key(key: &str) -> &str {
match key {
"provider" => "agent.provider",
"model" => "agent.model",
"api-key" | "agent-key" => "agent.api_key",
"public-token" => "public.api_token",
_ => key,
}
}
pub fn run_config_command(
plan: &ConfigPlan,
env: &BTreeMap<String, String>,
cwd: &Path,
) -> Result<String, ConfigCliError> {
let result = execute_config_plan(plan, env, cwd)?;
if plan.json {
return Ok(format!(
"{}\n",
serde_json::to_string_pretty(&ConfigJsonResult {
status: "success",
config: &result,
})?
));
}
Ok(render_config_result(&result))
}
fn execute_config_plan(
plan: &ConfigPlan,
env: &BTreeMap<String, String>,
cwd: &Path,
) -> Result<ConfigResult, ConfigCliError> {
let config_dir = resolve_runx_home_dir(env, cwd);
let config_path = config_dir.join("config.json");
let config = load_runx_config_file(&config_path)?;
match plan.action {
ConfigAction::List => Ok(ConfigResult::List {
action: ConfigAction::List,
values: mask_runx_config_file(&config),
}),
ConfigAction::Get => {
let key = required_key(plan)?;
let parsed_key = parse_config_key(key)?;
Ok(ConfigResult::Get {
action: ConfigAction::Get,
key: key.to_owned(),
value: lookup_runx_config_value(&config, parsed_key),
})
}
ConfigAction::Set => {
let key = required_key(plan)?;
let value = plan.value.as_deref().ok_or_else(|| {
ConfigCliError::InvalidArgs("config value is required.".to_owned())
})?;
let parsed_key = parse_config_key(key)?;
let next = update_runx_config_value(config, parsed_key, value, &config_dir)?;
write_runx_config_file(&config_path, &next)?;
Ok(ConfigResult::Set {
action: ConfigAction::Set,
key: key.to_owned(),
value: lookup_runx_config_value(&mask_runx_config_file(&next), parsed_key),
})
}
}
}
fn required_key(plan: &ConfigPlan) -> Result<&str, ConfigCliError> {
plan.key
.as_deref()
.ok_or_else(|| ConfigCliError::InvalidArgs("config key is required.".to_owned()))
}
fn render_config_result(result: &ConfigResult) -> String {
match result {
ConfigResult::List { values, .. } => {
let entries = flatten_config(values);
if entries.is_empty() {
return "\n No config values set.\n\n".to_owned();
}
let rows = entries
.iter()
.map(|(key, value)| (*key, Some(*value)))
.collect::<Vec<_>>();
render_key_value("config", "success", &rows)
}
ConfigResult::Get { key, value, .. } | ConfigResult::Set { key, value, .. } => {
render_key_value("config", "success", &[(key.as_str(), value.as_deref())])
}
}
}
fn flatten_config(config: &RunxConfigFile) -> Vec<(&'static str, &str)> {
let mut rows = Vec::new();
if let Some(agent) = config.agent.as_ref() {
if let Some(provider) = agent.provider.as_deref() {
rows.push(("agent.provider", provider));
}
if let Some(model) = agent.model.as_deref() {
rows.push(("agent.model", model));
}
if let Some(api_key_ref) = agent.api_key_ref.as_deref() {
rows.push(("agent.api_key", api_key_ref));
}
}
if let Some(public) = config.public.as_ref()
&& let Some(api_token_ref) = public.api_token_ref.as_deref()
{
rows.push(("public.api_token", api_token_ref));
}
rows
}
fn render_key_value(title: &str, status: &str, rows: &[(&str, Option<&str>)]) -> String {
let visible = rows
.iter()
.filter(|(_label, value)| value.is_some_and(|value| !value.is_empty()))
.collect::<Vec<_>>();
let width = visible
.iter()
.map(|(label, _value)| label.len())
.max()
.unwrap_or(0);
let mut lines = vec![String::new(), format!(" ✓ {title} {status}")];
lines.extend(
visible
.into_iter()
.map(|(label, value)| format!(" {label:<width$} {}", value.unwrap_or_default())),
);
lines.push(String::new());
lines.join("\n")
}
#[derive(Serialize)]
struct ConfigJsonResult<'a> {
status: &'static str,
config: &'a ConfigResult,
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io;
use std::path::PathBuf;
use super::*;
#[test]
fn parses_config_set_with_multi_word_value() {
assert_eq!(
parse_config_plan(&[
"config".into(),
"set".into(),
"model".into(),
"gpt".into(),
"test".into(),
"-j".into(),
]),
Ok(ConfigPlan {
action: ConfigAction::Set,
key: Some("agent.model".to_owned()),
value: Some("gpt test".to_owned()),
json: true,
})
);
}
#[test]
fn config_set_get_list_masks_api_key() -> Result<(), ConfigTestError> {
let temp = tempfile_dir()?;
let runx_home = temp.join(".runx");
let env = BTreeMap::from([(
"RUNX_HOME".to_owned(),
runx_home.to_string_lossy().to_string(),
)]);
let set_provider = ConfigPlan {
action: ConfigAction::Set,
key: Some("agent.provider".to_owned()),
value: Some("openai".to_owned()),
json: true,
};
let set_key = ConfigPlan {
action: ConfigAction::Set,
key: Some("agent.api_key".to_owned()),
value: Some("sk-secret-test".to_owned()),
json: true,
};
let set_public_token = ConfigPlan {
action: ConfigAction::Set,
key: Some("public.api_token".to_owned()),
value: Some("rxk-secret-test".to_owned()),
json: true,
};
run_config_command(&set_provider, &env, &temp)?;
let key_output = run_config_command(&set_key, &env, &temp)?;
let public_output = run_config_command(&set_public_token, &env, &temp)?;
assert!(key_output.contains("\"value\": \"[encrypted]\""));
assert!(public_output.contains("\"value\": \"[encrypted]\""));
assert!(!key_output.contains("sk-secret-test"));
assert!(!public_output.contains("rxk-secret-test"));
let get_output = run_config_command(
&ConfigPlan {
action: ConfigAction::Get,
key: Some("agent.api_key".to_owned()),
value: None,
json: false,
},
&env,
&temp,
)?;
assert!(get_output.contains("agent.api_key"));
assert!(get_output.contains("[encrypted]"));
assert!(!get_output.contains("sk-secret-test"));
let list_output = run_config_command(
&ConfigPlan {
action: ConfigAction::List,
key: None,
value: None,
json: false,
},
&env,
&temp,
)?;
assert!(list_output.contains("agent.provider"));
assert!(list_output.contains("openai"));
assert!(list_output.contains("agent.api_key"));
assert!(list_output.contains("public.api_token"));
assert!(list_output.contains("[encrypted]"));
assert!(!list_output.contains("sk-secret-test"));
assert!(!list_output.contains("rxk-secret-test"));
let config_contents = fs::read_to_string(runx_home.join("config.json"))?;
assert!(config_contents.contains("api_key_ref"));
assert!(config_contents.contains("api_token_ref"));
assert!(!config_contents.contains("sk-secret-test"));
assert!(!config_contents.contains("rxk-secret-test"));
fs::remove_dir_all(temp)?;
Ok(())
}
#[derive(Debug)]
enum ConfigTestError {
Io(io::Error),
Cli(ConfigCliError),
}
impl std::fmt::Display for ConfigTestError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(formatter, "{error}"),
Self::Cli(error) => write!(formatter, "{error}"),
}
}
}
impl std::error::Error for ConfigTestError {}
impl From<io::Error> for ConfigTestError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<ConfigCliError> for ConfigTestError {
fn from(error: ConfigCliError) -> Self {
Self::Cli(error)
}
}
fn tempfile_dir() -> Result<PathBuf, io::Error> {
let path = std::env::temp_dir().join(format!(
"runx-cli-config-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
fs::create_dir_all(&path)?;
Ok(path)
}
}