use crate::cli::args::{ConfigAction, ConfigArgs};
use crate::config::{Config, ConfigManager};
use crate::error::{MinoError, MinoResult};
use crate::ui::{self, UiContext};
use tokio::fs;
pub async fn execute(args: ConfigArgs, config: &Config) -> MinoResult<()> {
let manager = ConfigManager::new();
match args.action {
None | Some(ConfigAction::Show) => show_config(config),
Some(ConfigAction::Path) => show_path(&manager),
Some(ConfigAction::Init { force }) => init_config(&manager, force).await?,
Some(ConfigAction::Set { key, value, local }) => {
if local {
set_local_value(&key, &value).await?
} else {
set_value(&manager, config, &key, &value).await?
}
}
}
Ok(())
}
fn show_config(config: &Config) {
let toml =
toml::to_string_pretty(config).unwrap_or_else(|_| "Error serializing config".to_string());
println!("{}", toml);
}
fn show_path(manager: &ConfigManager) {
println!("{}", manager.path().display());
}
async fn init_config(manager: &ConfigManager, force: bool) -> MinoResult<()> {
let ctx = UiContext::detect();
let path = manager.path();
if path.exists() && !force {
ui::step_warn_hint(
&ctx,
&format!("Config already exists at {}", path.display()),
"Use --force to overwrite",
);
return Ok(());
}
let config = Config::default();
manager.save(&config).await?;
ui::step_ok_detail(
&ctx,
"Configuration initialized",
&path.display().to_string(),
);
Ok(())
}
async fn set_value(
manager: &ConfigManager,
config: &Config,
key: &str,
value: &str,
) -> MinoResult<()> {
let ctx = UiContext::detect();
let mut config = config.clone();
let parts: Vec<&str> = key.split('.').collect();
match parts.as_slice() {
["general", "verbose"] => config.general.verbose = parse_bool(value)?,
["general", "log_format"] => config.general.log_format = value.to_string(),
["general", "audit_log"] => config.general.audit_log = parse_bool(value)?,
["vm", "name"] => config.vm.name = value.to_string(),
["vm", "distro"] => config.vm.distro = value.to_string(),
["container", "image"] => config.container.image = value.to_string(),
["container", "network"] => config.container.network = value.to_string(),
["container", "workdir"] => config.container.workdir = value.to_string(),
["container", "network_allow"] => {
config.container.network_allow = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
["credentials", "aws", "enabled"] => config.credentials.aws.enabled = parse_bool(value)?,
["credentials", "aws", "session_duration_secs"] => {
config.credentials.aws.session_duration_secs = parse_u32(value)?
}
["credentials", "aws", "role_arn"] => {
config.credentials.aws.role_arn = Some(value.to_string())
}
["credentials", "aws", "profile"] => {
config.credentials.aws.profile = Some(value.to_string())
}
["credentials", "aws", "region"] => config.credentials.aws.region = Some(value.to_string()),
["credentials", "gcp", "enabled"] => config.credentials.gcp.enabled = parse_bool(value)?,
["credentials", "gcp", "project"] => {
config.credentials.gcp.project = Some(value.to_string())
}
["credentials", "azure", "enabled"] => {
config.credentials.azure.enabled = parse_bool(value)?
}
["credentials", "azure", "subscription"] => {
config.credentials.azure.subscription = Some(value.to_string())
}
["credentials", "azure", "tenant"] => {
config.credentials.azure.tenant = Some(value.to_string())
}
["session", "shell"] => config.session.shell = value.to_string(),
["session", "auto_cleanup_hours"] => config.session.auto_cleanup_hours = parse_u32(value)?,
_ => {
ui::step_error_detail(&ctx, "Unknown config key", key);
ui::remark(&ctx, "Valid keys:");
print_valid_keys();
return Ok(());
}
}
manager.save(&config).await?;
ui::step_ok(&ctx, &format!("Set {} = {}", key, value));
Ok(())
}
async fn set_local_value(key: &str, value: &str) -> MinoResult<()> {
let ctx = UiContext::detect();
let cwd = std::env::current_dir().map_err(|e| MinoError::io("getting current directory", e))?;
let local_path = cwd.join(".mino.toml");
validate_config_key(key)?;
let mut doc: toml_edit::DocumentMut = if local_path.exists() {
let content = fs::read_to_string(&local_path)
.await
.map_err(|e| MinoError::io(format!("reading {}", local_path.display()), e))?;
content
.parse()
.map_err(|e: toml_edit::TomlError| MinoError::ConfigInvalid {
path: local_path.clone(),
reason: e.to_string(),
})?
} else {
toml_edit::DocumentMut::new()
};
set_toml_edit_value(&mut doc, key, value)?;
fs::write(&local_path, doc.to_string())
.await
.map_err(|e| MinoError::io(format!("writing {}", local_path.display()), e))?;
ui::step_ok(
&ctx,
&format!("Set {} = {} in {}", key, value, local_path.display()),
);
Ok(())
}
fn validate_config_key(key: &str) -> MinoResult<()> {
let parts: Vec<&str> = key.split('.').collect();
match parts.as_slice() {
["general", "verbose" | "log_format" | "audit_log"]
| ["vm", "name" | "distro"]
| ["container", "image" | "network" | "workdir" | "network_allow"]
| ["credentials", "aws", "enabled" | "session_duration_secs" | "role_arn" | "profile" | "region"]
| ["credentials", "gcp", "enabled" | "project"]
| ["credentials", "azure", "enabled" | "subscription" | "tenant"]
| ["session", "shell" | "auto_cleanup_hours"] => Ok(()),
_ => Err(MinoError::User(format!("Unknown config key: {}", key))),
}
}
fn set_toml_edit_value(doc: &mut toml_edit::DocumentMut, key: &str, value: &str) -> MinoResult<()> {
let parts: Vec<&str> = key.split('.').collect();
let mut table = doc.as_table_mut();
for &part in &parts[..parts.len() - 1] {
if !table.contains_key(part) {
table.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
table = table[part]
.as_table_mut()
.ok_or_else(|| MinoError::User(format!("Expected table at key: {}", part)))?;
}
let leaf = *parts.last().unwrap();
let is_list_key =
key.ends_with("network_allow") || key.ends_with("layers") || key.ends_with("volumes");
if is_list_key {
let mut arr = toml_edit::Array::new();
for item in value.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
arr.push(item);
}
table.insert(leaf, toml_edit::value(arr));
} else if value == "true" || value == "false" {
table.insert(leaf, toml_edit::value(parse_bool(value)?));
} else if let Ok(n) = value.parse::<i64>() {
table.insert(leaf, toml_edit::value(n));
} else {
table.insert(leaf, toml_edit::value(value));
}
Ok(())
}
fn parse_bool(value: &str) -> MinoResult<bool> {
match value.to_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(MinoError::User(format!(
"Invalid boolean value: {}. Use true/false",
value
))),
}
}
fn parse_u32(value: &str) -> MinoResult<u32> {
value
.parse()
.map_err(|_| MinoError::User(format!("Invalid number: {}", value)))
}
fn print_valid_keys() {
let keys = [
"general.verbose",
"general.log_format",
"general.audit_log",
"vm.name",
"vm.distro",
"container.image",
"container.network",
"container.workdir",
"container.network_allow",
"credentials.aws.enabled",
"credentials.aws.session_duration_secs",
"credentials.aws.role_arn",
"credentials.aws.profile",
"credentials.aws.region",
"credentials.gcp.enabled",
"credentials.gcp.project",
"credentials.azure.enabled",
"credentials.azure.subscription",
"credentials.azure.tenant",
"session.shell",
"session.auto_cleanup_hours",
];
for key in keys {
eprintln!(" {}", key);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_toml_edit_value_preserves_comments() {
let input = "# Top-level comment\n[container]\n# Network comment\nnetwork = \"none\"\n";
let mut doc: toml_edit::DocumentMut = input.parse().unwrap();
set_toml_edit_value(&mut doc, "container.image", "fedora:43").unwrap();
let output = doc.to_string();
assert!(output.contains("# Top-level comment"), "top comment lost");
assert!(output.contains("# Network comment"), "inline comment lost");
assert!(output.contains("network = \"none\""), "existing value lost");
assert!(
output.contains("image = \"fedora:43\""),
"new value missing"
);
}
#[test]
fn set_toml_edit_value_creates_intermediate_tables() {
let mut doc = toml_edit::DocumentMut::new();
set_toml_edit_value(&mut doc, "credentials.aws.enabled", "true").unwrap();
let output = doc.to_string();
let parsed: toml::Value = output.parse().unwrap();
assert!(parsed["credentials"]["aws"]["enabled"].as_bool().unwrap());
}
#[test]
fn set_toml_edit_value_handles_list_keys() {
let mut doc = toml_edit::DocumentMut::new();
set_toml_edit_value(
&mut doc,
"container.network_allow",
"github.com:443,npmjs.org:443",
)
.unwrap();
let output = doc.to_string();
let parsed: toml::Value = output.parse().unwrap();
let arr = parsed["container"]["network_allow"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].as_str().unwrap(), "github.com:443");
assert_eq!(arr[1].as_str().unwrap(), "npmjs.org:443");
}
#[test]
fn set_toml_edit_value_handles_integer() {
let mut doc = toml_edit::DocumentMut::new();
set_toml_edit_value(&mut doc, "session.auto_cleanup_hours", "48").unwrap();
let output = doc.to_string();
let parsed: toml::Value = output.parse().unwrap();
assert_eq!(
parsed["session"]["auto_cleanup_hours"]
.as_integer()
.unwrap(),
48
);
}
#[test]
fn validate_config_key_rejects_unknown() {
assert!(validate_config_key("container.nonexistent").is_err());
}
#[test]
fn validate_config_key_accepts_known() {
assert!(validate_config_key("container.network").is_ok());
assert!(validate_config_key("credentials.aws.enabled").is_ok());
}
}