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