1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use directories::BaseDirs;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::workspace::expand_home_path;
10
11pub const CC_SWITCH_DB: &str = "cc-switch-db";
13
14const CONFIG_FILE_NAME: &str = "config.json";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ConfigEntry {
19 name: String,
20 value: PathBuf,
21}
22
23impl ConfigEntry {
24 #[must_use]
35 pub fn new(name: String, value: PathBuf) -> Self {
36 Self { name, value }
37 }
38
39 #[must_use]
45 pub fn name(&self) -> &str {
46 &self.name
47 }
48
49 #[must_use]
55 pub fn value(&self) -> &Path {
56 &self.value
57 }
58}
59
60#[derive(Debug, Error)]
62pub enum ConfigError {
63 #[error("failed to resolve user home directory")]
65 MissingHomeDirectory,
66
67 #[error("unsupported config name '{name}'; supported config names: {supported}")]
69 UnsupportedConfigName {
70 name: String,
72 supported: &'static str,
74 },
75
76 #[error("configuration file error: {0}")]
78 Io(#[from] std::io::Error),
79
80 #[error("configuration JSON error: {0}")]
82 Json(#[from] serde_json::Error),
83
84 #[error("system clock is before the Unix epoch")]
86 InvalidSystemClock,
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
91pub struct UserConfig {
92 #[serde(rename = "cc-switch-db", skip_serializing_if = "Option::is_none")]
93 cc_switch_db: Option<PathBuf>,
94}
95
96impl UserConfig {
97 #[must_use]
103 pub fn cc_switch_db(&self) -> Option<&Path> {
104 self.cc_switch_db.as_deref()
105 }
106
107 pub fn set_value(&mut self, name: &str, value: PathBuf) -> Result<(), ConfigError> {
118 match parse_config_name(name)? {
119 ConfigName::CcSwitchDbRoute => {
120 self.cc_switch_db = Some(value);
121 Ok(())
122 }
123 }
124 }
125
126 pub fn get_value(&self, name: &str) -> Result<Option<ConfigEntry>, ConfigError> {
140 match parse_config_name(name)? {
141 ConfigName::CcSwitchDbRoute => Ok(self
142 .cc_switch_db
143 .as_ref()
144 .map(|value| ConfigEntry::new(CC_SWITCH_DB.to_owned(), value.clone()))),
145 }
146 }
147
148 #[must_use]
154 pub fn entries(&self) -> Vec<ConfigEntry> {
155 self.cc_switch_db
156 .as_ref()
157 .map(|value| ConfigEntry::new(CC_SWITCH_DB.to_owned(), value.clone()))
158 .into_iter()
159 .collect()
160 }
161}
162
163pub fn default_state_root() -> Result<PathBuf, ConfigError> {
173 Ok(home_dir()?.join(".codex-ws"))
174}
175
176pub fn default_config_dir() -> Result<PathBuf, ConfigError> {
186 Ok(default_state_root()?.join("config"))
187}
188
189pub fn default_config_file_path() -> Result<PathBuf, ConfigError> {
199 Ok(default_config_dir()?.join(CONFIG_FILE_NAME))
200}
201
202pub fn default_cc_switch_database_path() -> Result<PathBuf, ConfigError> {
212 let default_path = home_dir()?.join(".cc-switch").join("cc-switch.db");
213 #[cfg(windows)]
214 {
215 if !default_path.exists() {
216 if let Ok(home_env) = std::env::var("HOME") {
217 let trimmed = home_env.trim();
218 if !trimmed.is_empty() {
219 let legacy_path = PathBuf::from(trimmed)
220 .join(".cc-switch")
221 .join("cc-switch.db");
222 if legacy_path.exists() {
223 return Ok(legacy_path);
224 }
225 }
226 }
227 }
228 }
229 Ok(default_path)
230}
231
232pub fn load_default_user_config() -> Result<UserConfig, ConfigError> {
243 load_user_config(&default_config_file_path()?)
244}
245
246pub fn load_user_config(path: &Path) -> Result<UserConfig, ConfigError> {
260 if !path.exists() {
261 return Ok(UserConfig::default());
262 }
263
264 let content = fs::read_to_string(path)?;
265 Ok(serde_json::from_str(&content)?)
266}
267
268pub fn save_user_config(path: &Path, config: &UserConfig) -> Result<(), ConfigError> {
279 if let Some(parent) = path.parent() {
280 fs::create_dir_all(parent)?;
281 }
282
283 let content = serde_json::to_string_pretty(config)?;
284 atomic_write(path, content.as_bytes())?;
285 Ok(())
286}
287
288pub fn set_default_config_value(name: &str, value: PathBuf) -> Result<PathBuf, ConfigError> {
299 let path = default_config_file_path()?;
300 let mut config = load_user_config(&path)?;
301 config.set_value(name, expand_home_path(value))?;
302 save_user_config(&path, &config)?;
303 Ok(path)
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307enum ConfigName {
308 CcSwitchDbRoute,
309}
310
311fn parse_config_name(name: &str) -> Result<ConfigName, ConfigError> {
312 match name {
313 CC_SWITCH_DB => Ok(ConfigName::CcSwitchDbRoute),
314 _ => Err(ConfigError::UnsupportedConfigName {
315 name: name.to_owned(),
316 supported: CC_SWITCH_DB,
317 }),
318 }
319}
320
321fn home_dir() -> Result<PathBuf, ConfigError> {
322 BaseDirs::new()
323 .map(|dirs| dirs.home_dir().to_path_buf())
324 .ok_or(ConfigError::MissingHomeDirectory)
325}
326
327fn atomic_write(path: &Path, content: &[u8]) -> Result<(), ConfigError> {
328 let parent = path.parent().ok_or_else(|| {
329 std::io::Error::new(
330 std::io::ErrorKind::InvalidInput,
331 format!("invalid configuration path '{}'", path.display()),
332 )
333 })?;
334 let file_name = path.file_name().ok_or_else(|| {
335 std::io::Error::new(
336 std::io::ErrorKind::InvalidInput,
337 format!("invalid configuration file name '{}'", path.display()),
338 )
339 })?;
340 let timestamp = SystemTime::now()
341 .duration_since(UNIX_EPOCH)
342 .map_err(|_| ConfigError::InvalidSystemClock)?
343 .as_nanos();
344 let temporary_path = parent.join(format!(
345 "{}.tmp.{}-{timestamp}",
346 file_name.to_string_lossy(),
347 std::process::id()
348 ));
349
350 fs::write(&temporary_path, content)?;
351 #[cfg(windows)]
352 {
353 if path.exists() {
354 fs::remove_file(path)?;
355 }
356 }
357 fs::rename(temporary_path, path)?;
358 Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363 use std::sync::atomic::{AtomicUsize, Ordering};
364 use std::time::{SystemTime, UNIX_EPOCH};
365
366 use super::*;
367
368 static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
369
370 #[test]
371 fn user_config_sets_and_gets_cc_switch_database_path() {
372 let mut config = UserConfig::default();
373
374 config
375 .set_value(CC_SWITCH_DB, PathBuf::from("/tmp/cc-switch.db"))
376 .expect("supported config should set");
377
378 let entry = config
379 .get_value(CC_SWITCH_DB)
380 .expect("supported config should get")
381 .expect("entry should be present");
382 assert_eq!(entry.name(), CC_SWITCH_DB);
383 assert_eq!(entry.value(), Path::new("/tmp/cc-switch.db"));
384 }
385
386 #[test]
387 fn user_config_rejects_unsupported_config_names() {
388 let mut config = UserConfig::default();
389 let error = config
390 .set_value("unknown", PathBuf::from("value"))
391 .expect_err("unsupported config should fail")
392 .to_string();
393
394 assert_eq!(
395 error,
396 "unsupported config name 'unknown'; supported config names: cc-switch-db"
397 );
398 }
399
400 #[test]
401 fn load_user_config_returns_default_when_file_is_missing() {
402 let temp_dir = TestTempDir::create();
403 let config = load_user_config(&temp_dir.path().join("missing.json"))
404 .expect("missing config should load as default");
405
406 assert_eq!(config, UserConfig::default());
407 }
408
409 #[test]
410 fn save_user_config_creates_parent_directories() {
411 let temp_dir = TestTempDir::create();
412 let config_path = temp_dir.path().join("nested").join("config.json");
413 let mut config = UserConfig::default();
414 config
415 .set_value(CC_SWITCH_DB, PathBuf::from("/tmp/cc-switch.db"))
416 .expect("supported config should set");
417
418 save_user_config(&config_path, &config).expect("config should save");
419 let loaded = load_user_config(&config_path).expect("config should load");
420
421 assert_eq!(loaded, config);
422 }
423
424 #[derive(Debug)]
425 struct TestTempDir {
426 path: PathBuf,
427 }
428
429 impl TestTempDir {
430 fn create() -> Self {
431 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
432 let timestamp = SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .expect("system clock should be after Unix epoch")
435 .as_nanos();
436 let path = std::env::temp_dir().join(format!(
437 "codex-ws-config-test-{}-{timestamp}-{counter}",
438 std::process::id()
439 ));
440 fs::create_dir(&path).expect("temporary test directory should be created");
441 Self { path }
442 }
443
444 fn path(&self) -> &Path {
445 &self.path
446 }
447 }
448
449 impl Drop for TestTempDir {
450 fn drop(&mut self) {
451 let _ = fs::remove_dir_all(&self.path);
452 }
453 }
454}