use crate::types::{ConfigFile, ResolvedConfig, VolumeMount, VolumeSpec};
use crate::util::die;
use serde::Deserialize;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
pub fn home_dir() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home);
}
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
pub fn vvbox_home() -> PathBuf {
if let Ok(custom) = std::env::var("VVBOX_HOME") {
return PathBuf::from(custom);
}
home_dir().join(".vvbox")
}
pub fn runs_dir() -> PathBuf {
vvbox_home().join("runs")
}
pub fn worktrees_dir() -> PathBuf {
vvbox_home().join("worktrees")
}
pub fn volumes_dir() -> PathBuf {
vvbox_home().join("volumes")
}
pub fn ensure_dirs() {
let _ = fs::create_dir_all(runs_dir());
let _ = fs::create_dir_all(worktrees_dir());
let _ = fs::create_dir_all(volumes_dir());
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path, fallback: T) -> T {
if !path.exists() {
return fallback;
}
let mut buf = String::new();
if File::open(path).and_then(|mut f| f.read_to_string(&mut buf)).is_ok() {
if let Ok(val) = serde_json::from_str::<T>(&buf) {
return val;
}
}
fallback
}
fn read_yaml<T: for<'de> Deserialize<'de>>(path: &Path, fallback: T) -> T {
if !path.exists() {
return fallback;
}
let mut buf = String::new();
if File::open(path).and_then(|mut f| f.read_to_string(&mut buf)).is_ok() {
if let Ok(val) = serde_yaml::from_str::<T>(&buf) {
return val;
}
}
fallback
}
fn load_config_file(path: &Path) -> ConfigFile {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml") {
return read_yaml(path, ConfigFile::default());
}
}
read_json(path, ConfigFile::default())
}
fn merge_config(base: &mut ConfigFile, overlay: ConfigFile) {
if overlay.image.is_some() {
base.image = overlay.image;
}
if overlay.network.is_some() {
base.network = overlay.network;
}
if overlay.workdir.is_some() {
base.workdir = overlay.workdir;
}
if overlay.env.is_some() {
let mut merged = base.env.take().unwrap_or_default();
if let Some(env) = overlay.env {
for (k, v) in env {
merged.insert(k, v);
}
}
base.env = Some(merged);
}
if overlay.volumes.is_some() {
base.volumes = overlay.volumes;
}
if overlay.ports.is_some() {
base.ports = overlay.ports;
}
if overlay.pre_install.is_some() {
base.pre_install = overlay.pre_install;
}
if overlay.run.is_some() {
base.run = overlay.run;
}
if overlay.services.is_some() {
base.services = overlay.services;
}
}
fn expand_path(input: &str, repo_root: &Path) -> String {
if let Some(rest) = input.strip_prefix("~/") {
return home_dir().join(rest).to_string_lossy().to_string();
}
let p = Path::new(input);
if p.is_absolute() {
return input.to_string();
}
repo_root.join(input).to_string_lossy().to_string()
}
pub fn resolve_volume_spec(spec: &VolumeSpec, repo_root: &Path) -> VolumeMount {
match spec {
VolumeSpec::String(s) => {
if let Some(rest) = s.strip_prefix("vvbox:") {
let mut iter = rest.splitn(2, ':');
let name = iter.next().unwrap_or("").trim();
let remainder = iter.next().unwrap_or("");
if name.is_empty() || remainder.is_empty() {
die("invalid vvbox volume spec, expected vvbox:<name>:<target>[:ro]");
}
let mut iter2 = remainder.splitn(2, ':');
let target = iter2.next().unwrap_or("");
let mode = iter2.next();
let readonly = mode.map(|m| m == "ro").unwrap_or(false);
let dir = volumes_dir().join(name);
let _ = fs::create_dir_all(&dir);
return VolumeMount {
source: dir.to_string_lossy().to_string(),
target: target.to_string(),
readonly,
};
}
let parts: Vec<&str> = s.split(':').collect();
if parts.len() < 2 || parts.len() > 3 {
die("invalid volume spec, expected source:target[:ro]");
}
let source = parts[0];
let target = parts[1];
let readonly = parts.get(2).map(|p| *p == "ro").unwrap_or(false);
VolumeMount {
source: expand_path(source, repo_root),
target: target.to_string(),
readonly,
}
}
VolumeSpec::Object { source, target, readonly } => {
let resolved_source = if source.starts_with("vvbox:") {
let name = source.trim_start_matches("vvbox:").trim_matches('/');
let dir = volumes_dir().join(name);
let _ = fs::create_dir_all(&dir);
dir.to_string_lossy().to_string()
} else {
expand_path(source, repo_root)
};
VolumeMount {
source: resolved_source,
target: target.to_string(),
readonly: *readonly,
}
}
}
}
pub fn resolve_config(repo_root: &Path, config_override: Option<&str>) -> ResolvedConfig {
let mut base = ConfigFile::default();
let global_yaml = vvbox_home().join("config.yaml");
let global_yml = vvbox_home().join("config.yml");
let global_json = vvbox_home().join("config.json");
if global_yaml.exists() {
merge_config(&mut base, load_config_file(&global_yaml));
} else if global_yml.exists() {
merge_config(&mut base, load_config_file(&global_yml));
} else if global_json.exists() {
merge_config(&mut base, load_config_file(&global_json));
}
if let Some(path) = config_override {
let cfg_path = PathBuf::from(path);
merge_config(&mut base, load_config_file(&cfg_path));
} else {
let repo_yaml = repo_root.join("vvbox.yaml");
let repo_yml = repo_root.join("vvbox.yml");
let repo_json = repo_root.join("vvbox.json");
if repo_yaml.exists() {
merge_config(&mut base, load_config_file(&repo_yaml));
} else if repo_yml.exists() {
merge_config(&mut base, load_config_file(&repo_yml));
} else if repo_json.exists() {
merge_config(&mut base, load_config_file(&repo_json));
}
}
let image = base.image.unwrap_or_else(|| "ubuntu:latest".to_string());
let network = base.network;
let workdir = base.workdir.unwrap_or_else(|| "/work".to_string());
let env = base.env.unwrap_or_default();
let volumes = base
.volumes
.unwrap_or_default()
.iter()
.map(|v| resolve_volume_spec(v, repo_root))
.collect::<Vec<_>>();
let ports = base.ports.unwrap_or_default();
let pre_install = base.pre_install.map(|c| c.to_vec()).unwrap_or_default();
let run = base.run.map(|c| c.to_vec()).unwrap_or_default();
let services = base.services.unwrap_or_default();
ResolvedConfig { image, network, workdir, env, volumes, ports, pre_install, run, services }
}
pub fn write_default_config(path: &Path, force: bool) {
if path.exists() && !force {
die("config file already exists (use --force to overwrite)");
}
let content = r#"image: ubuntu:latest
network: default
workdir: /work
env:
CI: "1"
ports:
- "8080:8080"
volumes:
- source: vvbox:cache
target: /cache
pre_install:
- apt-get update
- apt-get install -y git
run:
- npm install
- npm test
services:
- name: db
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- source: vvbox:pgdata
target: /var/lib/postgresql/data
"#;
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let mut file = File::create(path).unwrap_or_else(|_| die("failed to write config"));
let _ = std::io::Write::write_all(&mut file, content.as_bytes());
}