pub mod paths;
pub mod schema;
pub mod validation;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::PathBuf;
use anyhow::{Context, Result};
use jsonc_parser::parse_to_serde_value;
use crate::docker::mount::ParsedMount;
pub use paths::{get_config_dir, get_config_path, get_data_dir, get_hosts_path, get_pid_path};
pub use schema::{Config, default_mounts, validate_bind_address};
pub use validation::{
ValidationError, ValidationWarning, display_validation_error, display_validation_warning,
validate_config,
};
pub fn ensure_config_dir() -> Result<PathBuf> {
let config_dir =
get_config_dir().ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
if !config_dir.exists() {
fs::create_dir_all(&config_dir).with_context(|| {
format!(
"Failed to create config directory: {}",
config_dir.display()
)
})?;
tracing::info!("Created config directory: {}", config_dir.display());
}
Ok(config_dir)
}
pub fn ensure_data_dir() -> Result<PathBuf> {
let data_dir =
get_data_dir().ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
if !data_dir.exists() {
fs::create_dir_all(&data_dir)
.with_context(|| format!("Failed to create data directory: {}", data_dir.display()))?;
tracing::info!("Created data directory: {}", data_dir.display());
}
Ok(data_dir)
}
pub fn load_config_or_default() -> Result<Config> {
let config_path =
get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
if !config_path.exists() {
tracing::info!(
"Config file not found, creating default at: {}",
config_path.display()
);
let config = Config::default();
ensure_default_mount_dirs(&config)?;
save_config(&config)?;
return Ok(config);
}
let mut file = File::open(&config_path)
.with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let mut parsed_value = parse_to_serde_value(&contents, &Default::default())
.map_err(|e| anyhow::anyhow!("Invalid JSONC in config file: {e}"))?
.ok_or_else(|| anyhow::anyhow!("Config file is empty"))?;
if let Some(obj) = parsed_value.as_object_mut() {
obj.remove("opencode_commit");
}
let mut config: Config = serde_json::from_value(parsed_value).with_context(|| {
format!(
"Invalid configuration in {}. Check for unknown fields or invalid values.",
config_path.display()
)
})?;
let mut removed_shadowing_mounts = false;
config.mounts.retain(|mount_str| {
let parsed = match ParsedMount::parse(mount_str) {
Ok(parsed) => parsed,
Err(_) => return true,
};
if parsed.container_path == "/opt/opencode"
|| parsed.container_path.starts_with("/opt/opencode/")
{
removed_shadowing_mounts = true;
tracing::warn!(
"Skipping bind mount that overrides opencode binaries: {}",
mount_str
);
return false;
}
true
});
if removed_shadowing_mounts {
tracing::info!("Removed bind mounts that shadow /opt/opencode");
}
ensure_default_mount_dirs(&config)?;
Ok(config)
}
pub fn save_config(config: &Config) -> Result<()> {
ensure_config_dir()?;
let config_path =
get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
if config_path.exists() {
let backup_path = config_path.with_extension("json.bak");
fs::copy(&config_path, &backup_path)
.with_context(|| format!("Failed to create backup at: {}", backup_path.display()))?;
tracing::debug!("Created config backup: {}", backup_path.display());
}
let json = serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
let mut file = File::create(&config_path)
.with_context(|| format!("Failed to create config file: {}", config_path.display()))?;
file.write_all(json.as_bytes())
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
tracing::debug!("Saved config to: {}", config_path.display());
Ok(())
}
fn ensure_default_mount_dirs(config: &Config) -> Result<()> {
let defaults = default_mounts();
if defaults.is_empty() {
return Ok(());
}
for mount_str in &config.mounts {
if !defaults.contains(mount_str) {
continue;
}
let parsed = crate::docker::mount::ParsedMount::parse(mount_str)
.with_context(|| format!("Invalid default mount configured: {mount_str}"))?;
let path = parsed.host_path.as_path();
if path.exists() {
if !path.is_dir() {
return Err(anyhow::anyhow!(
"Default mount path is not a directory: {}",
path.display()
));
}
continue;
}
fs::create_dir_all(path)
.with_context(|| format!("Failed to create mount directory: {}", path.display()))?;
tracing::info!("Created mount directory: {}", path.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_resolution_returns_values() {
assert!(get_config_dir().is_some());
assert!(get_data_dir().is_some());
assert!(get_config_path().is_some());
assert!(get_pid_path().is_some());
}
#[test]
fn test_paths_end_with_expected_names() {
let config_dir = get_config_dir().unwrap();
assert!(config_dir.ends_with("opencode-cloud"));
let data_dir = get_data_dir().unwrap();
assert!(data_dir.ends_with("opencode-cloud"));
let config_path = get_config_path().unwrap();
assert!(config_path.ends_with("config.json"));
let pid_path = get_pid_path().unwrap();
assert!(pid_path.ends_with("opencode-cloud.pid"));
}
}