use std::fs;
use std::process::Command;
use tempfile::tempdir;
fn bin() -> std::path::PathBuf {
env!("CARGO_BIN_EXE_teamctl").into()
}
fn seed_compose(root: &std::path::Path) {
fs::write(
root.join("team-compose.yaml"),
r#"
version: 2
broker:
type: sqlite
path: state/mailbox.db
supervisor:
type: tmux
tmux_prefix: a-
projects:
- file: projects/hello.yaml
"#,
)
.unwrap();
fs::create_dir_all(root.join("projects")).unwrap();
fs::write(
root.join("projects/hello.yaml"),
r#"
version: 2
project:
id: hello
name: Hello
cwd: .
channels:
- name: all
members: "*"
managers:
manager:
runtime: claude-code
model: claude-opus-4-7
can_dm: [dev]
can_broadcast: [all]
workers:
dev:
runtime: claude-code
model: claude-sonnet-4-6
reports_to: manager
can_dm: [manager]
can_broadcast: [all]
"#,
)
.unwrap();
}
#[test]
fn validate_passes_on_clean_compose() {
let tmp = tempdir().unwrap();
seed_compose(tmp.path());
let out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("1 project"), "got: {stdout}");
assert!(stdout.contains("2 agents"), "got: {stdout}");
}
#[test]
fn validate_fails_on_unknown_dm_target() {
let tmp = tempdir().unwrap();
seed_compose(tmp.path());
let path = tmp.path().join("projects/hello.yaml");
let contents = fs::read_to_string(&path)
.unwrap()
.replace("can_dm: [dev]", "can_dm: [ghost]");
fs::write(&path, contents).unwrap();
let out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("unknown agent `ghost`"),
"stderr was: {stderr}"
);
}
#[test]
fn send_injects_into_mailbox() {
let tmp = tempdir().unwrap();
seed_compose(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"send",
"hello:manager",
"hi there",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let db = tmp.path().join("state/mailbox.db");
let conn = rusqlite::Connection::open(&db).unwrap();
let (sender, recipient, text): (String, String, String) = conn
.query_row(
"SELECT sender, recipient, text FROM messages ORDER BY id DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(sender, "cli");
assert_eq!(recipient, "hello:manager");
assert_eq!(text, "hi there");
}
#[test]
fn help_header_includes_version_from_cargo_pkg_version() {
let out = Command::new(bin()).arg("--help").output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let expected = format!("teamctl {}", env!("CARGO_PKG_VERSION"));
assert!(
stdout.contains(&expected),
"expected `{expected}` in --help output; got:\n{stdout}"
);
}
#[test]
fn version_flag_still_prints_version() {
let out = Command::new(bin()).arg("--version").output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains(env!("CARGO_PKG_VERSION")),
"expected version in --version output; got: {stdout}"
);
}
#[test]
fn init_blank_template_scaffolds_minimal_tree() {
let tmp = tempdir().unwrap();
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "starter", "--template", "blank", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"init blank stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let team_dir = tmp.path().join("starter/.team");
assert!(
team_dir.is_dir(),
"expected .team/ at {}",
team_dir.display()
);
assert!(
team_dir.join("team-compose.yaml").is_file(),
"blank template must include team-compose.yaml"
);
assert!(
team_dir.join("projects/main.yaml").is_file(),
"blank template must include projects/main.yaml"
);
assert!(
team_dir.join(".env.example").is_file(),
"blank template must include .env.example (from _common)"
);
assert!(
team_dir.join(".gitignore").is_file(),
"blank template must include .gitignore (from _common)"
);
let validate = Command::new(bin())
.args(["--root", team_dir.to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
validate.status.success(),
"blank template validate stderr: {}",
String::from_utf8_lossy(&validate.stderr)
);
}
#[test]
fn init_force_overwrites_existing_dot_team_cleanly() {
let tmp = tempdir().unwrap();
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "myteam", "--template", "solo", "--yes"])
.output()
.unwrap();
assert!(out.status.success());
let team_dir = tmp.path().join("myteam/.team");
let sentinel = team_dir.join("sentinel-must-not-survive.txt");
fs::write(&sentinel, "this file should be wiped by --force").unwrap();
assert!(sentinel.exists(), "sentinel seeded for the test");
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "myteam", "--template", "blank", "--force", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"init --force stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!sentinel.exists(),
"sentinel survived --force; .team/ was not cleanly replaced"
);
assert!(team_dir.join("team-compose.yaml").is_file());
assert!(team_dir.join("projects/main.yaml").is_file());
assert!(
!team_dir.join("roles/manager.md").exists(),
"prior solo template's roles/manager.md should be wiped"
);
}
#[test]
fn approve_after_ttl_elapsed_returns_no_pending_error() {
let tmp = tempdir().unwrap();
seed_compose(tmp.path());
let db = tmp.path().join("state/mailbox.db");
std::fs::create_dir_all(db.parent().unwrap()).unwrap();
let conn = rusqlite::Connection::open(&db).unwrap();
team_core::mailbox::ensure(&conn).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let requested_at = now - 3600.0;
let expires_at = now - 1800.0;
conn.execute(
"INSERT INTO approvals (project_id, agent_id, action, summary, status,
requested_at, expires_at)
VALUES ('hello', 'manager', 'publish', 'old request', 'pending', ?1, ?2)",
rusqlite::params![requested_at, expires_at],
)
.unwrap();
let id: i64 = conn.last_insert_rowid();
drop(conn);
let gc_out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "gc"])
.output()
.unwrap();
assert!(
gc_out.status.success(),
"gc stderr: {}",
String::from_utf8_lossy(&gc_out.stderr)
);
let conn = rusqlite::Connection::open(&db).unwrap();
let status: String = conn
.query_row(
"SELECT status FROM approvals WHERE id = ?1",
rusqlite::params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
status, "undeliverable",
"gc should mark expired-undelivered row"
);
drop(conn);
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"approve",
&id.to_string(),
])
.output()
.unwrap();
assert!(
!out.status.success(),
"approve on terminal row should exit non-zero"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(&format!("no pending approval with id {id}")),
"expected canonical error, got: {stderr}"
);
let conn = rusqlite::Connection::open(&db).unwrap();
let (status, decided_by, delivered_at): (String, Option<String>, Option<f64>) = conn
.query_row(
"SELECT status, decided_by, delivered_at FROM approvals WHERE id = ?1",
rusqlite::params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(status, "undeliverable");
assert!(
decided_by.is_none() || decided_by.as_deref() != Some("cli"),
"cli should not have stamped decided_by on a terminal row"
);
assert!(
delivered_at.is_none(),
"delivered_at must stay NULL on undeliverable row (invariant); got {delivered_at:?}"
);
}
#[test]
fn reload_dry_run_with_no_prior_lists_added_and_does_not_apply() {
let tmp = tempdir().unwrap();
seed_compose(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"reload",
"--dry-run",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("added") && stdout.contains("(dry run)"),
"expected added/(dry run) lines, got: {stdout}"
);
assert!(
stdout.contains("hello:manager"),
"expected hello:manager in plan, got: {stdout}"
);
assert!(
stdout.contains("hello:dev"),
"expected hello:dev in plan, got: {stdout}"
);
let applied = tmp.path().join("state/applied.json");
assert!(
!applied.exists(),
"dry-run wrote applied.json at {}",
applied.display()
);
let envs = tmp.path().join("state/envs");
assert!(
!envs.exists(),
"dry-run rendered env files at {}",
envs.display()
);
}
fn seed_two_projects(root: &std::path::Path) {
fs::write(
root.join("team-compose.yaml"),
r#"
version: 2
broker:
type: sqlite
path: state/mailbox.db
supervisor:
type: tmux
tmux_prefix: a-
projects:
- file: projects/alpha.yaml
- file: projects/beta.yaml
"#,
)
.unwrap();
fs::create_dir_all(root.join("projects")).unwrap();
fs::write(
root.join("projects/alpha.yaml"),
r#"
version: 2
project:
id: alpha
name: Alpha
cwd: .
managers:
manager:
runtime: claude-code
model: claude-opus-4-7
can_dm: [dev]
workers:
dev:
runtime: claude-code
model: claude-sonnet-4-6
reports_to: manager
can_dm: [manager]
"#,
)
.unwrap();
fs::write(
root.join("projects/beta.yaml"),
r#"
version: 2
project:
id: beta
name: Beta
cwd: .
managers:
manager:
runtime: claude-code
model: claude-opus-4-7
can_dm: [dev]
workers:
dev:
runtime: claude-code
model: claude-sonnet-4-6
reports_to: manager
can_dm: [manager]
"#,
)
.unwrap();
}
#[test]
fn reload_dry_run_with_unknown_project_lists_known_and_exits_nonzero() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"reload",
"--dry-run",
"ghost",
])
.output()
.unwrap();
assert!(!out.status.success(), "expected nonzero exit");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("ghost"), "names rejected input: {stderr}");
assert!(stderr.contains("alpha"), "lists alpha: {stderr}");
assert!(stderr.contains("beta"), "lists beta: {stderr}");
}
#[test]
fn reload_dry_run_scoped_to_project_lists_only_that_project() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"reload",
"--dry-run",
"alpha",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("alpha:manager"), "got: {stdout}");
assert!(stdout.contains("alpha:dev"), "got: {stdout}");
assert!(
!stdout.contains("beta:manager"),
"scoped plan must omit beta: {stdout}"
);
assert!(
!stdout.contains("beta:dev"),
"scoped plan must omit beta: {stdout}"
);
}
#[test]
fn reload_dry_run_resolves_project_by_filename_stem() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"reload",
"--dry-run",
"beta",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("beta:manager"), "got: {stdout}");
assert!(!stdout.contains("alpha:"), "got: {stdout}");
}
#[test]
fn reload_no_arg_unchanged_lists_every_project() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"reload",
"--dry-run",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("alpha:manager"), "got: {stdout}");
assert!(stdout.contains("beta:manager"), "got: {stdout}");
}
fn run_validate_with_env(
cwd: &std::path::Path,
home: &std::path::Path,
extra_env: &[(&str, &str)],
explicit_root: Option<&std::path::Path>,
) -> String {
let mut cmd = Command::new(bin());
cmd.env_clear()
.env("HOME", home)
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.current_dir(cwd);
for (k, v) in extra_env {
cmd.env(k, v);
}
if let Some(r) = explicit_root {
cmd.args(["--root", r.to_str().unwrap(), "validate"]);
} else {
cmd.arg("validate");
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"validate exited non-zero: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stderr).unwrap()
}
fn seed_dot_team(dir: &std::path::Path) -> std::path::PathBuf {
let root = dir.join(".team");
fs::create_dir_all(&root).unwrap();
seed_compose(&root);
root
}
fn strip_ansi(s: &str) -> String {
let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
re.replace_all(s, "").to_string()
}
#[test]
fn warn_a_walk_up_silent() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let root = seed_dot_team(tmp.path());
let _ = root; let stderr = run_validate_with_env(tmp.path(), home.path(), &[], None);
let clean = strip_ansi(&stderr);
assert!(
!clean.contains("warning:"),
"walk-up must not warn; stderr was: {clean}"
);
}
#[test]
fn warn_b_env_root_warns() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let root = seed_dot_team(tmp.path());
let stderr = run_validate_with_env(
tmp.path(),
home.path(),
&[("TEAMCTL_ROOT", root.to_str().unwrap())],
None,
);
let clean = strip_ansi(&stderr);
assert!(
clean.contains("warning:") && clean.contains("TEAMCTL_ROOT"),
"expected env warning; stderr was: {clean}"
);
}
#[test]
fn warn_b_empty_env_root_treated_as_unset() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let _ = seed_dot_team(tmp.path());
let stderr = run_validate_with_env(tmp.path(), home.path(), &[("TEAMCTL_ROOT", "")], None);
let clean = strip_ansi(&stderr);
assert!(
!clean.contains("warning:"),
"empty TEAMCTL_ROOT must fall through silently to walk-up; stderr was: {clean}"
);
}
#[test]
fn warn_c_explicit_root_silent() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let root = seed_dot_team(tmp.path());
let stderr = run_validate_with_env(
tmp.path(),
home.path(),
&[("TEAMCTL_ROOT", "/definitely/not/this")],
Some(&root),
);
let clean = strip_ansi(&stderr);
assert!(
!clean.contains("warning:"),
"--root must not warn; stderr was: {clean}"
);
}
#[test]
fn warn_d_registered_context_no_longer_resolves_root() {
let tmp = tempdir().unwrap();
let unrelated_cwd = tempdir().unwrap();
let home = tempdir().unwrap();
let root = seed_dot_team(tmp.path());
let cfg_dir = home.path().join(".config/teamctl");
fs::create_dir_all(&cfg_dir).unwrap();
let store = format!(
r#"{{"current":"demo","contexts":{{"demo":"{}"}}}}"#,
root.display()
);
fs::write(cfg_dir.join("contexts.json"), store).unwrap();
let mut cmd = Command::new(bin());
cmd.env_clear()
.env("HOME", home.path())
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.current_dir(unrelated_cwd.path())
.arg("validate");
let out = cmd.output().unwrap();
assert!(
!out.status.success(),
"validate must fail when no `.team/` is reachable from cwd"
);
let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr));
assert!(
stderr.contains("no `.team/team-compose.yaml`"),
"expected no-team error, not a context fallback; stderr was: {stderr}"
);
}
#[test]
fn context_subcommand_emits_deprecation_warning() {
let home = tempdir().unwrap();
let mut cmd = Command::new(bin());
cmd.env_clear()
.env("HOME", home.path())
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.args(["context", "ls"]);
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"context ls must still succeed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr));
assert!(
stderr.contains("`teamctl context` is deprecated"),
"expected deprecation warning; stderr was: {stderr}"
);
}
#[test]
fn init_with_name_creates_team_folder_that_validates() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let init = Command::new(bin())
.env_clear()
.env("HOME", home.path())
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.current_dir(tmp.path())
.args(["init", "my-team", "--yes"])
.output()
.unwrap();
assert!(
init.status.success(),
"init failed: stderr={}",
String::from_utf8_lossy(&init.stderr)
);
let team_dir = tmp.path().join("my-team/.team");
for f in [
"team-compose.yaml",
"projects/main.yaml",
"roles/manager.md",
"roles/dev.md",
".env.example",
".gitignore",
"README.md",
] {
assert!(team_dir.join(f).is_file(), "missing scaffolded file: {f}");
}
let validate = Command::new(bin())
.env_clear()
.env("HOME", home.path())
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.args(["--root", team_dir.to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
validate.status.success(),
"validate failed: stderr={}",
String::from_utf8_lossy(&validate.stderr)
);
let stdout = String::from_utf8_lossy(&validate.stdout);
assert!(
stdout.contains("ok") && stdout.contains("2 agents"),
"unexpected validate output: {stdout}"
);
}
#[test]
fn init_refuses_existing_team_without_force() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let run_init = |extra: &[&str]| -> std::process::Output {
let mut args = vec!["init", "my-team", "--yes"];
args.extend(extra);
Command::new(bin())
.env_clear()
.env("HOME", home.path())
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.current_dir(tmp.path())
.args(args)
.output()
.unwrap()
};
let first = run_init(&[]);
assert!(first.status.success(), "first init must succeed");
let second = run_init(&[]);
assert!(
!second.status.success(),
"second init without --force must refuse"
);
let stderr = String::from_utf8_lossy(&second.stderr);
assert!(
stderr.contains("already exists") && stderr.contains("--force"),
"expected refusal hint in stderr, got: {stderr}"
);
let third = run_init(&["--force"]);
assert!(
third.status.success(),
"init --force must overwrite: stderr={}",
String::from_utf8_lossy(&third.stderr)
);
}
#[test]
fn ui_with_no_prompt_and_no_binary_prints_install_hint_and_exits_zero() {
let empty = tempdir().unwrap();
let out = Command::new(bin())
.env("PATH", empty.path())
.args(["ui", "--no-prompt"])
.output()
.unwrap();
assert!(
out.status.success(),
"ui --no-prompt must exit 0 even when teamctl-ui is missing; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("teamctl-ui is not installed"),
"expected install hint on stderr, got: {stderr}"
);
assert!(
stderr.contains("cargo install teamctl-ui"),
"expected install command in hint, got: {stderr}"
);
}
#[test]
fn warn_e_quiet_silences_env() {
let tmp = tempdir().unwrap();
let home = tempdir().unwrap();
let root = seed_dot_team(tmp.path());
let stderr = run_validate_with_env(
tmp.path(),
home.path(),
&[
("TEAMCTL_ROOT", root.to_str().unwrap()),
("TEAMCTL_QUIET", "1"),
],
None,
);
let clean = strip_ansi(&stderr);
assert!(
!clean.contains("warning:"),
"TEAMCTL_QUIET=1 must silence; stderr was: {clean}"
);
}