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