use crate::prompts::PromptMode;
use directories::{BaseDirs, ProjectDirs};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
fn resolve_command_path(command: &str) -> String {
if Path::new(command).is_absolute() {
return command.to_string();
}
Command::new("which")
.arg(command)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| command.to_string())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub claude_path: String,
pub max_iterations: u32,
pub recent_threads: u32,
pub notify_interval: u32,
pub plan_prompt: Option<PathBuf>,
pub build_prompt: Option<PathBuf>,
pub notify_shell: String,
pub tui_enabled: bool,
pub tui_recent_messages: usize,
pub eval_dir: PathBuf,
pub iteration_timeout: u64,
pub timeout_retries: u32,
pub prompt_mode: PromptMode,
}
impl Default for Config {
fn default() -> Self {
let eval_dir = BaseDirs::new()
.map(|d| d.home_dir().join(".rslph").join("evals"))
.unwrap_or_else(|| PathBuf::from(".rslph/evals"));
Self {
claude_path: "claude".to_string(),
max_iterations: 20,
recent_threads: 5,
notify_interval: 10,
plan_prompt: None,
build_prompt: None,
notify_shell: "/bin/sh".to_string(),
tui_enabled: true,
tui_recent_messages: 10,
eval_dir,
iteration_timeout: 600,
timeout_retries: 3,
prompt_mode: PromptMode::default(),
}
}
}
impl Config {
pub fn default_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "rslph").map(|dirs| dirs.config_dir().join("config.toml"))
}
pub fn load(config_path: Option<&Path>) -> color_eyre::Result<Self> {
let path = config_path.map(PathBuf::from).or_else(Self::default_path);
let mut figment = Figment::new().merge(Serialized::defaults(Config::default()));
if let Some(ref p) = path {
if p.exists() {
figment = figment.merge(Toml::file(p));
}
}
figment = figment.merge(Env::prefixed("RSLPH_").lowercase(true));
let mut config: Config = figment.extract()?;
config.claude_path = resolve_command_path(&config.claude_path);
Ok(config)
}
pub fn load_with_overrides(
config_path: Option<&Path>,
overrides: PartialConfig,
) -> color_eyre::Result<Self> {
let path = config_path.map(PathBuf::from).or_else(Self::default_path);
let mut figment = Figment::new().merge(Serialized::defaults(Config::default()));
if let Some(ref p) = path {
if p.exists() {
figment = figment.merge(Toml::file(p));
}
}
figment = figment.merge(Env::prefixed("RSLPH_").lowercase(true));
figment = figment.merge(Serialized::defaults(overrides));
let mut config: Config = figment.extract()?;
config.claude_path = resolve_command_path(&config.claude_path);
Ok(config)
}
}
#[derive(Debug, Default, Serialize)]
pub struct PartialConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_iterations: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_threads: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notify_interval: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_prompt: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_prompt: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notify_shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tui_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tui_recent_messages: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eval_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iteration_timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_retries: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_mode: Option<PromptMode>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.claude_path, "claude");
assert_eq!(config.max_iterations, 20);
assert_eq!(config.recent_threads, 5);
assert_eq!(config.notify_interval, 10);
assert!(config.plan_prompt.is_none());
assert!(config.build_prompt.is_none());
assert_eq!(config.notify_shell, "/bin/sh");
assert!(config.tui_enabled);
assert_eq!(config.tui_recent_messages, 10);
assert_eq!(config.iteration_timeout, 600);
assert_eq!(config.timeout_retries, 3);
assert_eq!(config.prompt_mode, PromptMode::Basic);
assert!(
config.eval_dir.ends_with(".rslph/evals"),
"eval_dir should end with .rslph/evals, got: {:?}",
config.eval_dir
);
}
#[test]
fn test_load_missing_file_uses_defaults() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::remove_var("RSLPH_MAX_ITERATIONS");
let config = Config::load(Some(Path::new("/nonexistent/config.toml")))
.expect("Should use defaults when file missing");
assert_eq!(config.max_iterations, 20);
}
#[test]
fn test_env_override() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("RSLPH_MAX_ITERATIONS", "50");
let config = Config::load(None).expect("Should load");
assert_eq!(config.max_iterations, 50);
std::env::remove_var("RSLPH_MAX_ITERATIONS");
}
#[test]
fn test_default_path_is_xdg_compliant() {
let path = Config::default_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(path.ends_with("rslph/config.toml"));
}
#[test]
fn test_cli_overrides_highest() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("RSLPH_MAX_ITERATIONS", "50");
let overrides = PartialConfig {
max_iterations: Some(100),
..Default::default()
};
let config = Config::load_with_overrides(None, overrides).expect("Should load");
assert_eq!(config.max_iterations, 100); std::env::remove_var("RSLPH_MAX_ITERATIONS");
}
#[test]
fn test_resolve_command_path_absolute_unchanged() {
let result = resolve_command_path("/bin/echo");
assert_eq!(result, "/bin/echo");
}
#[test]
fn test_resolve_command_path_relative_resolved() {
let result = resolve_command_path("echo");
assert!(
result.starts_with('/'),
"Expected absolute path, got: {}",
result
);
assert!(
result.ends_with("echo"),
"Expected path ending in echo, got: {}",
result
);
}
#[test]
fn test_resolve_command_path_nonexistent_fallback() {
let result = resolve_command_path("nonexistent_command_xyz_12345");
assert_eq!(result, "nonexistent_command_xyz_12345");
}
#[test]
fn test_default_prompt_mode() {
let config = Config::default();
assert_eq!(config.prompt_mode, PromptMode::Basic);
}
}