use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
pub const GLOBAL_CONFIG_FILE: &str = "config.toml";
pub const GLOBAL_CONFIG_DIR: &str = ".tt";
pub const DEFAULT_REDIS_PORT: u16 = 16379;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default = "default_cli")]
pub default_cli: String,
#[serde(default)]
pub conductor_cli: Option<String>,
#[serde(default)]
pub agent_clis: std::collections::HashMap<String, String>,
#[serde(default)]
pub redis: GlobalRedisConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalRedisConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub password: Option<String>,
#[serde(default = "default_true")]
pub use_central: bool,
}
impl Default for GlobalRedisConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
password: None,
use_central: true,
}
}
}
fn default_cli() -> String {
"claude".to_string()
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
DEFAULT_REDIS_PORT
}
fn default_true() -> bool {
true
}
pub(crate) fn normalize_builtin_cli_reference(value: &str) -> Option<&'static str> {
match value.trim() {
"claude --print --dangerously-skip-permissions" => Some("claude"),
"auggie --print" => Some("auggie"),
"codex --dangerously-bypass-approvals-and-sandbox" => Some("codex"),
"codex exec --dangerously-bypass-approvals-and-sandbox" => Some("codex"),
"codex exec --dangerously-bypass-approvals-and-sandbox -m gpt-5.4-mini -c model_reasoning_effort=\"medium\"" => {
Some("codex-mini")
}
"aider --yes --no-auto-commits --message" => Some("aider"),
_ => None,
}
}
fn normalize_cli_reference(value: &mut String) -> bool {
let Some(normalized) = normalize_builtin_cli_reference(value) else {
return false;
};
if value == normalized {
return false;
}
*value = normalized.to_string();
true
}
fn normalize_optional_cli_reference(value: &mut Option<String>) -> bool {
let Some(current) = value.as_deref() else {
return false;
};
let Some(normalized) = normalize_builtin_cli_reference(current) else {
return false;
};
if current == normalized {
return false;
}
*value = Some(normalized.to_string());
true
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
default_cli: default_cli(),
conductor_cli: None,
agent_clis: std::collections::HashMap::new(),
redis: GlobalRedisConfig::default(),
}
}
}
impl GlobalConfig {
pub fn config_dir() -> Result<PathBuf> {
dirs::home_dir()
.map(|h| h.join(GLOBAL_CONFIG_DIR))
.ok_or_else(|| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not find home directory",
))
})
}
pub fn config_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join(GLOBAL_CONFIG_FILE))
}
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&config_path)?;
let mut config: GlobalConfig = toml::from_str(&content)
.map_err(|e| Error::Io(std::io::Error::other(format!("Invalid config.toml: {}", e))))?;
config.normalize_cli_references();
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_dir = Self::config_dir()?;
let config_path = Self::config_path()?;
std::fs::create_dir_all(&config_dir)?;
let content = toml::to_string_pretty(self).map_err(|e| {
Error::Io(std::io::Error::other(format!(
"Failed to serialize config: {}",
e
)))
})?;
std::fs::write(&config_path, content)?;
Ok(())
}
pub fn redis_pid_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join("redis.pid"))
}
pub fn is_central_redis_running() -> bool {
let pid_path = match Self::redis_pid_path() {
Ok(p) => p,
Err(_) => return false,
};
if !pid_path.exists() {
return false;
}
if let Ok(pid_str) = std::fs::read_to_string(&pid_path)
&& let Ok(pid) = pid_str.trim().parse::<i32>()
{
unsafe {
return libc::kill(pid, 0) == 0;
}
}
false
}
pub fn load_or_init() -> Result<Self> {
let config_path = Self::config_path()?;
let mut config = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
toml::from_str(&content).map_err(|e| {
Error::Io(std::io::Error::other(format!("Invalid config.toml: {}", e)))
})?
} else {
Self::default()
};
let mut changed = config.normalize_cli_references();
if config.ensure_redis_password() {
changed = true;
}
if changed {
config.save()?;
}
Ok(config)
}
fn normalize_cli_references(&mut self) -> bool {
let mut changed = normalize_cli_reference(&mut self.default_cli);
changed |= normalize_optional_cli_reference(&mut self.conductor_cli);
changed
}
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
match key {
"default_cli" => {
self.default_cli = value.to_string();
Ok(())
}
"conductor_cli" => {
self.conductor_cli = Some(value.to_string());
Ok(())
}
"redis.host" => {
self.redis.host = value.to_string();
Ok(())
}
"redis.port" => {
self.redis.port = value.parse().map_err(|_| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid port number",
))
})?;
Ok(())
}
"redis.password" => {
self.redis.password = Some(value.to_string());
Ok(())
}
"redis.use_central" => {
self.redis.use_central = value.parse().map_err(|_| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid boolean value",
))
})?;
Ok(())
}
_ if key.starts_with("agent_clis.") => {
let cli_name = key.strip_prefix("agent_clis.").unwrap();
self.agent_clis
.insert(cli_name.to_string(), value.to_string());
Ok(())
}
_ => Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Unknown config key: {}", key),
))),
}
}
pub fn get(&self, key: &str) -> Option<String> {
match key {
"default_cli" => Some(self.default_cli.clone()),
"conductor_cli" => Some(
self.conductor_cli
.clone()
.unwrap_or_else(|| self.default_cli.clone()),
),
"redis.host" => Some(self.redis.host.clone()),
"redis.port" => Some(self.redis.port.to_string()),
"redis.password" => self.redis.password.clone(),
"redis.use_central" => Some(self.redis.use_central.to_string()),
_ if key.starts_with("agent_clis.") => {
let cli_name = key.strip_prefix("agent_clis.").unwrap();
self.agent_clis.get(cli_name).cloned()
}
_ => None,
}
}
#[must_use]
pub fn generate_password() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
let random_state = RandomState::new();
let mut hasher = random_state.build_hasher();
hasher.write_u128(timestamp);
hasher.write_u32(pid);
let hash1 = hasher.finish();
let random_state2 = RandomState::new();
let mut hasher2 = random_state2.build_hasher();
hasher2.write_u64(hash1);
let stack_var: u64 = 0;
hasher2.write_usize(&stack_var as *const _ as usize);
let hash2 = hasher2.finish();
format!("tt_{:016x}{:016x}", hash1, hash2)
}
pub fn ensure_redis_password(&mut self) -> bool {
if self.redis.password.is_none() {
self.redis.password = Some(Self::generate_password());
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::{GlobalConfig, GlobalRedisConfig, normalize_builtin_cli_reference};
#[test]
fn normalizes_legacy_builtin_cli_commands() {
assert_eq!(
normalize_builtin_cli_reference("codex --dangerously-bypass-approvals-and-sandbox"),
Some("codex")
);
assert_eq!(
normalize_builtin_cli_reference(
"codex exec --dangerously-bypass-approvals-and-sandbox"
),
Some("codex")
);
assert_eq!(
normalize_builtin_cli_reference(
"codex exec --dangerously-bypass-approvals-and-sandbox -m gpt-5.4-mini -c model_reasoning_effort=\"medium\""
),
Some("codex-mini")
);
}
#[test]
fn global_config_normalizes_legacy_cli_references() {
let mut config = GlobalConfig {
default_cli: "codex --dangerously-bypass-approvals-and-sandbox".to_string(),
conductor_cli: Some(
"codex exec --dangerously-bypass-approvals-and-sandbox".to_string(),
),
agent_clis: std::collections::HashMap::new(),
redis: GlobalRedisConfig::default(),
};
assert!(config.normalize_cli_references());
assert_eq!(config.default_cli, "codex");
assert_eq!(config.conductor_cli.as_deref(), Some("codex"));
}
}