use std::fs::Permissions;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use assert_cmd::prelude::*;
struct Sandbox {
dir: PathBuf,
}
impl Sandbox {
fn new(configs: &[&str]) -> Self {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("podbox-test-{}-{n}", std::process::id()));
let cfg = dir.join("podbox");
std::fs::create_dir_all(&cfg).unwrap();
for name in configs {
let fixture =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/full.toml");
std::fs::copy(&fixture, cfg.join(format!("{name}.toml"))).unwrap();
}
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let stub = bin.join("podman");
let mut f = std::fs::File::create(&stub).unwrap();
writeln!(f, "#!/bin/sh\nexit 0").unwrap();
drop(f);
std::fs::set_permissions(&stub, Permissions::from_mode(0o755)).unwrap();
Self { dir }
}
fn path_env(&self) -> String {
format!(
"{}:{}",
self.dir.join("bin").display(),
std::env::var("PATH").unwrap_or_default()
)
}
fn cmd(&self) -> Command {
let mut c = Command::cargo_bin("podbox").unwrap();
c.env("XDG_CONFIG_HOME", &self.dir)
.env("XDG_DATA_HOME", self.dir.join("data"))
.env("XDG_STATE_HOME", self.dir.join("state"))
.env("XDG_RUNTIME_DIR", self.dir.join("runtime"))
.env_remove("PODBOX_CONTAINER")
.env("PATH", self.path_env());
c
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn find_definition_missing_exits_two() {
let sb = Sandbox::new(&[]);
let out = sb
.cmd()
.args(["find-definition", "nosuch"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(
out.stdout.is_empty(),
"stdout must stay machine-clean on failure"
);
}
#[test]
fn find_definition_prints_path() {
let sb = Sandbox::new(&["myenv"]);
let out = sb
.cmd()
.args(["find-definition", "myenv"])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let expected = sb.dir.join("podbox").join("myenv.toml");
assert_eq!(stdout.trim(), expected.display().to_string());
}
#[test]
fn non_tty_multiple_configs_errors_with_hint() {
let sb = Sandbox::new(&["alpha", "beta"]);
let out = sb.cmd().args(["enter", "--dry-run"]).output().unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("Multiple container configs found"));
assert!(stderr.contains("alpha"));
assert!(stderr.contains("beta"));
assert!(stderr.contains("-C <NAME>"), "must suggest -C");
}
#[test]
fn non_tty_explicit_container_flag_resolves() {
let sb = Sandbox::new(&["alpha", "beta"]);
let out = sb
.cmd()
.args(["-C", "alpha", "enter", "--dry-run"])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn complete_names_lists_configs_and_never_fails() {
let sb = Sandbox::new(&["alpha", "beta"]);
let out = sb.cmd().arg("__complete-names").output().unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("alpha"));
assert!(stdout.contains("beta"));
let empty = Sandbox::new(&[]);
let out = empty.cmd().arg("__complete-names").output().unwrap();
assert!(out.status.success());
assert!(out.stdout.is_empty());
}
#[test]
fn completions_include_dynamic_name_glue() {
for (shell, marker) in [
("bash", "__podbox_wrap"),
("fish", "__podbox_names"),
("zsh", "__podbox_wrap"),
] {
let out = sb_cmd_completions(shell).output().unwrap();
assert!(out.status.success(), "{shell}");
assert!(
String::from_utf8_lossy(&out.stdout).contains(marker),
"{shell} script should reference {marker}"
);
}
}
#[test]
fn fish_abbrs_are_opt_in_and_fish_only() {
let out = sb_cmd_completions("fish")
.args(["--abbrs"])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
for token in [
"pb", "pbb", "pbc", "pbd", "pbe", "pbl", "pbr", "pbs", "pbt", "pbu", "pbv", "pbx",
] {
assert!(
stdout
.lines()
.any(|l| l.starts_with(&format!("abbr {token} "))),
"missing `abbr {token}` definition"
);
}
let default = sb_cmd_completions("fish").output().unwrap();
let default_stdout = String::from_utf8_lossy(&default.stdout);
assert!(
!default_stdout.contains("abbr "),
"default fish output must not contain abbreviations"
);
let bash = sb_cmd_completions("bash")
.args(["--abbrs"])
.output()
.unwrap();
assert!(bash.status.success());
let bash_stdout = String::from_utf8_lossy(&bash.stdout);
assert!(
!bash_stdout.contains("abbr "),
"abbreviations must be fish-only"
);
}
fn sb_cmd_completions(shell: &str) -> Command {
let mut c = Sandbox::new(&[]).cmd();
c.args(["completions", shell]);
c
}
fn sb_with_history_log() -> Sandbox {
let sb = Sandbox::new(&[]);
let dir = sb.dir.join("state").join("podbox");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("history.log"),
"2026-08-26T01:00:00Z\talpha\tbuild\t\n\
2026-08-26T02:00:00Z\tbeta\tstart\t\n\
2026-08-26T03:00:00Z\talpha\tstop\t\n",
)
.unwrap();
sb
}
#[test]
fn history_prints_entries_newest_first() {
let sb = sb_with_history_log();
let out = sb.cmd().arg("history").output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let stop = stdout.find("stop").expect("stop entry present");
let build = stdout.find("build").expect("build entry present");
assert!(stop < build, "newest entry must print first");
}
#[test]
fn history_filters_by_name_and_limit() {
let sb = sb_with_history_log();
let out = sb.cmd().args(["history", "alpha"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("alpha"));
assert!(!stdout.contains("beta"), "other containers filtered out");
let out = sb.cmd().args(["history", "--limit", "1"]).output().unwrap();
let rows = String::from_utf8_lossy(&out.stdout)
.lines()
.skip(2) .count();
assert_eq!(rows, 1, "limit caps printed entries");
}
#[test]
fn history_json_output_parses() {
let sb = sb_with_history_log();
let out = sb
.cmd()
.args(["history", "--output", "json"])
.output()
.unwrap();
assert!(out.status.success());
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("valid json");
let events = v["history"].as_array().expect("history array");
assert_eq!(events.len(), 3);
assert_eq!(events[0]["action"], "stop");
assert_eq!(events[0]["name"], "alpha");
}
#[test]
fn history_without_log_is_empty_success() {
let sb = Sandbox::new(&[]);
let out = sb.cmd().arg("history").output().unwrap();
assert!(out.status.success());
assert!(out.stdout.is_empty());
}
#[test]
fn list_columns_align_without_trailing_space() {
let sb = Sandbox::new(&["alpha", "beta"]);
let out = sb.cmd().arg("list").output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert!(lines.len() >= 4, "header + rule + two rows");
let header = lines[0];
let autostart_col = header.find("AUTOSTART").expect("AUTOSTART header");
let active_col = header
.find("ACTIVE CONTEXT")
.expect("ACTIVE CONTEXT header");
for row in &lines[2..] {
assert_eq!(
row.len(),
row.trim_end().len(),
"trailing whitespace in {row:?}"
);
assert!(row.len() > autostart_col, "row too short: {row:?}");
}
assert_eq!(lines[1].chars().count(), header.trim_end().chars().count());
for row in &lines[2..] {
assert!(
row.starts_with("alpha") || row.starts_with("beta"),
"unexpected row {row:?}"
);
let _ = active_col; }
}
#[test]
fn doctor_groups_stale_sockets_into_one_check() {
let sb = Sandbox::new(&["alpha"]);
let sock_dir = sb.dir.join("runtime").join("podbox");
std::fs::create_dir_all(&sock_dir).unwrap();
std::fs::write(sock_dir.join("ghost-wayland.sock"), "").unwrap();
std::fs::write(sock_dir.join("ghost-dbus.sock"), "").unwrap();
let out = sb.cmd().args(["doctor"]).output().unwrap();
let text = String::from_utf8_lossy(&out.stdout);
let warn_lines: Vec<&str> = text
.lines()
.filter(|l| l.contains("stale sockets"))
.collect();
assert_eq!(
warn_lines.len(),
1,
"stale sockets must be a single grouped check, got: {warn_lines:?}"
);
let line = warn_lines[0];
assert!(line.contains("ghost-wayland.sock"));
assert!(line.contains("ghost-dbus.sock"));
let out = sb
.cmd()
.args(["doctor", "--output", "json"])
.output()
.unwrap();
let v: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap();
let stale: Vec<&serde_json::Value> = v["checks"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["name"] == "stale sockets")
.collect();
assert_eq!(stale.len(), 1);
assert_eq!(stale[0]["status"], "warn");
}
#[test]
fn doctor_json_has_groups_and_exit_status() {
let sb = Sandbox::new(&["alpha"]);
let out = sb
.cmd()
.args(["-C", "alpha", "doctor", "--output", "json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("doctor --output json must print valid JSON");
let checks = v["checks"].as_array().expect("checks array");
assert!(!checks.is_empty());
assert!(checks.iter().all(|c| c["group"].is_string()));
let groups: Vec<&str> = checks
.iter()
.map(|c| c["group"].as_str().unwrap())
.collect();
assert!(groups.contains(&"Host"));
assert!(groups.contains(&"Integration"));
let out = sb.cmd().args(["-C", "alpha", "doctor"]).output().unwrap();
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("Host"));
assert!(text.contains("Integration"));
assert!(text.contains("Host exposure"), "exposure summary missing");
}
#[test]
fn recover_dry_run_prints_plan() {
let sb = Sandbox::new(&["alpha"]);
let out = sb
.cmd()
.args(["-C", "alpha", "recover", "--dry-run"])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("Recovery plan"));
assert!(text.contains("daemon-reload"));
assert!(text.contains("Quadlet"));
}