use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::workspace::expand_home_path;
pub const CC_SWITCH_DB: &str = "cc-switch-db";
const CONFIG_FILE_NAME: &str = "config.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEntry {
name: String,
value: PathBuf,
}
impl ConfigEntry {
#[must_use]
pub fn new(name: String, value: PathBuf) -> Self {
Self { name, value }
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn value(&self) -> &Path {
&self.value
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to resolve user home directory")]
MissingHomeDirectory,
#[error("unsupported config name '{name}'; supported config names: {supported}")]
UnsupportedConfigName {
name: String,
supported: &'static str,
},
#[error("configuration file error: {0}")]
Io(#[from] std::io::Error),
#[error("configuration JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("system clock is before the Unix epoch")]
InvalidSystemClock,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct UserConfig {
#[serde(rename = "cc-switch-db", skip_serializing_if = "Option::is_none")]
cc_switch_db: Option<PathBuf>,
}
impl UserConfig {
#[must_use]
pub fn cc_switch_db(&self) -> Option<&Path> {
self.cc_switch_db.as_deref()
}
pub fn set_value(&mut self, name: &str, value: PathBuf) -> Result<(), ConfigError> {
match parse_config_name(name)? {
ConfigName::CcSwitchDbRoute => {
self.cc_switch_db = Some(value);
Ok(())
}
}
}
pub fn get_value(&self, name: &str) -> Result<Option<ConfigEntry>, ConfigError> {
match parse_config_name(name)? {
ConfigName::CcSwitchDbRoute => Ok(self
.cc_switch_db
.as_ref()
.map(|value| ConfigEntry::new(CC_SWITCH_DB.to_owned(), value.clone()))),
}
}
#[must_use]
pub fn entries(&self) -> Vec<ConfigEntry> {
self.cc_switch_db
.as_ref()
.map(|value| ConfigEntry::new(CC_SWITCH_DB.to_owned(), value.clone()))
.into_iter()
.collect()
}
}
pub fn default_state_root() -> Result<PathBuf, ConfigError> {
Ok(home_dir()?.join(".codex-ws"))
}
pub fn default_config_dir() -> Result<PathBuf, ConfigError> {
Ok(default_state_root()?.join("config"))
}
pub fn default_config_file_path() -> Result<PathBuf, ConfigError> {
Ok(default_config_dir()?.join(CONFIG_FILE_NAME))
}
pub fn default_cc_switch_database_path() -> Result<PathBuf, ConfigError> {
let default_path = home_dir()?.join(".cc-switch").join("cc-switch.db");
#[cfg(windows)]
{
if !default_path.exists() {
if let Ok(home_env) = std::env::var("HOME") {
let trimmed = home_env.trim();
if !trimmed.is_empty() {
let legacy_path = PathBuf::from(trimmed)
.join(".cc-switch")
.join("cc-switch.db");
if legacy_path.exists() {
return Ok(legacy_path);
}
}
}
}
}
Ok(default_path)
}
pub fn load_default_user_config() -> Result<UserConfig, ConfigError> {
load_user_config(&default_config_file_path()?)
}
pub fn load_user_config(path: &Path) -> Result<UserConfig, ConfigError> {
if !path.exists() {
return Ok(UserConfig::default());
}
let content = fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn save_user_config(path: &Path, config: &UserConfig) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(config)?;
atomic_write(path, content.as_bytes())?;
Ok(())
}
pub fn set_default_config_value(name: &str, value: PathBuf) -> Result<PathBuf, ConfigError> {
let path = default_config_file_path()?;
let mut config = load_user_config(&path)?;
config.set_value(name, expand_home_path(value))?;
save_user_config(&path, &config)?;
Ok(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigName {
CcSwitchDbRoute,
}
fn parse_config_name(name: &str) -> Result<ConfigName, ConfigError> {
match name {
CC_SWITCH_DB => Ok(ConfigName::CcSwitchDbRoute),
_ => Err(ConfigError::UnsupportedConfigName {
name: name.to_owned(),
supported: CC_SWITCH_DB,
}),
}
}
fn home_dir() -> Result<PathBuf, ConfigError> {
BaseDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.ok_or(ConfigError::MissingHomeDirectory)
}
fn atomic_write(path: &Path, content: &[u8]) -> Result<(), ConfigError> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid configuration path '{}'", path.display()),
)
})?;
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid configuration file name '{}'", path.display()),
)
})?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ConfigError::InvalidSystemClock)?
.as_nanos();
let temporary_path = parent.join(format!(
"{}.tmp.{}-{timestamp}",
file_name.to_string_lossy(),
std::process::id()
));
fs::write(&temporary_path, content)?;
#[cfg(windows)]
{
if path.exists() {
fs::remove_file(path)?;
}
}
fs::rename(temporary_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[test]
fn user_config_sets_and_gets_cc_switch_database_path() {
let mut config = UserConfig::default();
config
.set_value(CC_SWITCH_DB, PathBuf::from("/tmp/cc-switch.db"))
.expect("supported config should set");
let entry = config
.get_value(CC_SWITCH_DB)
.expect("supported config should get")
.expect("entry should be present");
assert_eq!(entry.name(), CC_SWITCH_DB);
assert_eq!(entry.value(), Path::new("/tmp/cc-switch.db"));
}
#[test]
fn user_config_rejects_unsupported_config_names() {
let mut config = UserConfig::default();
let error = config
.set_value("unknown", PathBuf::from("value"))
.expect_err("unsupported config should fail")
.to_string();
assert_eq!(
error,
"unsupported config name 'unknown'; supported config names: cc-switch-db"
);
}
#[test]
fn load_user_config_returns_default_when_file_is_missing() {
let temp_dir = TestTempDir::create();
let config = load_user_config(&temp_dir.path().join("missing.json"))
.expect("missing config should load as default");
assert_eq!(config, UserConfig::default());
}
#[test]
fn save_user_config_creates_parent_directories() {
let temp_dir = TestTempDir::create();
let config_path = temp_dir.path().join("nested").join("config.json");
let mut config = UserConfig::default();
config
.set_value(CC_SWITCH_DB, PathBuf::from("/tmp/cc-switch.db"))
.expect("supported config should set");
save_user_config(&config_path, &config).expect("config should save");
let loaded = load_user_config(&config_path).expect("config should load");
assert_eq!(loaded, config);
}
#[derive(Debug)]
struct TestTempDir {
path: PathBuf,
}
impl TestTempDir {
fn create() -> Self {
let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"codex-ws-config-test-{}-{timestamp}-{counter}",
std::process::id()
));
fs::create_dir(&path).expect("temporary test directory should be created");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestTempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
}