Skip to main content

codex_ws/
config.rs

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
11/// Configuration key that stores the cc-switch SQLite database path.
12pub const CC_SWITCH_DB: &str = "cc-switch-db";
13
14const CONFIG_FILE_NAME: &str = "config.json";
15
16/// A persisted user configuration entry.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ConfigEntry {
19    name: String,
20    value: PathBuf,
21}
22
23impl ConfigEntry {
24    /// Create a configuration entry.
25    ///
26    /// # Arguments
27    ///
28    /// * `name` - Supported configuration key.
29    /// * `value` - Persisted configuration value.
30    ///
31    /// # Returns
32    ///
33    /// A configuration entry with owned fields.
34    #[must_use]
35    pub fn new(name: String, value: PathBuf) -> Self {
36        Self { name, value }
37    }
38
39    /// Return the configuration key.
40    ///
41    /// # Returns
42    ///
43    /// The configuration key as a borrowed string slice.
44    #[must_use]
45    pub fn name(&self) -> &str {
46        &self.name
47    }
48
49    /// Return the configuration value.
50    ///
51    /// # Returns
52    ///
53    /// The configuration value as a borrowed path.
54    #[must_use]
55    pub fn value(&self) -> &Path {
56        &self.value
57    }
58}
59
60/// Errors returned while reading or writing user configuration.
61#[derive(Debug, Error)]
62pub enum ConfigError {
63    /// The operating system did not expose a usable home directory.
64    #[error("failed to resolve user home directory")]
65    MissingHomeDirectory,
66
67    /// The requested configuration key is not supported.
68    #[error("unsupported config name '{name}'; supported config names: {supported}")]
69    UnsupportedConfigName {
70        /// User-provided configuration key.
71        name: String,
72        /// Comma-separated supported configuration keys.
73        supported: &'static str,
74    },
75
76    /// The configuration file could not be read or written.
77    #[error("configuration file error: {0}")]
78    Io(#[from] std::io::Error),
79
80    /// The configuration file contained invalid JSON.
81    #[error("configuration JSON error: {0}")]
82    Json(#[from] serde_json::Error),
83
84    /// The system clock cannot be used to create a unique temporary file name.
85    #[error("system clock is before the Unix epoch")]
86    InvalidSystemClock,
87}
88
89/// User-level codex-ws configuration.
90#[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    /// Return the configured cc-switch database path.
98    ///
99    /// # Returns
100    ///
101    /// The configured path, if the user set `cc-switch-db`.
102    #[must_use]
103    pub fn cc_switch_db(&self) -> Option<&Path> {
104        self.cc_switch_db.as_deref()
105    }
106
107    /// Set a supported configuration value.
108    ///
109    /// # Arguments
110    ///
111    /// * `name` - Supported configuration key.
112    /// * `value` - Value to store for the key.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ConfigError::UnsupportedConfigName`] when `name` is not supported.
117    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    /// Return one supported configuration value.
127    ///
128    /// # Arguments
129    ///
130    /// * `name` - Supported configuration key.
131    ///
132    /// # Returns
133    ///
134    /// The configuration entry when the key is set.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ConfigError::UnsupportedConfigName`] when `name` is not supported.
139    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    /// Return all configured values.
149    ///
150    /// # Returns
151    ///
152    /// Configured entries in stable key order.
153    #[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
163/// Return the default codex-ws state root.
164///
165/// # Returns
166///
167/// The `.codex-ws` directory under the real user home directory.
168///
169/// # Errors
170///
171/// Returns [`ConfigError::MissingHomeDirectory`] when the OS does not expose a home directory.
172pub fn default_state_root() -> Result<PathBuf, ConfigError> {
173    Ok(home_dir()?.join(".codex-ws"))
174}
175
176/// Return the default user configuration directory.
177///
178/// # Returns
179///
180/// The `config` directory under the codex-ws state root.
181///
182/// # Errors
183///
184/// Returns [`ConfigError::MissingHomeDirectory`] when the OS does not expose a home directory.
185pub fn default_config_dir() -> Result<PathBuf, ConfigError> {
186    Ok(default_state_root()?.join("config"))
187}
188
189/// Return the default user configuration file path.
190///
191/// # Returns
192///
193/// The `config.json` path under the codex-ws config directory.
194///
195/// # Errors
196///
197/// Returns [`ConfigError::MissingHomeDirectory`] when the OS does not expose a home directory.
198pub fn default_config_file_path() -> Result<PathBuf, ConfigError> {
199    Ok(default_config_dir()?.join(CONFIG_FILE_NAME))
200}
201
202/// Return the fallback cc-switch database path.
203///
204/// # Returns
205///
206/// The legacy cc-switch database path under the user's home directory.
207///
208/// # Errors
209///
210/// Returns [`ConfigError::MissingHomeDirectory`] when the home directory cannot be resolved.
211pub 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
232/// Load user configuration from the default codex-ws config file.
233///
234/// # Returns
235///
236/// Parsed user configuration, or an empty configuration when the file does not exist.
237///
238/// # Errors
239///
240/// Returns an error when the config path cannot be resolved, the file cannot be read, or its JSON
241/// is invalid.
242pub fn load_default_user_config() -> Result<UserConfig, ConfigError> {
243    load_user_config(&default_config_file_path()?)
244}
245
246/// Load user configuration from a file path.
247///
248/// # Arguments
249///
250/// * `path` - Configuration JSON path.
251///
252/// # Returns
253///
254/// Parsed user configuration, or an empty configuration when the file does not exist.
255///
256/// # Errors
257///
258/// Returns an error when the file cannot be read or its JSON is invalid.
259pub 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
268/// Save user configuration to a file path.
269///
270/// # Arguments
271///
272/// * `path` - Configuration JSON path.
273/// * `config` - User configuration to persist.
274///
275/// # Errors
276///
277/// Returns an error when the parent directory cannot be created or the file cannot be written.
278pub 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
288/// Set a configuration value in the default codex-ws config file.
289///
290/// # Arguments
291///
292/// * `name` - Supported configuration key.
293/// * `value` - Value to persist.
294///
295/// # Errors
296///
297/// Returns an error when the key is unsupported or the config file cannot be updated.
298pub 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}