opencode_cloud_core/config/
mod.rs1pub 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
25pub 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
46pub 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
63pub 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 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 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 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 if let Some(obj) = parsed_value.as_object_mut() {
99 obj.remove("opencode_commit");
100 }
101
102 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
137pub 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 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 let json = serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
157
158 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 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 }