use std::sync::Arc;
use clap::Subcommand;
use crate::settings::Settings;
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigCommand {
Init {
#[arg(short, long)]
output: Option<std::path::PathBuf>,
#[arg(long)]
force: bool,
},
List {
#[arg(short, long)]
filter: Option<String>,
},
Get {
path: String,
},
Set {
path: String,
value: String,
},
Reset {
path: String,
},
Path,
}
pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
let db: Option<Arc<dyn crate::db::Database>> = match connect_db().await {
Ok(d) => Some(d),
Err(e) => {
eprintln!(
"Warning: Could not connect to database ({}), using disk fallback",
e
);
None
}
};
let db_ref = db.as_deref();
match cmd {
ConfigCommand::Init { output, force } => init_toml(db_ref, output, force).await,
ConfigCommand::List { filter } => list_settings(db_ref, filter).await,
ConfigCommand::Get { path } => get_setting(db_ref, &path).await,
ConfigCommand::Set { path, value } => set_setting(db_ref, &path, &value).await,
ConfigCommand::Reset { path } => reset_setting(db_ref, &path).await,
ConfigCommand::Path => show_path(db_ref.is_some()),
}
}
async fn connect_db() -> anyhow::Result<Arc<dyn crate::db::Database>> {
let config = crate::config::Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))
}
const DEFAULT_USER_ID: &str = "default";
async fn load_settings(store: Option<&dyn crate::db::Database>) -> Settings {
if let Some(store) = store {
match store.get_all_settings(DEFAULT_USER_ID).await {
Ok(map) if !map.is_empty() => return Settings::from_db_map(&map),
_ => {}
}
}
Settings::default()
}
async fn list_settings(
store: Option<&dyn crate::db::Database>,
filter: Option<String>,
) -> anyhow::Result<()> {
let settings = load_settings(store).await;
let all = settings.list();
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let source = if store.is_some() { "database" } else { "disk" };
println!("Settings (source: {}):", source);
println!();
for (key, value) in all {
if let Some(ref f) = filter
&& !key.starts_with(f)
{
continue;
}
let display_value = if value.len() > 60 {
let end = crate::util::floor_char_boundary(&value, 57);
format!("{}...", &value[..end])
} else {
value
};
println!(" {:width$} {}", key, display_value, width = max_key_len);
}
Ok(())
}
async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
let settings = load_settings(store).await;
match settings.get(path) {
Some(value) => {
println!("{}", value);
Ok(())
}
None => {
anyhow::bail!("Setting not found: {}", path);
}
}
}
async fn set_setting(
store: Option<&dyn crate::db::Database>,
path: &str,
value: &str,
) -> anyhow::Result<()> {
let mut settings = load_settings(store).await;
settings
.set(path, value)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let store = store.ok_or_else(|| {
anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.")
})?;
let json_value = match serde_json::from_str::<serde_json::Value>(value) {
Ok(v) => v,
Err(_) => serde_json::Value::String(value.to_string()),
};
store
.set_setting(DEFAULT_USER_ID, path, &json_value)
.await
.map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?;
println!("Set {} = {}", path, value);
Ok(())
}
async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
let default = Settings::default();
let default_value = default
.get(path)
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
let store = store.ok_or_else(|| {
anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.")
})?;
store
.delete_setting(DEFAULT_USER_ID, path)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
println!("Reset {} to default: {}", path, default_value);
Ok(())
}
async fn init_toml(
store: Option<&dyn crate::db::Database>,
output: Option<std::path::PathBuf>,
force: bool,
) -> anyhow::Result<()> {
let path = output.unwrap_or_else(Settings::default_toml_path);
if path.exists() && !force {
anyhow::bail!(
"Config file already exists: {}\nUse --force to overwrite.",
path.display()
);
}
let settings = load_settings(store).await;
settings
.save_toml(&path)
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Config file written to {}", path.display());
println!();
println!("Edit the file to customize settings.");
println!("Priority: env var > config.toml > database > defaults");
Ok(())
}
fn show_path(has_db: bool) -> anyhow::Result<()> {
if has_db {
println!("Settings stored in: database (settings table)");
} else {
println!("Settings stored in: PostgreSQL (not connected, using defaults)");
}
println!(
"Env config: {}",
crate::bootstrap::ironclaw_env_path().display()
);
let toml_path = Settings::default_toml_path();
let toml_status = if toml_path.exists() {
"found"
} else {
"not found (run `ironclaw config init` to create)"
};
println!(
"TOML config: {} ({})",
toml_path.display(),
toml_status
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_list_settings() {
let settings = Settings::default();
let list = settings.list();
assert!(!list.is_empty());
}
#[test]
fn test_get_set_reset() {
let _dir = tempdir().unwrap();
let mut settings = Settings::default();
settings.set("agent.name", "testbot").unwrap();
assert_eq!(settings.agent.name, "testbot");
settings.reset("agent.name").unwrap();
assert_eq!(settings.agent.name, "ironclaw");
}
#[tokio::test]
async fn init_toml_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
init_toml(None, Some(path.clone()), false).await.unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[agent]"));
}
#[tokio::test]
async fn init_toml_refuses_overwrite_without_force() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "existing").unwrap();
let result = init_toml(None, Some(path.clone()), false).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[tokio::test]
async fn init_toml_force_overwrites() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "old content").unwrap();
init_toml(None, Some(path.clone()), true).await.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[agent]"));
}
}