vvbox 0.1.0

Lightweight sandbox runner for macOS 26 using Apple container CLI.
Documentation
//! Service container orchestration based on config.

use crate::config::resolve_config;
use crate::container::{
    build_named_service_args, ensure_container_available, list_service_containers, list_service_status,
    stop_container, tail_container_logs,
};
use crate::git::resolve_repo_root;
use crate::util::{die, print_json};

/// Start configured service containers for a repo.
pub fn services_up(repo_path: &str, config: Option<String>, restart: bool) {
    let repo_root = resolve_repo_root(repo_path);
    let base_config = resolve_config(&repo_root, config.as_deref());
    if base_config.services.is_empty() {
        die("no services defined in config");
    }
    ensure_container_available();
    let prefix = repo_key(&repo_root);
    if restart {
        for name in list_service_containers(&prefix) {
            stop_container(&name);
        }
    }
    let net = base_config.network.clone();
    for svc in &base_config.services {
        if svc.name.is_empty() || svc.image.is_empty() {
            die("service entries require name and image");
        }
        let (name, args) = build_named_service_args(&prefix, svc, &repo_root, &net);
        let status = crate::util::run_cmd(&args, None, true);
        if !status.success() {
            die("failed to start service container");
        }
        println!("started {}", name);
    }
}

/// Stop configured service containers for a repo.
pub fn services_down(repo_path: &str, config: Option<String>) {
    let repo_root = resolve_repo_root(repo_path);
    let base_config = resolve_config(&repo_root, config.as_deref());
    ensure_container_available();
    let prefix = repo_key(&repo_root);
    if !base_config.services.is_empty() {
        for svc in &base_config.services {
            let name = format!("vvbox-svc-{}-{}", prefix, svc.name);
            stop_container(&name);
        }
    } else {
        for name in list_service_containers(&prefix) {
            stop_container(&name);
        }
    }
    println!("services stopped");
}

/// Report service status for a repo, optionally as JSON.
pub fn services_status(repo_path: &str, config: Option<String>, json: bool) {
    let repo_root = resolve_repo_root(repo_path);
    let base_config = resolve_config(&repo_root, config.as_deref());
    ensure_container_available();
    let prefix = repo_key(&repo_root);
    let statuses = list_service_status(&prefix);
    if json {
        let mut arr = vec![];
        if !base_config.services.is_empty() {
            for svc in &base_config.services {
                let name = format!("vvbox-svc-{}-{}", prefix, svc.name);
                let status = statuses
                    .iter()
                    .find(|(id, _)| id == &name)
                    .map(|(_, s)| s.clone())
                    .unwrap_or_else(|| "missing".to_string());
                arr.push(serde_json::json!({ "name": name, "status": status }));
            }
        } else {
            for (id, status) in statuses {
                arr.push(serde_json::json!({ "name": id, "status": status }));
            }
        }
        print_json(&serde_json::json!({ "services": arr }));
        return;
    }

    if !base_config.services.is_empty() {
        for svc in &base_config.services {
            let name = format!("vvbox-svc-{}-{}", prefix, svc.name);
            let status = statuses
                .iter()
                .find(|(id, _)| id == &name)
                .map(|(_, s)| s.clone())
                .unwrap_or_else(|| "missing".to_string());
            println!("{} {}", name, status);
        }
    } else if statuses.is_empty() {
        println!("no services running");
    } else {
        for (id, status) in statuses {
            println!("{} {}", id, status);
        }
    }
}

/// Restart configured service containers for a repo.
pub fn services_restart(repo_path: &str, config: Option<String>) {
    let repo_root = resolve_repo_root(repo_path);
    let base_config = resolve_config(&repo_root, config.as_deref());
    if base_config.services.is_empty() {
        die("no services defined in config");
    }
    ensure_container_available();
    let prefix = repo_key(&repo_root);
    for name in list_service_containers(&prefix) {
        stop_container(&name);
    }
    let net = base_config.network.clone();
    for svc in &base_config.services {
        if svc.name.is_empty() || svc.image.is_empty() {
            die("service entries require name and image");
        }
        let (name, args) = build_named_service_args(&prefix, svc, &repo_root, &net);
        let status = crate::util::run_cmd(&args, None, true);
        if !status.success() {
            die("failed to start service container");
        }
        println!("started {}", name);
    }
}

/// Stream logs for a named service container.
pub fn services_logs(repo_path: &str, config: Option<String>, name: String, tail: Option<u32>, since: Option<String>, no_follow: bool) {
    let repo_root = resolve_repo_root(repo_path);
    let base_config = resolve_config(&repo_root, config.as_deref());
    if base_config.services.is_empty() {
        die("no services defined in config");
    }
    ensure_container_available();
    let prefix = repo_key(&repo_root);
    let svc_name = format!("vvbox-svc-{}-{}", prefix, name);
    let ok = tail_container_logs(&svc_name, tail, since, !no_follow);
    if !ok {
        die("failed to read service logs");
    }
}

/// Derive a stable repo key for service container naming.
pub fn repo_key(repo_root: &std::path::Path) -> String {
    let binding = repo_root.to_string_lossy();
    let bytes = binding.as_bytes();
    let mut hash: u64 = 1469598103934665603;
    for b in bytes {
        hash ^= *b as u64;
        hash = hash.wrapping_mul(1099511628211);
    }
    format!("{:x}", hash)
}