vvbox 0.1.0

Lightweight sandbox runner for macOS 26 using Apple container CLI.
Documentation
//! CLI command dispatch and execution.

use crate::cli::{Cli, Commands, ServiceCommands};
use crate::config;
use crate::container::{build_container_args, build_service_args, ensure_container_available, tail_container_logs};
use crate::git::{create_worktree, git_apply_stat, git_status_clean, resolve_existing_worktree, resolve_repo_root};
use crate::runs::{cleanup_run, ensure_patch, last_run_id, list_runs, load_meta, save_meta};
use crate::services::{services_down, services_logs, services_restart, services_status, services_up};
use crate::types::VolumeSpec;
use crate::util::{confirm_apply, die, print_json, shell_escape};
use chrono::Utc;
use std::io::IsTerminal;
use uuid::Uuid;

/// Execute a parsed CLI command.
///
/// This is the main entry point used by the `vvbox` binary and can also be
/// called by other Rust programs that want the same behavior.
pub fn run(cli: Cli) {
    config::ensure_dirs();

    match cli.command {
        Commands::Run {
            repo,
            worktree,
            in_place,
            config,
            image,
            network,
            no_network,
            workdir,
            task,
            env,
            env_file,
            port,
            volume,
            read_only,
            diff,
            cleanup,
            cleanup_on_success,
            shell,
            keep,
            json,
            cmd,
            args,
        } => {
            if in_place && worktree.is_none() {
                die("--in-place requires --worktree");
            }
            let (repo_root, snapshot_path, in_place_mode, run_id) = if let Some(wt) = worktree.clone() {
                let (root, wt_path) = resolve_existing_worktree(&wt);
                (root, wt_path, in_place, Uuid::new_v4().to_string())
            } else {
                let root = resolve_repo_root(&repo);
                let run_id = Uuid::new_v4().to_string();
                let snap = create_worktree(&root, &run_id);
                (root, snap, false, run_id)
            };

            let base_config = config::resolve_config(&repo_root, config.as_deref());

            let pre_install = base_config.pre_install.clone();
            let mut run_cmds = base_config.run.clone();
            let mut command: Vec<String> = vec![];
            let mut should_shell = false;

            if shell {
                if cmd.is_some() || !args.is_empty() || !run_cmds.is_empty() || !pre_install.is_empty() {
                    die("--shell cannot be combined with other commands");
                }
                command = vec!["/bin/sh".to_string()];
            } else if let Some(cmd) = cmd {
                run_cmds = vec![cmd];
                should_shell = true;
            } else if !args.is_empty() {
                command = args;
                run_cmds = vec![];
            } else if !run_cmds.is_empty() {
                should_shell = true;
            } else {
                die("no command provided (use --cmd, --, or config run)");
            }

            if !pre_install.is_empty() {
                should_shell = true;
            }

            if should_shell {
                let mut script = String::new();
                if !pre_install.is_empty() {
                    script.push_str(&pre_install.join(" && "));
                }
                if !run_cmds.is_empty() || !command.is_empty() {
                    if !script.is_empty() {
                        script.push_str(" && ");
                    }
                    if !run_cmds.is_empty() {
                        script.push_str(&run_cmds.join(" && "));
                    } else if !command.is_empty() {
                        let escaped = command.iter().map(|a| shell_escape(a)).collect::<Vec<_>>().join(" ");
                        script.push_str(&escaped);
                    }
                }
                command = vec!["sh".to_string(), "-lc".to_string(), script];
            }

            let mut volumes = base_config.volumes.clone();
            for v in volume {
                let spec = VolumeSpec::String(v);
                volumes.push(config::resolve_volume_spec(&spec, &repo_root));
            }

            let mut ports = base_config.ports.clone();
            for p in port {
                ports.push(p);
            }

            let mut services_list: Vec<String> = vec![];
            if !base_config.services.is_empty() {
                if no_network {
                    die("services require networking; remove --no-network");
                }
                let service_network = if no_network {
                    Some("none".to_string())
                } else {
                    network.clone().or(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_service_args(&run_id, svc, &repo_root, &service_network);
                    let status = crate::util::run_cmd(&args, None, true);
                    if !status.success() {
                        die("failed to start service container");
                    }
                    services_list.push(name);
                }
            }

            let container_name = format!("vvbox-{}", run_id);
            let mut meta = crate::types::RunMeta {
                id: run_id.clone(),
                created_at: Utc::now().to_rfc3339(),
                repo_root: repo_root.to_string_lossy().to_string(),
                snapshot_path: snapshot_path.to_string_lossy().to_string(),
                snapshot_mode: if in_place_mode { "in-place".to_string() } else { "worktree".to_string() },
                container_name: container_name.clone(),
                image: image.unwrap_or_else(|| base_config.image.clone()),
                workdir: workdir.unwrap_or_else(|| base_config.workdir.clone()),
                network: if no_network { Some("none".to_string()) } else { network.or(base_config.network.clone()) },
                env: base_config.env.clone(),
                volumes: volumes.clone(),
                ports: ports.clone(),
                command: command.clone(),
                pre_install: pre_install.clone(),
                run: run_cmds.clone(),
                services: services_list.clone(),
                in_place: in_place_mode,
                task,
                exit_code: None,
                status: "created".to_string(),
            };

            save_meta(&meta);
            ensure_container_available();

            let mut container_args = build_container_args(&meta, &env, &env_file, read_only);
            if !keep {
                container_args.insert(2, "--rm".to_string());
            }
            if shell {
                container_args.insert(2, "-t".to_string());
                container_args.insert(2, "-i".to_string());
            }
            let status = crate::util::run_cmd(&container_args, None, true);

            meta.exit_code = status.code();
            meta.status = if status.success() { "ran".to_string() } else { "failed".to_string() };
            save_meta(&meta);

            let mut patch_path: Option<std::path::PathBuf> = None;
            if diff && !in_place_mode {
                patch_path = Some(ensure_patch(&meta));
            } else if diff && in_place_mode {
                println!("note: --diff is ignored in --in-place mode");
            }

            if cleanup || (cleanup_on_success && meta.exit_code == Some(0)) {
                cleanup_run(&meta);
            }

            if json {
                let mut obj = serde_json::json!({
                    "id": meta.id,
                    "repoRoot": meta.repo_root,
                    "snapshotPath": meta.snapshot_path,
                    "exitCode": meta.exit_code,
                    "status": meta.status,
                });
                if let Some(p) = patch_path {
                    obj["patchPath"] = serde_json::json!(p.to_string_lossy().to_string());
                }
                print_json(&obj);
            } else {
                println!("run: {}", meta.id);
                println!("snapshot: {}", meta.snapshot_path);
                if let Some(code) = meta.exit_code {
                    println!("exit: {}", code);
                }
                if let Some(p) = patch_path {
                    println!("patch: {}", p.to_string_lossy());
                }
            }
        }

        Commands::Diff { id, last, json } => {
            let run_id = id.unwrap_or_else(|| if last { last_run_id() } else { "".to_string() });
            if run_id.is_empty() {
                die("--id or --last is required");
            }
            let meta = load_meta(&run_id);
            let patch_path = ensure_patch(&meta);
            if json {
                print_json(&serde_json::json!({ "id": run_id, "patchPath": patch_path.to_string_lossy().to_string() }));
            } else {
                println!("patch: {}", patch_path.to_string_lossy());
            }
        }

        Commands::Apply { id, last, json, allow_dirty, yes } => {
            let run_id = id.unwrap_or_else(|| if last { last_run_id() } else { "".to_string() });
            if run_id.is_empty() {
                die("--id or --last is required");
            }
            let meta = load_meta(&run_id);
            if meta.in_place {
                die("this run used --in-place; no patch to apply");
            }
            let patch_path = ensure_patch(&meta);
            if !allow_dirty && !git_status_clean(&meta.repo_root) {
                die("working tree is not clean; commit/stash changes before applying patch");
            }
            if let Some(stat) = git_apply_stat(&meta.repo_root, &patch_path) {
                println!("{}", stat.trim_end());
            }
            if !yes {
                if json || !std::io::stdin().is_terminal() {
                    die("use --yes to apply in non-interactive mode");
                }
                if !confirm_apply() {
                    println!("apply aborted");
                    return;
                }
            }
            let cmd = vec![
                "git".to_string(),
                "-C".to_string(),
                meta.repo_root.clone(),
                "apply".to_string(),
                "--whitespace=nowarn".to_string(),
                patch_path.to_string_lossy().to_string(),
            ];
            let status = crate::util::run_cmd(&cmd, None, true);
            if !status.success() {
                die("failed to apply patch");
            }
            if json {
                print_json(&serde_json::json!({ "id": run_id, "applied": true, "patchPath": patch_path.to_string_lossy().to_string() }));
            } else {
                println!("patch applied");
            }
        }

        Commands::Attach { id, last, shell, cmd, args } => {
            let run_id = id.unwrap_or_else(|| if last { last_run_id() } else { "".to_string() });
            if run_id.is_empty() {
                die("--id or --last is required");
            }
            ensure_container_available();
            let meta = load_meta(&run_id);
            let mut exec_args: Vec<String> = vec![
                "container".to_string(),
                "exec".to_string(),
                "-i".to_string(),
                "-t".to_string(),
                meta.container_name.clone(),
            ];
            if shell {
                exec_args.push("/bin/sh".to_string());
            } else if let Some(cmd) = cmd {
                exec_args.push("sh".to_string());
                exec_args.push("-lc".to_string());
                exec_args.push(cmd);
            } else if !args.is_empty() {
                exec_args.extend(args);
            } else {
                die("no command provided (use --shell, --cmd, or --)");
            }
            let status = crate::util::run_cmd(&exec_args, None, true);
            if !status.success() {
                die("failed to attach to container");
            }
        }

        Commands::Logs { id, last, tail, since, no_follow } => {
            let run_id = id.unwrap_or_else(|| if last { last_run_id() } else { "".to_string() });
            if run_id.is_empty() {
                die("--id or --last is required");
            }
            ensure_container_available();
            let meta = load_meta(&run_id);
            let ok = tail_container_logs(&meta.container_name, tail, since, !no_follow);
            if !ok {
                die("failed to read run logs");
            }
        }

        Commands::Services { action } => match action {
            ServiceCommands::Up { repo, config, restart } => {
                services_up(&repo.unwrap_or_else(|| ".".to_string()), config, restart);
            }
            ServiceCommands::Down { repo, config } => {
                services_down(&repo.unwrap_or_else(|| ".".to_string()), config);
            }
            ServiceCommands::Status { repo, config, json } => {
                services_status(&repo.unwrap_or_else(|| ".".to_string()), config, json);
            }
            ServiceCommands::Restart { repo, config } => {
                services_restart(&repo.unwrap_or_else(|| ".".to_string()), config);
            }
            ServiceCommands::Logs { repo, config, name, tail, since, no_follow } => {
                services_logs(&repo.unwrap_or_else(|| ".".to_string()), config, name, tail, since, no_follow);
            }
        },

        Commands::Up { repo, config, restart } => {
            services_up(&repo.unwrap_or_else(|| ".".to_string()), config, restart);
        }

        Commands::Down { repo, config } => {
            services_down(&repo.unwrap_or_else(|| ".".to_string()), config);
        }

        Commands::List { json } => {
            let runs = list_runs();
            if json {
                let data = serde_json::to_value(runs).unwrap_or_else(|_| serde_json::json!([]));
                print_json(&data);
            } else {
                for r in runs {
                    println!("{}  {}  {}  {}", r.id, r.status, r.created_at, r.repo_root);
                }
            }
        }

        Commands::Cleanup { id, last, json } => {
            let run_id = id.unwrap_or_else(|| if last { last_run_id() } else { "".to_string() });
            if run_id.is_empty() {
                die("--id or --last is required");
            }
            let meta = load_meta(&run_id);
            cleanup_run(&meta);
            if json {
                print_json(&serde_json::json!({ "id": run_id, "cleaned": true }));
            } else {
                println!("cleanup complete");
            }
        }

        Commands::Init { repo, out, force } => {
            let repo_path = repo.unwrap_or_else(|| ".".to_string());
            let repo_root = resolve_repo_root(&repo_path);
            let out_path = out
                .map(std::path::PathBuf::from)
                .unwrap_or_else(|| repo_root.join("vvbox.yaml"));
            config::write_default_config(&out_path, force);
            println!("wrote {}", out_path.to_string_lossy());
        }
    }
}