use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use weftos::init::Host;
use weftos::init::apply::{apply, format_plan};
use weftos::init::plan::{Action, AgentInitOptions, plan};
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, text).unwrap();
}
fn fixture_agents(dir: &Path) {
write(
dir,
"stew/weftos-package.yaml",
"name: stew\nkind: specialist\nrequired_project_context:\n - key: board.cmd\n description: board command\n - key: goal\n required: false\n description: goal object\n",
);
write(
dir,
"stew/AGENT.md",
"---\nname: stew\nnickname: Stew\ndescription: >\n Writes the board. Carefully.\n Use when: filing work.\ntools: [Read, Grep, Bash]\nmodel_hint: default\n---\n\n# Stew\n\nBody text.\n",
);
write(
dir,
"stew/skills/board/SKILL.md",
"---\nname: board\ndescription: Board skill.\n---\n\n# Board\n",
);
write(dir, "stew/skills/board/references/notes.md", "notes\n");
write(dir, "stew/evals/scenarios.md", "not rendered\n");
write(dir, "rev/weftos-package.yaml", "name: rev\nkind: lane\n");
write(
dir,
"rev/AGENT.md",
"---\nname: rev\ndescription: \"Reviews: read-only.\"\ntools: Read, Grep\n---\nReview body.\n",
);
write(
dir,
"rev/skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill.\n---\nR\n",
);
write(
dir,
"lead/weftos-package.yaml",
"name: lead\nkind: skill\nrequired_project_context:\n - key: handoff_path\n description: handoff record\n",
);
write(
dir,
"lead/SKILL.md",
"---\nname: lead\ndescription: Lead doctrine. Not spawnable.\n---\n# Lead\n",
);
write(
dir,
"tmpl/weftos-package.yaml",
"name: tmpl\nkind: template\n",
);
write(
dir,
"tmpl/AGENT.md",
"---\nname: tmpl\ndescription: template\n---\nT\n",
);
write(dir, "weftos/weftos-package.yaml", "name: legacy\n");
write(
dir,
"weftos/AGENT.md",
"---\nname: legacy\ndescription: legacy\n---\nL\n",
);
write(
dir,
"teams/core/team.yaml",
"name: core\nlead: lead\nmembers:\n - agent: stew\n - rev\n - ghost\n - agent: tmpl\n active: false\n",
);
}
struct Env {
_tmp: tempfile::TempDir,
agents: PathBuf,
project: PathBuf,
}
fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let agents = tmp.path().join("agents");
let project = tmp.path().join("project");
fixture_agents(&agents);
fs::create_dir_all(&project).unwrap();
Env {
_tmp: tmp,
agents,
project,
}
}
fn opts(e: &Env, apply: bool) -> AgentInitOptions {
AgentInitOptions {
root: e.project.clone(),
hosts: vec![Host::Claude, Host::Grok, Host::Codex],
apply,
from: Some(e.agents.clone()),
team: Some("core".into()),
..Default::default()
}
}
fn run(o: &AgentInitOptions) -> weftos::init::Plan {
let p = plan(o).unwrap();
if o.apply {
apply(&p).unwrap();
}
p
}
fn read(root: &Path, rel: &str) -> String {
fs::read_to_string(root.join(rel)).unwrap_or_else(|_| panic!("missing {rel}"))
}
#[test]
fn renders_expected_file_set_for_all_hosts() {
let e = env();
let p = run(&opts(&e, true));
assert_eq!(p.selected, vec!["lead", "rev", "stew"]);
assert!(
p.warnings.iter().any(|w| w.contains("ghost")),
"{:?}",
p.warnings
);
let mut paths: Vec<&str> = p.items.iter().map(|i| i.path.as_str()).collect();
paths.sort();
let expected = [
".agents/project-context.md",
".agents/skills/board/SKILL.md",
".agents/skills/board/references/notes.md",
".agents/skills/lead/SKILL.md",
".agents/skills/review/SKILL.md",
".claude/agents/rev.md",
".claude/agents/stew.md",
".claude/skills/board/SKILL.md",
".claude/skills/board/references/notes.md",
".claude/skills/lead/SKILL.md",
".claude/skills/review/SKILL.md",
".codex/agents/rev.toml",
".codex/agents/stew.toml",
".grok/agents/rev.md",
".grok/agents/stew.md",
".grok/skills/board/SKILL.md",
".grok/skills/board/references/notes.md",
".grok/skills/lead/SKILL.md",
".grok/skills/review/SKILL.md",
"AGENTS.md",
];
assert_eq!(paths, expected);
for rel in expected {
assert!(e.project.join(rel).is_file(), "{rel} not written");
}
assert!(!e.project.join(".claude/agents/lead.md").exists());
assert!(!e.project.join(".claude/agents/tmpl.md").exists());
assert!(!e.project.join(".claude/agents/legacy.md").exists());
assert!(e.project.join(".weftos/agents.lock.json").is_file());
}
#[test]
fn host_frontmatter_matches_each_host() {
let e = env();
run(&opts(&e, true));
let claude = read(&e.project, ".claude/agents/stew.md");
assert!(claude.starts_with(
"---\nname: stew\ndescription: \"Writes the board. Carefully. Use when: filing work.\"\n"
));
assert!(claude.contains("\ntools: Read, Grep, Bash\n"));
assert!(claude.contains("\nskills: board\n"));
assert!(claude.contains("\nmodel: inherit\n---\n\n# Stew\n\nBody text.\n"));
assert!(!claude.contains("model_hint"));
let grok = read(&e.project, ".grok/agents/stew.md");
assert!(grok.starts_with("---\nname: stew\ndescription: "));
assert!(grok.contains("\nprompt_mode: full\nagents_md: true\n---\n"));
assert!(!grok.contains("\ntools:"));
assert!(grok.ends_with("# Stew\n\nBody text.\n"));
let codex: toml::Value = toml::from_str(&read(&e.project, ".codex/agents/stew.toml")).unwrap();
assert_eq!(codex["name"].as_str(), Some("stew"));
assert!(
codex["description"]
.as_str()
.unwrap()
.starts_with("Writes the board.")
);
let instr = codex["developer_instructions"].as_str().unwrap();
assert!(instr.contains("`board`") && instr.ends_with("# Stew\n\nBody text.\n"));
assert_eq!(
read(&e.project, ".claude/skills/board/SKILL.md"),
read(&e.agents, "stew/skills/board/SKILL.md")
);
assert_eq!(
read(&e.project, ".agents/skills/lead/SKILL.md"),
read(&e.agents, "lead/SKILL.md")
);
let agents_md = read(&e.project, "AGENTS.md");
assert!(
agents_md
.contains("- **stew** (Stew): Writes the board. Agent: `.codex/agents/stew.toml`.")
);
assert!(agents_md.contains("- **lead** (skill, not a spawnable agent): Lead doctrine."));
let ctx = read(&e.project, ".agents/project-context.md");
assert!(
ctx.contains("`board.cmd`: TODO")
&& ctx.contains("`goal` (optional)")
&& ctx.contains("`handoff_path`")
);
}
#[test]
fn second_apply_is_a_noop() {
let e = env();
run(&opts(&e, true));
let lock_before = read(&e.project, ".weftos/agents.lock.json");
let p = run(&opts(&e, true));
assert!(p.is_noop(), "{}", format_plan(&p, true));
assert_eq!(p.count(Action::Unchanged), 19);
assert_eq!(p.count(Action::Keep), 1);
assert_eq!(read(&e.project, ".weftos/agents.lock.json"), lock_before);
assert!(format_plan(&p, true).contains("No changes"));
}
#[test]
fn agents_md_block_preserves_user_content() {
let e = env();
write(&e.project, "AGENTS.md", "# House rules\n\nBe kind.\n");
run(&opts(&e, true));
let text = read(&e.project, "AGENTS.md");
assert!(text.starts_with("# House rules\n\nBe kind.\n\n<!-- weftos:agents:begin -->"));
fs::write(
e.project.join("AGENTS.md"),
format!("{text}\nMore notes.\n"),
)
.unwrap();
assert!(plan(&opts(&e, false)).unwrap().is_noop());
}
#[test]
fn drift_is_reported_and_kept_unless_forced() {
let e = env();
run(&opts(&e, true));
let rel = ".claude/agents/stew.md";
fs::write(e.project.join(rel), "local edit\n").unwrap();
fs::remove_file(e.project.join(".weftos/agents.lock.json")).unwrap();
let p = plan(&opts(&e, false)).unwrap();
assert!(
p.items
.iter()
.any(|i| i.path == rel && i.action == Action::Unmanaged)
);
run(&opts(&e, true)); assert_eq!(read(&e.project, rel), "local edit\n");
let mut forced = opts(&e, true);
forced.force = true;
run(&forced);
fs::write(e.project.join(rel), "edited again\n").unwrap();
let p = run(&opts(&e, true));
let item = p.items.iter().find(|i| i.path == rel).unwrap();
assert_eq!(item.action, Action::Drifted);
assert_eq!(read(&e.project, rel), "edited again\n");
assert!(format_plan(&p, true).contains("--force"));
let p = plan(&opts(&e, false)).unwrap();
assert_eq!(
p.items.iter().find(|i| i.path == rel).unwrap().action,
Action::Drifted
);
let p = run(&forced);
assert_eq!(
p.items.iter().find(|i| i.path == rel).unwrap().action,
Action::Overwrite
);
assert!(read(&e.project, rel).starts_with("---\nname: stew\n"));
assert!(plan(&opts(&e, false)).unwrap().is_noop());
}
#[test]
fn source_update_and_removal_are_applied() {
let e = env();
run(&opts(&e, true));
write(
&e.agents,
"stew/AGENT.md",
"---\nname: stew\ndescription: New.\ntools: [Read]\n---\nNew body.\n",
);
fs::remove_file(e.agents.join("stew/skills/board/references/notes.md")).unwrap();
let p = run(&opts(&e, true));
let action = |path: &str| p.items.iter().find(|i| i.path == path).map(|i| i.action);
assert_eq!(action(".claude/agents/stew.md"), Some(Action::Update));
assert_eq!(
action(".grok/skills/board/references/notes.md"),
Some(Action::Delete)
);
assert!(
!e.project.join(".grok/skills/board/references").exists(),
"empty dir pruned"
);
assert!(read(&e.project, ".codex/agents/stew.toml").contains("New body."));
assert!(plan(&opts(&e, false)).unwrap().is_noop());
}
#[test]
fn single_agent_install_is_cumulative_and_plan_writes_nothing() {
let e = env();
let mut o = opts(&e, false);
o.team = None;
o.agents = vec!["rev".into()];
o.hosts = vec![Host::Claude];
let p = run(&o);
assert!(!p.is_noop());
assert!(
fs::read_dir(&e.project).unwrap().next().is_none(),
"--plan wrote files"
);
o.apply = true;
run(&o);
o.agents = vec!["stew".into()];
let p = run(&o);
assert_eq!(p.selected, vec!["rev", "stew"]);
assert!(e.project.join(".claude/agents/rev.md").is_file());
assert!(!e.project.join(".grok").exists());
o.agents = vec!["nope".into()];
assert!(plan(&o).is_err());
}
#[test]
fn missing_team_falls_back_to_all_non_template_packages() {
let e = env();
let mut o = opts(&e, false);
o.team = Some("absent".into());
let p = plan(&o).unwrap();
assert_eq!(p.selected, vec!["lead", "rev", "stew"]);
assert!(p.warnings.iter().any(|w| w.contains("not found")));
}
#[test]
fn cli_plan_then_apply_then_noop() {
let e = env();
let bin = env!("CARGO_BIN_EXE_weftos");
let cli = |extra: &[&str]| {
let out = Command::new(bin)
.arg("init")
.arg(&e.project)
.args(["--claude", "--grok", "--codex", "--team", "core", "--from"])
.arg(&e.agents)
.args(extra)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
};
assert!(cli(&["--plan"]).contains("Plan only."));
assert!(!e.project.join(".claude").exists());
assert!(cli(&["--apply"]).contains("19 create"));
assert!(cli(&["--apply"]).contains("No changes"));
let out = Command::new(bin)
.arg("init")
.arg(&e.project)
.arg("--apply")
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(2),
"--apply without a host flag must fail"
);
}