vvbox 0.1.0

Lightweight sandbox runner for macOS 26 using Apple container CLI.
Documentation
//! Configuration loading and path resolution.

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};

/// Return the user's home directory, falling back to `/`.
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("/"))
}

/// Return the vvbox state directory, honoring `VVBOX_HOME` when set.
pub fn vvbox_home() -> PathBuf {
    if let Ok(custom) = std::env::var("VVBOX_HOME") {
        return PathBuf::from(custom);
    }
    home_dir().join(".vvbox")
}

/// Directory for run metadata and patches.
pub fn runs_dir() -> PathBuf {
    vvbox_home().join("runs")
}

/// Directory for git worktree snapshots.
pub fn worktrees_dir() -> PathBuf {
    vvbox_home().join("worktrees")
}

/// Directory for named vvbox volumes.
pub fn volumes_dir() -> PathBuf {
    vvbox_home().join("volumes")
}

/// Ensure vvbox state directories exist.
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()
}

/// Resolve a volume spec from config into a concrete mount.
///
/// Supports `vvbox:<name>:<target>` named volumes and relative paths rooted at
/// `repo_root`.
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,
            }
        }
    }
}

/// Load and merge configuration, returning fully resolved defaults.
///
/// Order of precedence: global config in `VVBOX_HOME` (yaml/yml/json), then the
/// provided `config_override` or repo-local `vvbox.(yml|yaml|json)`.
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 }
}

/// Write a default `vvbox.yaml` template.
///
/// Use `force` to overwrite an existing file.
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());
}