use std::collections::HashMap;
use std::ffi::OsString;
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(
default,
rename = "launchCommand",
skip_serializing_if = "Vec::is_empty"
)]
pub launch_command: Vec<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl Config {
pub fn load() -> io::Result<Self> {
Self::load_from(&crate::paths::config_json())
}
pub fn load_from(path: &Path) -> io::Result<Self> {
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("config.json parse error at {}: {e}", path.display()),
)
}),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => Err(e),
}
}
pub fn save(&self) -> io::Result<()> {
self.save_to(&crate::paths::config_json())
}
pub fn save_to(&self, path: &Path) -> io::Result<()> {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, format!("{json}\n"))?;
std::fs::rename(&tmp, path)
}
pub fn launch_command(&self) -> Option<Vec<OsString>> {
if self.launch_command.is_empty() {
None
} else {
Some(self.launch_command.iter().map(OsString::from).collect())
}
}
}
pub fn resolve_launch_command() -> Vec<OsString> {
resolve_launch_command_with(
std::env::var_os("CLAUDE_SMART_CLAUDE_BIN"),
&Config::load().unwrap_or_default(),
)
}
pub fn resolve_launch_command_with(env_bin: Option<OsString>, cfg: &Config) -> Vec<OsString> {
if let Some(bin) = env_bin {
return vec![bin];
}
cfg.launch_command()
.unwrap_or_else(|| vec![OsString::from("claude")])
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn parse(json: &str) -> Config {
serde_json::from_str(json).expect("valid json")
}
#[test]
fn absent_file_is_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist.json");
let cfg = Config::load_from(&path).unwrap();
assert!(cfg.launch_command.is_empty());
assert_eq!(cfg.launch_command(), None);
}
#[test]
fn empty_object_is_default() {
let cfg = parse("{}");
assert!(cfg.launch_command.is_empty());
assert_eq!(cfg.launch_command(), None);
}
#[test]
fn single_token_roundtrip() {
let cfg = parse(r#"{"launchCommand": ["happy"]}"#);
assert_eq!(cfg.launch_command(), Some(vec![OsString::from("happy")]));
}
#[test]
fn multi_token_roundtrip() {
let cfg = parse(r#"{"launchCommand": ["npx", "happy"]}"#);
assert_eq!(
cfg.launch_command(),
Some(vec![OsString::from("npx"), OsString::from("happy")])
);
}
#[test]
fn save_to_then_load_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.json");
let cfg = Config {
launch_command: vec!["tp".to_owned()],
..Default::default()
};
cfg.save_to(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.ends_with('\n'), "config.json must end with newline");
assert!(!path.with_extension("json.tmp").exists());
let reloaded = Config::load_from(&path).unwrap();
assert_eq!(reloaded.launch_command(), Some(vec![OsString::from("tp")]));
}
#[test]
fn default_config_serializes_as_empty_object() {
let s = serde_json::to_string(&Config::default()).unwrap();
assert_eq!(s, "{}");
}
#[test]
fn unknown_future_keys_preserved() {
let cfg = parse(r#"{"launchCommand": ["happy"], "futureKey": 42}"#);
assert_eq!(cfg.extra.get("futureKey"), Some(&serde_json::json!(42)));
let s = serde_json::to_string(&cfg).unwrap();
assert!(s.contains("futureKey"), "unknown key dropped: {s}");
}
#[test]
fn invalid_json_is_err() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.json");
std::fs::write(&path, "not json").unwrap();
assert!(Config::load_from(&path).is_err());
}
#[test]
fn env_override_wins_over_config() {
let cfg = parse(r#"{"launchCommand": ["happy"]}"#);
let out = resolve_launch_command_with(Some(OsString::from("xtest")), &cfg);
assert_eq!(out, vec![OsString::from("xtest")]);
}
#[test]
fn config_used_when_no_env() {
let cfg = parse(r#"{"launchCommand": ["happy"]}"#);
let out = resolve_launch_command_with(None, &cfg);
assert_eq!(out, vec![OsString::from("happy")]);
}
#[test]
fn multi_token_config_passthrough() {
let cfg = parse(r#"{"launchCommand": ["npx", "happy"]}"#);
let out = resolve_launch_command_with(None, &cfg);
assert_eq!(out, vec![OsString::from("npx"), OsString::from("happy")]);
}
#[test]
fn default_claude_when_nothing_set() {
let out = resolve_launch_command_with(None, &Config::default());
assert_eq!(out, vec![OsString::from("claude")]);
}
}