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-8
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 agent sessions"), "got: {stdout}");
}
#[test]
fn validate_accepts_cascading_role_prompt_list_form() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("team-compose.yaml"),
r#"
version: 2
broker:
type: sqlite
path: state/mailbox.db
supervisor:
type: tmux
tmux_prefix: cascade-
projects:
- file: projects/cascade.yaml
"#,
)
.unwrap();
fs::create_dir_all(tmp.path().join("projects")).unwrap();
fs::write(
tmp.path().join("projects/cascade.yaml"),
r#"
version: 2
project:
id: cascade
name: Cascade
cwd: .
channels:
- name: all
members: "*"
managers:
pm:
runtime: claude-code
model: claude-opus-4-8
role_prompt:
- roles/_base.md
- roles/pm.md
can_dm: [eng]
can_broadcast: [all]
workers:
eng:
runtime: claude-code
model: claude-sonnet-4-6
reports_to: pm
role_prompt:
- roles/_base.md
- roles/_worker.md
- roles/eng.md
can_dm: [pm]
can_broadcast: [all]
"#,
)
.unwrap();
let out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
out.status.success(),
"validate should accept cascade list-form role_prompt; 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 agent sessions"), "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 stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("teamctl attach"),
"agentless blank must not suggest attach; got:\n{stdout}"
);
assert!(
!stdout.contains("teamctl bot setup"),
"agentless blank must not suggest bot setup; got:\n{stdout}"
);
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_essentials_template_scaffolds_two_project_tree() {
let tmp = tempdir().unwrap();
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "starter", "--template", "essentials", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"init essentials stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let team_dir = tmp.path().join("starter/.team");
for relpath in [
"team-compose.yaml",
"projects/main.yaml",
"projects/ops.yaml",
"roles/ops.md",
".env.example",
".gitignore",
"README.md",
] {
assert!(
team_dir.join(relpath).is_file(),
"essentials template must include {relpath}"
);
}
let validate = Command::new(bin())
.args(["--root", team_dir.to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
validate.status.success(),
"essentials template validate stderr: {}",
String::from_utf8_lossy(&validate.stderr)
);
}
#[test]
fn init_ideate_and_build_template_scaffolds_with_subagents() {
let tmp = tempdir().unwrap();
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "studio", "--template", "ideate-and-build", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"init ideate-and-build stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("teamctl attach main:executor"),
"Next: block must suggest attaching to the lead manager; got:\n{stdout}"
);
assert!(
stdout.contains("teamctl bot setup"),
"Next: block must offer bot setup for an agent-bearing template; got:\n{stdout}"
);
let team_dir = tmp.path().join("studio/.team");
for relpath in [
"team-compose.yaml",
"projects/main.yaml",
"charter.md",
"subagents/code-investigator.md",
"subagents/implementer.md",
"subagents/test-author.md",
"subagents/qa-tester.md",
"subagents/pr-narrator.md",
"subagents/code-roaster.md",
"subagents/memory-writer.md",
"subagents/product-researcher.md",
"subagents/feasibility-analyst.md",
"subagents/deep-research.md",
"subagents/learn.md",
"subagents/pr-summarizer.md",
"subagents/ideator.md",
"subagents/code-review.md",
"subagents/security-review.md",
] {
assert!(
team_dir.join(relpath).is_file(),
"ideate-and-build template must include {relpath}"
);
}
let validate = Command::new(bin())
.args(["--root", team_dir.to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
validate.status.success(),
"ideate-and-build template validate stderr: {}",
String::from_utf8_lossy(&validate.stderr)
);
}
#[test]
fn ideate_and_build_template_renders_per_agent_subagents() {
let template =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/templates/ideate-and-build");
let compose = team_core::compose::Compose::load(&template).unwrap();
let expect_names = |agent: &str, expected: &[&str]| {
let h = compose
.agents()
.find(|h| h.agent == agent)
.unwrap_or_else(|| panic!("agent `{agent}` not found in template compose"));
let json = team_core::render::render_subagents(&compose, h)
.unwrap()
.unwrap_or_else(|| panic!("agent `{agent}` rendered no sub-agents"));
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = v.as_object().expect("agents json is an object");
let mut got: Vec<&str> = obj.keys().map(String::as_str).collect();
got.sort_unstable();
let mut want = expected.to_vec();
want.sort_unstable();
assert_eq!(got, want, "sub-agent set for `{agent}`");
};
let engineer_stable = [
"code-investigator",
"implementer",
"test-author",
"qa-tester",
"pr-narrator",
"code-roaster",
"code-review",
"security-review",
];
expect_names("engineer_1", &engineer_stable);
expect_names("engineer_2", &engineer_stable);
expect_names(
"compass",
&[
"memory-writer",
"code-investigator",
"product-researcher",
"feasibility-analyst",
"deep-research",
"learn",
"ideator",
],
);
expect_names("executor", &["pr-summarizer"]);
}
#[test]
fn init_yes_without_template_defaults_to_essentials() {
let tmp = tempdir().unwrap();
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "starter", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"init --yes (no template) stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let team_dir = tmp.path().join("starter/.team");
assert!(
team_dir.join("projects/ops.yaml").is_file(),
"--yes default must land essentials (projects/ops.yaml missing)"
);
assert!(
team_dir.join("roles/ops.md").is_file(),
"--yes default must land essentials (roles/ops.md missing)"
);
}
#[test]
fn init_template_guided_with_yes_errors() {
let tmp = tempdir().unwrap();
let out = Command::new(bin())
.current_dir(tmp.path())
.args(["init", "starter", "--template", "guided", "--yes"])
.output()
.unwrap();
assert!(
!out.status.success(),
"`--template guided --yes` must exit non-zero; stdout: {} stderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("guided") && stderr.contains("interactive"),
"expected error to name `guided` + `interactive`; got: {stderr}"
);
assert!(
!tmp.path().join("starter/.team").exists(),
"rejected --template guided --yes must not create a .team/ tree"
);
}
#[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", "essentials", "--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/ops.md").exists(),
"prior essentials template's roles/ops.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-8
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-8
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",
"projects/ops.yaml",
"roles/ops.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 projects") && stdout.contains("1 agent"),
"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 adjust_yes_flag_errors_clearly_with_actionable_message() {
let empty = tempdir().unwrap();
let out = Command::new(bin())
.env("PATH", empty.path())
.args(["adjust", "--yes"])
.output()
.unwrap();
assert!(
!out.status.success(),
"adjust --yes must exit non-zero; stderr: {}",
String::from_utf8_lossy(&out.stderr),
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("interactive-only"),
"expected `interactive-only` in stderr, got: {stderr}"
);
assert!(
stderr.contains("--yes"),
"expected `--yes` in stderr, got: {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}"
);
}
#[test]
fn reload_dry_run_scoped_to_agent_forces_only_that_agent() {
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",
"dev",
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("reloaded · alpha:dev (forced) (dry run)"),
"force line for the scoped agent: {stdout}"
);
assert!(
!stdout.contains("alpha:manager"),
"must not touch the unscoped sibling: {stdout}"
);
assert!(
!stdout.contains("beta:"),
"must not touch other projects: {stdout}"
);
}
#[test]
fn reload_dry_run_except_agent_forces_the_complement() {
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",
"--except",
"dev",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("reloaded · alpha:manager (forced) (dry run)"),
"complement is forced: {stdout}"
);
assert!(
!stdout.contains("alpha:dev"),
"the excepted agent is left alone: {stdout}"
);
assert!(
!stdout.contains("beta:"),
"other projects untouched: {stdout}"
);
}
#[test]
fn reload_project_only_keeps_the_unchanged_diff_path() {
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());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("added · alpha:manager"), "got: {stdout}");
assert!(
!stdout.contains("(forced)"),
"project-only must not force-restart: {stdout}"
);
}
#[test]
fn scoped_unknown_agent_errors_listing_valid_names() {
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",
"ghost",
])
.output()
.unwrap();
assert!(!out.status.success(), "unknown agent must exit nonzero");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("ghost"), "names the bad input: {stderr}");
assert!(stderr.contains("manager"), "lists valid agents: {stderr}");
assert!(stderr.contains("dev"), "lists valid agents: {stderr}");
}
#[test]
fn except_unknown_agent_also_errors() {
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",
"--except",
"ghost",
])
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("ghost"), "names the bad input: {stderr}");
}
#[test]
fn positional_list_and_except_are_mutually_exclusive() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"down",
"alpha",
"dev",
"--except",
"manager",
])
.output()
.unwrap();
assert!(
!out.status.success(),
"supplying both forms must be a usage error"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("cannot be used with"),
"clap conflict message: {stderr}"
);
}
#[test]
fn except_requires_a_project() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args([
"--root",
tmp.path().to_str().unwrap(),
"down",
"--except",
"dev",
])
.output()
.unwrap();
assert!(
!out.status.success(),
"--except without a project is invalid"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("PROJECT"),
"clap names the missing required arg: {stderr}"
);
}
#[test]
fn except_every_agent_reports_empty_scope() {
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",
"--except",
"manager",
"--except",
"dev",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("no agents in scope for project alpha"),
"got: {stdout}"
);
}
#[test]
fn help_documents_all_scope_levels_consistently() {
for cmd in ["up", "down", "reload"] {
let out = Command::new(bin()).args([cmd, "--help"]).output().unwrap();
assert!(out.status.success(), "{cmd} --help failed");
let help = String::from_utf8(out.stdout).unwrap();
assert!(
help.contains("every project"),
"{cmd} --help missing all-projects form: {help}"
);
assert!(
help.contains("every agent in it"),
"{cmd} --help missing project-only form: {help}"
);
assert!(
help.contains("just those agents"),
"{cmd} --help missing positional-list form: {help}"
);
assert!(
help.contains("--except"),
"{cmd} --help missing --except form: {help}"
);
}
}
fn seed_compose_with_evil_project_id(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/evil.yaml
"#,
)
.unwrap();
fs::create_dir_all(root.join("projects")).unwrap();
fs::write(
root.join("projects/evil.yaml"),
r#"
version: 2
project:
id: "evil; touch /tmp/teamctl-t310-pwn"
name: Evil
cwd: .
managers:
manager:
runtime: claude-code
model: claude-opus-4-8
workers:
dev:
runtime: claude-code
model: claude-sonnet-4-6
reports_to: manager
"#,
)
.unwrap();
}
#[test]
fn validate_rejects_project_id_with_shell_metacharacters() {
let tmp = tempdir().unwrap();
seed_compose_with_evil_project_id(tmp.path());
let out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(!out.status.success(), "must exit nonzero");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("evil; touch /tmp/teamctl-t310-pwn"),
"names the rejected id: {stderr}"
);
assert!(
stderr.contains("disallowed characters"),
"explains the rule: {stderr}"
);
}
#[test]
fn down_bails_on_evil_project_id_before_reaching_supervisor() {
let pwn_marker = std::path::PathBuf::from("/tmp/teamctl-t310-pwn");
let _ = std::fs::remove_file(&pwn_marker);
let tmp = tempdir().unwrap();
seed_compose_with_evil_project_id(tmp.path());
let out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "down"])
.output()
.unwrap();
assert!(!out.status.success(), "down must bail on validation error");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("disallowed characters"),
"down must surface the id-charset error: {stderr}"
);
assert!(
!pwn_marker.exists(),
"INJECTION FIRED — `build_up_command` was reached: marker {} exists",
pwn_marker.display()
);
let _ = std::fs::remove_file(&pwn_marker);
}
#[test]
fn validate_accepts_existing_conformant_id_shapes() {
let tmp = tempdir().unwrap();
seed_two_projects(tmp.path());
let out = Command::new(bin())
.args(["--root", tmp.path().to_str().unwrap(), "validate"])
.output()
.unwrap();
assert!(
out.status.success(),
"conformant ids must validate: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}