Skip to main content

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;
8pub mod validation;
9
10use std::fs::{self, File};
11use std::io::{Read, Write};
12use std::path::PathBuf;
13
14use anyhow::{Context, Result};
15use jsonc_parser::parse_to_serde_value;
16
17pub use paths::{get_config_dir, get_config_path, get_data_dir, get_hosts_path, get_pid_path};
18pub use schema::{Config, default_mounts, validate_bind_address};
19pub use validation::{
20    ValidationError, ValidationWarning, display_validation_error, display_validation_warning,
21    validate_config,
22};
23
24/// Ensure the config directory exists
25///
26/// Creates `~/.config/opencode-cloud/` if it doesn't exist.
27/// Returns the path to the config directory.
28pub fn ensure_config_dir() -> Result<PathBuf> {
29    let config_dir =
30        get_config_dir().ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
31
32    if !config_dir.exists() {
33        fs::create_dir_all(&config_dir).with_context(|| {
34            format!(
35                "Failed to create config directory: {}",
36                config_dir.display()
37            )
38        })?;
39        tracing::info!("Created config directory: {}", config_dir.display());
40    }
41
42    Ok(config_dir)
43}
44
45/// Ensure the data directory exists
46///
47/// Creates `~/.local/share/opencode-cloud/` if it doesn't exist.
48/// Returns the path to the data directory.
49pub fn ensure_data_dir() -> Result<PathBuf> {
50    let data_dir =
51        get_data_dir().ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
52
53    if !data_dir.exists() {
54        fs::create_dir_all(&data_dir)
55            .with_context(|| format!("Failed to create data directory: {}", data_dir.display()))?;
56        tracing::info!("Created data directory: {}", data_dir.display());
57    }
58
59    Ok(data_dir)
60}
61
62/// Load configuration from the config file
63///
64/// If the config file doesn't exist, creates a new one with default values.
65/// Supports JSONC (JSON with comments).
66/// Rejects unknown fields for strict validation.
67pub fn load_config() -> Result<Config> {
68    let config_path =
69        get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
70
71    if !config_path.exists() {
72        // Create default config
73        tracing::info!(
74            "Config file not found, creating default at: {}",
75            config_path.display()
76        );
77        let config = Config::default();
78        ensure_default_mount_dirs(&config)?;
79        save_config(&config)?;
80        return Ok(config);
81    }
82
83    // Read the file
84    let mut file = File::open(&config_path)
85        .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
86
87    let mut contents = String::new();
88    file.read_to_string(&mut contents)
89        .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
90
91    // Parse JSONC (JSON with comments)
92    let mut parsed_value = parse_to_serde_value(&contents, &Default::default())
93        .map_err(|e| anyhow::anyhow!("Invalid JSONC in config file: {e}"))?
94        .ok_or_else(|| anyhow::anyhow!("Config file is empty"))?;
95
96    // Drop deprecated keys that were removed from the schema.
97    if let Some(obj) = parsed_value.as_object_mut() {
98        obj.remove("opencode_commit");
99    }
100
101    // Deserialize into Config struct (deny_unknown_fields will reject unknown keys)
102    let config: Config = serde_json::from_value(parsed_value).with_context(|| {
103        format!(
104            "Invalid configuration in {}. Check for unknown fields or invalid values.",
105            config_path.display()
106        )
107    })?;
108
109    ensure_default_mount_dirs(&config)?;
110    Ok(config)
111}
112
113/// Save configuration to the config file
114///
115/// Creates a backup of the existing config (config.json.bak) before overwriting.
116/// Ensures the config directory exists.
117pub fn save_config(config: &Config) -> Result<()> {
118    ensure_config_dir()?;
119
120    let config_path =
121        get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
122
123    // Create backup if file exists
124    if config_path.exists() {
125        let backup_path = config_path.with_extension("json.bak");
126        fs::copy(&config_path, &backup_path)
127            .with_context(|| format!("Failed to create backup at: {}", backup_path.display()))?;
128        tracing::debug!("Created config backup: {}", backup_path.display());
129    }
130
131    // Serialize with pretty formatting
132    let json = serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
133
134    // Write to file
135    let mut file = File::create(&config_path)
136        .with_context(|| format!("Failed to create config file: {}", config_path.display()))?;
137
138    file.write_all(json.as_bytes())
139        .with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
140
141    tracing::debug!("Saved config to: {}", config_path.display());
142
143    Ok(())
144}
145
146fn ensure_default_mount_dirs(config: &Config) -> Result<()> {
147    let defaults = default_mounts();
148    if defaults.is_empty() {
149        return Ok(());
150    }
151
152    for mount_str in &config.mounts {
153        if !defaults.contains(mount_str) {
154            continue;
155        }
156        let parsed = crate::docker::mount::ParsedMount::parse(mount_str)
157            .with_context(|| format!("Invalid default mount configured: {mount_str}"))?;
158        let path = parsed.host_path.as_path();
159        if path.exists() {
160            if !path.is_dir() {
161                return Err(anyhow::anyhow!(
162                    "Default mount path is not a directory: {}",
163                    path.display()
164                ));
165            }
166            continue;
167        }
168        fs::create_dir_all(path)
169            .with_context(|| format!("Failed to create mount directory: {}", path.display()))?;
170        tracing::info!("Created mount directory: {}", path.display());
171    }
172
173    Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_path_resolution_returns_values() {
182        // Verify path functions return Some on supported platforms
183        assert!(get_config_dir().is_some());
184        assert!(get_data_dir().is_some());
185        assert!(get_config_path().is_some());
186        assert!(get_pid_path().is_some());
187    }
188
189    #[test]
190    fn test_paths_end_with_expected_names() {
191        let config_dir = get_config_dir().unwrap();
192        assert!(config_dir.ends_with("opencode-cloud"));
193
194        let data_dir = get_data_dir().unwrap();
195        assert!(data_dir.ends_with("opencode-cloud"));
196
197        let config_path = get_config_path().unwrap();
198        assert!(config_path.ends_with("config.json"));
199
200        let pid_path = get_pid_path().unwrap();
201        assert!(pid_path.ends_with("opencode-cloud.pid"));
202    }
203
204    // Note: Integration tests for load_config/save_config that modify the real
205    // filesystem are run via CLI commands rather than unit tests to avoid
206    // test isolation issues with environment variable manipulation in Rust 2024.
207}