opencode_cloud_core/config/
mod.rs

1//! Configuration management for opencode-cloud
2//!
3//! Handles loading, saving, and validating the JSONC configuration file.
4//! Creates default config if missing, validates against schema.
5
6pub mod paths;
7pub mod schema;
8
9use std::fs::{self, File};
10use std::io::{Read, Write};
11use std::path::PathBuf;
12
13use anyhow::{Context, Result};
14use jsonc_parser::parse_to_serde_value;
15
16pub use paths::{get_config_dir, get_config_path, get_data_dir, get_pid_path};
17pub use schema::Config;
18
19/// Ensure the config directory exists
20///
21/// Creates `~/.config/opencode-cloud/` if it doesn't exist.
22/// Returns the path to the config directory.
23pub fn ensure_config_dir() -> Result<PathBuf> {
24    let config_dir =
25        get_config_dir().ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
26
27    if !config_dir.exists() {
28        fs::create_dir_all(&config_dir).with_context(|| {
29            format!(
30                "Failed to create config directory: {}",
31                config_dir.display()
32            )
33        })?;
34        tracing::info!("Created config directory: {}", config_dir.display());
35    }
36
37    Ok(config_dir)
38}
39
40/// Ensure the data directory exists
41///
42/// Creates `~/.local/share/opencode-cloud/` if it doesn't exist.
43/// Returns the path to the data directory.
44pub fn ensure_data_dir() -> Result<PathBuf> {
45    let data_dir =
46        get_data_dir().ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
47
48    if !data_dir.exists() {
49        fs::create_dir_all(&data_dir)
50            .with_context(|| format!("Failed to create data directory: {}", data_dir.display()))?;
51        tracing::info!("Created data directory: {}", data_dir.display());
52    }
53
54    Ok(data_dir)
55}
56
57/// Load configuration from the config file
58///
59/// If the config file doesn't exist, creates a new one with default values.
60/// Supports JSONC (JSON with comments).
61/// Rejects unknown fields for strict validation.
62pub fn load_config() -> Result<Config> {
63    let config_path =
64        get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
65
66    if !config_path.exists() {
67        // Create default config
68        tracing::info!(
69            "Config file not found, creating default at: {}",
70            config_path.display()
71        );
72        let config = Config::default();
73        save_config(&config)?;
74        return Ok(config);
75    }
76
77    // Read the file
78    let mut file = File::open(&config_path)
79        .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
80
81    let mut contents = String::new();
82    file.read_to_string(&mut contents)
83        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
84
85    // Parse JSONC (JSON with comments)
86    let parsed_value = parse_to_serde_value(&contents, &Default::default())
87        .map_err(|e| anyhow::anyhow!("Invalid JSONC in config file: {}", e))?
88        .ok_or_else(|| anyhow::anyhow!("Config file is empty"))?;
89
90    // Deserialize into Config struct (deny_unknown_fields will reject unknown keys)
91    let config: Config = serde_json::from_value(parsed_value).with_context(|| {
92        format!(
93            "Invalid configuration in {}. Check for unknown fields or invalid values.",
94            config_path.display()
95        )
96    })?;
97
98    Ok(config)
99}
100
101/// Save configuration to the config file
102///
103/// Creates a backup of the existing config (config.json.bak) before overwriting.
104/// Ensures the config directory exists.
105pub fn save_config(config: &Config) -> Result<()> {
106    ensure_config_dir()?;
107
108    let config_path =
109        get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
110
111    // Create backup if file exists
112    if config_path.exists() {
113        let backup_path = config_path.with_extension("json.bak");
114        fs::copy(&config_path, &backup_path)
115            .with_context(|| format!("Failed to create backup at: {}", backup_path.display()))?;
116        tracing::debug!("Created config backup: {}", backup_path.display());
117    }
118
119    // Serialize with pretty formatting
120    let json = serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
121
122    // Write to file
123    let mut file = File::create(&config_path)
124        .with_context(|| format!("Failed to create config file: {}", config_path.display()))?;
125
126    file.write_all(json.as_bytes())
127        .with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
128
129    tracing::debug!("Saved config to: {}", config_path.display());
130
131    Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_path_resolution_returns_values() {
140        // Verify path functions return Some on supported platforms
141        assert!(get_config_dir().is_some());
142        assert!(get_data_dir().is_some());
143        assert!(get_config_path().is_some());
144        assert!(get_pid_path().is_some());
145    }
146
147    #[test]
148    fn test_paths_end_with_expected_names() {
149        let config_dir = get_config_dir().unwrap();
150        assert!(config_dir.ends_with("opencode-cloud"));
151
152        let data_dir = get_data_dir().unwrap();
153        assert!(data_dir.ends_with("opencode-cloud"));
154
155        let config_path = get_config_path().unwrap();
156        assert!(config_path.ends_with("config.json"));
157
158        let pid_path = get_pid_path().unwrap();
159        assert!(pid_path.ends_with("opencode-cloud.pid"));
160    }
161
162    // Note: Integration tests for load_config/save_config that modify the real
163    // filesystem are run via CLI commands rather than unit tests to avoid
164    // test isolation issues with environment variable manipulation in Rust 2024.
165}