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
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
24pub 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
45pub 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
62pub 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 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 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 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 if let Some(obj) = parsed_value.as_object_mut() {
98 obj.remove("opencode_commit");
99 }
100
101 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
113pub 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 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 let json = serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
133
134 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 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 }