use std::path::Path;
use super::*;
fn ctx(root: &Path, mode: Mode) -> Ctx {
Ctx {
root: root.to_path_buf(),
home: None,
user: false,
dry_run: false,
force: false,
approve: false,
gitignore_claude: false,
mode,
}
}
#[test]
fn nothing_detected_selects_no_harness() {
let dir = tempfile::tempdir().unwrap();
let found = hosts::detect_with(dir.path(), None, |_| false);
assert!(found.is_empty());
}
#[test]
fn detection_reads_project_home_and_session_signals() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".cursor")).unwrap();
std::fs::create_dir_all(home.path().join(".codex")).unwrap();
let found = hosts::detect_with(dir.path(), Some(home.path()), |key| key == "CLAUDECODE");
let names: Vec<Harness> = found.iter().map(|d| d.harness).collect();
assert_eq!(
names,
vec![Harness::Claude, Harness::Codex, Harness::Cursor]
);
assert_eq!(found[0].evidence, vec!["$CLAUDECODE".to_string()]);
assert_eq!(found[1].evidence, vec!["~/.codex".to_string()]);
assert_eq!(found[2].evidence, vec![".cursor".to_string()]);
}
#[test]
fn guide_scaffolds_agents_md_and_claude_import_then_stays_stable() {
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), Mode::Install);
let steps = guide::install(&ctx, &[Harness::Claude]);
assert!(
steps.iter().all(|s| s.status == StepStatus::Written),
"{steps:?}"
);
let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
assert!(agents.starts_with("<!-- fallow:agent-install v1 authored sha256="));
assert!(agents.contains("## Fallow task map"));
let claude = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(claude.contains("@AGENTS.md"));
let again = guide::install(&ctx, &[Harness::Claude]);
assert!(
again.iter().all(|s| s.status == StepStatus::Unchanged),
"{again:?}"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(),
agents
);
assert_eq!(
std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(),
claude
);
}
#[test]
fn guide_uninstall_deletes_authored_files_but_keeps_edited_ones() {
let dir = tempfile::tempdir().unwrap();
let install = ctx(dir.path(), Mode::Install);
guide::install(&install, &[Harness::Claude]);
let agents_path = dir.path().join("AGENTS.md");
let mut edited = std::fs::read_to_string(&agents_path).unwrap();
edited.push_str("\n## Team notes\n\nKeep me.\n");
std::fs::write(&agents_path, edited).unwrap();
let uninstall = ctx(dir.path(), Mode::Uninstall);
let steps = guide::uninstall(&uninstall, &[Harness::Claude]);
assert!(!dir.path().join("CLAUDE.md").exists(), "{steps:?}");
let kept = std::fs::read_to_string(&agents_path).unwrap();
assert!(kept.contains("Keep me."));
assert!(!kept.contains("## Fallow task map"));
}
#[test]
fn guide_uninstall_strips_import_from_an_edited_authored_claude_md() {
let dir = tempfile::tempdir().unwrap();
let install = ctx(dir.path(), Mode::Install);
guide::install(&install, &[Harness::Claude]);
let path = dir.path().join("CLAUDE.md");
let mut edited = std::fs::read_to_string(&path).unwrap();
edited.push_str("\n## Mine\n\nKeep me.\n");
std::fs::write(&path, edited).unwrap();
let uninstall = ctx(dir.path(), Mode::Uninstall);
let steps = guide::uninstall(&uninstall, &[Harness::Claude]);
let claude = steps
.iter()
.find(|s| s.path.as_deref() == Some("CLAUDE.md"))
.unwrap();
assert_eq!(claude.status, StepStatus::Removed, "{steps:?}");
let kept = std::fs::read_to_string(&path).unwrap();
assert!(kept.contains("Keep me."));
assert!(!kept.contains("@AGENTS.md"), "{kept}");
assert!(!kept.contains("claude-import"), "{kept}");
}
#[test]
fn guide_appends_import_block_to_existing_claude_md_and_removes_it_again() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "# Mine\n\nRules.\n").unwrap();
let install = ctx(dir.path(), Mode::Install);
guide::install(&install, &[Harness::Claude]);
let text = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(text.starts_with("# Mine\n"));
assert!(text.contains("claude-import:start"));
assert!(text.contains("@AGENTS.md"));
let uninstall = ctx(dir.path(), Mode::Uninstall);
guide::uninstall(&uninstall, &[Harness::Claude]);
let text = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert_eq!(text, "# Mine\n\nRules.\n");
}
#[test]
fn skill_refuses_foreign_skill_without_force() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join(".agents").join("skills").join("fallow");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: fallow\n---\nmaintainer skill\n",
)
.unwrap();
let ctx = ctx(dir.path(), Mode::Install);
let steps = skill::install(&ctx, &[Harness::Codex]);
assert_eq!(steps.len(), skill::RELEASED_SKILLS.len(), "{steps:?}");
let refused = steps
.iter()
.find(|s| s.path.as_deref() == Some(".agents/skills/fallow"))
.unwrap();
assert_eq!(refused.status, StepStatus::Refused, "{steps:?}");
assert_eq!(refused.reason, Some(Reason::SkillNameTaken));
assert!(
steps
.iter()
.filter(|s| s.path.as_deref() != Some(".agents/skills/fallow"))
.all(|s| s.status != StepStatus::Refused),
"a foreign `fallow` skill must not block the other skills: {steps:?}"
);
assert_eq!(
std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap(),
"---\nname: fallow\n---\nmaintainer skill\n"
);
}
fn ship_skill(root: &Path, name: &str) {
let shipped = root.join("node_modules/fallow/skills").join(name);
std::fs::create_dir_all(shipped.join("agents")).unwrap();
std::fs::write(
shipped.join("SKILL.md"),
format!("---\nname: {name}\ndescription: Shipped description.\nlicense: MIT\n---\n\n# Full skill\n"),
)
.unwrap();
std::fs::write(
shipped.join("agents/openai.yaml"),
"interface:\n display_name: \"Fallow\"\n",
)
.unwrap();
}
#[test]
fn released_skills_are_fallow_and_fallow_setup() {
let names: Vec<&str> = skill::RELEASED_SKILLS.iter().map(|s| s.name).collect();
assert_eq!(names, ["fallow", "fallow-setup"]);
}
#[test]
fn skill_writes_stub_when_node_modules_ships_the_skill() {
let dir = tempfile::tempdir().unwrap();
for released in skill::RELEASED_SKILLS {
ship_skill(dir.path(), released.name);
}
let ctx = ctx(dir.path(), Mode::Install);
let steps = skill::install(&ctx, &[Harness::Claude, Harness::Cursor]);
assert_eq!(steps.len(), 2 * skill::RELEASED_SKILLS.len(), "{steps:?}");
assert!(
steps.iter().all(|s| s.status == StepStatus::Written),
"{steps:?}"
);
for base in [".agents", ".claude"] {
for name in ["fallow", "fallow-setup"] {
let skill_dir = dir.path().join(base).join("skills").join(name);
let text = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert!(text.starts_with(&format!(
"---\nname: {name}\ndescription: Shipped description.\n"
)));
assert!(text.contains("skill=stub version="));
assert!(text.contains(&format!("node_modules/fallow/skills/{name}/SKILL.md")));
assert_eq!(
text.contains("## Fallow task map"),
name == "fallow",
"{text}"
);
assert!(skill_dir.join("agents/openai.yaml").is_file());
}
}
let again = skill::install(&ctx, &[Harness::Claude, Harness::Cursor]);
assert!(
again.iter().all(|s| s.status == StepStatus::Unchanged),
"{again:?}"
);
let uninstall = Ctx {
mode: Mode::Uninstall,
..ctx
};
let removed = skill::uninstall(&uninstall, &[Harness::Claude, Harness::Cursor]);
assert!(
removed.iter().all(|s| s.status == StepStatus::Removed),
"{removed:?}"
);
assert!(!dir.path().join(".claude/skills").exists());
assert!(!dir.path().join(".agents/skills").exists());
}
#[test]
fn skill_embedded_copy_round_trips_when_available() {
if skill::EMBEDDED_SKILLS.is_empty() {
return;
}
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), Mode::Install);
let steps = skill::install(&ctx, &[Harness::Codex]);
assert!(
steps.iter().all(|s| s.status == StepStatus::Written),
"{steps:?}"
);
for (name, reference) in [
("fallow", "references/cli-reference.md"),
("fallow-setup", "references/ci-gate.md"),
] {
let skill_dir = dir.path().join(".agents/skills").join(name);
let text = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert!(text.starts_with(&format!("---\nname: {name}\n")));
assert!(text.contains("skill=embedded version="));
assert!(skill_dir.join(reference).is_file());
assert_eq!(
skill::inspect(&skill_dir),
skill::SkillState::Managed {
flavor: skill::Flavor::Embedded,
version: env!("CARGO_PKG_VERSION").to_string()
}
);
}
}
#[test]
fn every_released_skill_is_embedded_when_the_tree_is_available() {
if skill::EMBEDDED_SKILLS.is_empty() {
return;
}
let embedded: Vec<&str> = skill::EMBEDDED_SKILLS.iter().map(|s| s.name).collect();
let released: Vec<&str> = skill::RELEASED_SKILLS.iter().map(|s| s.name).collect();
assert_eq!(embedded, released);
}
#[test]
fn skill_falls_back_to_embedded_copy_when_node_modules_lacks_a_skill() {
if skill::EMBEDDED_SKILLS.is_empty() {
return;
}
let dir = tempfile::tempdir().unwrap();
ship_skill(dir.path(), "fallow");
let ctx = ctx(dir.path(), Mode::Install);
let steps = skill::install(&ctx, &[Harness::Codex]);
assert!(
steps.iter().all(|s| s.status == StepStatus::Written),
"{steps:?}"
);
let base = dir.path().join(".agents/skills");
assert!(matches!(
skill::inspect(&base.join("fallow")),
skill::SkillState::Managed {
flavor: skill::Flavor::Stub,
..
}
));
assert!(matches!(
skill::inspect(&base.join("fallow-setup")),
skill::SkillState::Managed {
flavor: skill::Flavor::Embedded,
..
}
));
}
#[test]
fn skill_switch_from_embedded_to_stub_removes_old_references() {
if skill::EMBEDDED_SKILLS.is_empty() {
return;
}
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), Mode::Install);
skill::install(&ctx, &[Harness::Codex]);
let skill_dir = dir.path().join(".agents/skills/fallow-setup");
assert!(skill_dir.join("references/ci-gate.md").is_file());
ship_skill(dir.path(), "fallow-setup");
let steps = skill::install(&ctx, &[Harness::Codex]);
assert!(
steps.iter().all(|s| s.status != StepStatus::Failed),
"{steps:?}"
);
assert!(matches!(
skill::inspect(&skill_dir),
skill::SkillState::Managed {
flavor: skill::Flavor::Stub,
..
}
));
assert!(
!skill_dir.join("references").exists(),
"the pointer stub must not keep the references of the embedded copy"
);
}
#[test]
fn skill_embedded_copy_resolves_every_relative_link() {
if skill::EMBEDDED_SKILLS.is_empty() {
return;
}
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), Mode::Install);
let steps = skill::install(&ctx, &[Harness::Codex]);
assert!(
steps.iter().all(|s| s.status == StepStatus::Written),
"{steps:?}"
);
let mut broken = Vec::new();
for (embedded, file) in skill::EMBEDDED_SKILLS.iter().flat_map(|embedded| {
embedded
.files
.iter()
.filter(|f| Path::new(f.path).extension().is_some_and(|ext| ext == "md"))
.map(move |file| (embedded, file))
}) {
let skill_dir = dir.path().join(".agents/skills").join(embedded.name);
let path = skill_dir.join(file.path);
let text = std::fs::read_to_string(&path).unwrap();
for target in text
.split("](")
.skip(1)
.filter_map(|rest| rest.split(')').next())
{
let target = target.split('#').next().unwrap_or_default();
if target.is_empty() || target.contains("://") || target.starts_with("mailto:") {
continue;
}
if !path.parent().unwrap().join(target).is_file() {
broken.push(format!("{}/{} -> {target}", embedded.name, file.path));
}
}
}
assert!(
broken.is_empty(),
"embedded skill has broken links: {broken:?}"
);
}
#[test]
fn mcp_command_prefers_node_modules_then_path_then_self() {
let node = mcp::resolve_command_with(true, || None, || None).unwrap();
assert_eq!(node.command, "npx");
assert_eq!(node.args, vec!["--no", "fallow-mcp"]);
let path =
mcp::resolve_command_with(false, || Some("/usr/local/bin/fallow-mcp".into()), || None)
.unwrap();
assert_eq!(path.command, "fallow-mcp");
assert!(path.args.is_empty());
let own = mcp::resolve_command_with(false, || None, || Some("/opt/fallow".into())).unwrap();
assert_eq!(own.args, vec!["mcp-server"]);
assert!(mcp::resolve_command_with(false, || None, || None).is_none());
}
#[test]
fn mcp_json_merge_preserves_other_servers_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
std::fs::write(
&path,
"{\n \"mcpServers\": {\n \"other\": { \"command\": \"x\" }\n },\n \"custom\": 1\n}\n",
)
.unwrap();
let entry =
serde_json::json!({"type": "stdio", "command": "npx", "args": ["--no", "fallow-mcp"]});
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, false).unwrap(),
mcp::FileOutcome::Changed
);
let text = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["custom"], 1);
assert_eq!(value["mcpServers"]["other"]["command"], "x");
assert_eq!(value["mcpServers"]["fallow"]["args"][1], "fallow-mcp");
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, false).unwrap(),
mcp::FileOutcome::Unchanged
);
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", None, false, false).unwrap(),
mcp::FileOutcome::Changed
);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(value["mcpServers"].get("fallow").is_none());
assert_eq!(value["mcpServers"]["other"]["command"], "x");
}
#[test]
fn mcp_json_merge_refuses_invalid_json_without_force() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
std::fs::write(&path, "{ not json").unwrap();
let entry = serde_json::json!({"command": "fallow-mcp"});
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, false).unwrap(),
mcp::FileOutcome::InvalidPreserved
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
assert!(matches!(
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, true).unwrap(),
mcp::FileOutcome::ChangedAfterBackup(_)
));
assert_eq!(
std::fs::read_to_string(dir.path().join(".mcp.json.fallow-bak")).unwrap(),
"{ not json"
);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(value["mcpServers"]["fallow"]["command"], "fallow-mcp");
}
#[test]
fn mcp_foreign_entry_is_refused_unless_forced_and_never_removed_silently() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
std::fs::write(
&path,
"{\n \"mcpServers\": {\n \"fallow\": { \"command\": \"./scripts/mine.sh\", \"env\": { \"FALLOW_BIN\": \"/opt/fallow\" } }\n }\n}\n",
)
.unwrap();
let entry =
serde_json::json!({"type": "stdio", "command": "npx", "args": ["--no", "fallow-mcp"]});
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, false).unwrap(),
mcp::FileOutcome::Foreign
);
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", None, false, false).unwrap(),
mcp::FileOutcome::Foreign
);
assert!(std::fs::read_to_string(&path).unwrap().contains("mine.sh"));
assert_eq!(
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, true).unwrap(),
mcp::FileOutcome::Changed
);
let registered = mcp::registered_command(&path, Harness::Claude).unwrap();
assert!(mcp::registered_is_managed(®istered));
}
#[test]
fn mcp_uninstall_deletes_a_file_it_emptied_and_keeps_others() {
let dir = tempfile::tempdir().unwrap();
let cursor = dir.path().join(".cursor").join("mcp.json");
let entry = serde_json::json!({"command": "fallow-mcp", "args": []});
assert_eq!(
mcp::merge_json_server(&cursor, "mcpServers", Some(&entry), false, false).unwrap(),
mcp::FileOutcome::Changed
);
assert_eq!(
mcp::merge_json_server(&cursor, "mcpServers", None, false, false).unwrap(),
mcp::FileOutcome::Deleted
);
assert!(!dir.path().join(".cursor").exists());
assert!(!mcp::mcp_json_has_servers(&cursor));
}
#[test]
fn mcp_json_edit_keeps_the_existing_indent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
std::fs::write(
&path,
"{\n \"mcpServers\": {\n \"other\": {\n \"command\": \"x\"\n }\n }\n}",
)
.unwrap();
let entry = serde_json::json!({"command": "fallow-mcp", "args": []});
mcp::merge_json_server(&path, "mcpServers", Some(&entry), false, false).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.contains("\n \"mcpServers\": {\n \"other\""),
"{text}"
);
assert!(
text.contains("\n \"fallow\": {\n \"command\""),
"{text}"
);
assert!(!text.ends_with('\n'));
}
#[test]
fn mcp_command_shapes_are_recognized_as_fallow_owned() {
let owned = [
("npx", vec!["--no", "fallow-mcp"]),
("fallow-mcp", vec![]),
("/opt/homebrew/bin/fallow-mcp", vec![]),
("C:\\tools\\fallow-mcp.exe", vec![]),
(
"/Users/me/.vscode/extensions/fallow/bin/fallow",
vec!["mcp-server"],
),
];
for (command, args) in owned {
let candidate = mcp::McpCommand {
command: command.to_string(),
args: args.iter().map(|a| a.to_string()).collect(),
source: mcp::McpSource::Registered,
};
assert!(candidate.is_fallow_shape(), "{command} {args:?}");
}
let foreign = mcp::McpCommand {
command: "./scripts/my-wrapper.sh".to_string(),
args: Vec::new(),
source: mcp::McpSource::Registered,
};
assert!(!foreign.is_fallow_shape());
}
#[test]
fn every_reason_is_documented_in_backwards_compatibility() {
let doc = std::fs::read_to_string(
std::path::PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
)
.join("../../docs/backwards-compatibility.md"),
)
.unwrap();
for reason in Reason::ALL {
assert!(
doc.contains(&format!("`{}`", reason.as_str())),
"{} is missing from docs/backwards-compatibility.md",
reason.as_str()
);
assert_eq!(
serde_json::to_value(reason).unwrap(),
serde_json::Value::String(reason.as_str().to_string())
);
}
}
#[test]
fn codex_toml_merge_keeps_comments_and_other_tables() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"# my codex config\nmodel = \"gpt-5\"\n\n[mcp_servers.other]\ncommand = \"x\"\n",
)
.unwrap();
let command = mcp::McpCommand {
command: "npx".to_string(),
args: vec!["--no".to_string(), "fallow-mcp".to_string()],
source: mcp::McpSource::NodeModules,
};
assert_eq!(
mcp::merge_codex(&path, Some(&command), false, false).unwrap(),
mcp::FileOutcome::Changed
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("# my codex config\nmodel = \"gpt-5\"\n"));
assert!(text.contains("[mcp_servers.other]\ncommand = \"x\"\n"));
assert!(
text.contains(
"[mcp_servers.fallow]\ncommand = \"npx\"\nargs = [\"--no\", \"fallow-mcp\"]\n"
)
);
assert!(!text.contains("\n[mcp_servers]\n"));
assert_eq!(
mcp::merge_codex(&path, Some(&command), false, false).unwrap(),
mcp::FileOutcome::Unchanged
);
assert_eq!(
mcp::merge_codex(&path, None, false, false).unwrap(),
mcp::FileOutcome::Changed
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("fallow"));
assert!(text.contains("[mcp_servers.other]"));
}
#[test]
fn dry_run_touches_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
let ctx = Ctx {
dry_run: true,
..ctx(dir.path(), Mode::Install)
};
let mut steps = guide::install(&ctx, &[Harness::Claude]);
steps.extend(skill::install(&ctx, &[Harness::Claude]));
let command = mcp::McpCommand {
command: "fallow-mcp".to_string(),
args: Vec::new(),
source: mcp::McpSource::Path,
};
steps.extend(mcp::install(&ctx, &[Harness::Claude], Some(&command)));
steps.extend(hooks::install(&ctx, &[Harness::Claude]));
assert!(
steps.iter().any(|s| s.status == StepStatus::Written),
"{steps:?}"
);
let mut entries: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
entries.sort();
assert_eq!(entries, vec![".claude".to_string()]);
assert!(
std::fs::read_dir(dir.path().join(".claude"))
.unwrap()
.next()
.is_none()
);
}
#[test]
fn approval_is_opt_in_and_lands_in_local_settings() {
let dir = tempfile::tempdir().unwrap();
let command = mcp::McpCommand {
command: "fallow-mcp".to_string(),
args: Vec::new(),
source: mcp::McpSource::Path,
};
let without = ctx(dir.path(), Mode::Install);
let steps = mcp::install(&without, &[Harness::Claude], Some(&command));
let approval = steps
.iter()
.find(|s| s.path.as_deref() == Some(".claude/settings.local.json"))
.unwrap();
assert_eq!(approval.status, StepStatus::Skipped);
assert_eq!(approval.reason, Some(Reason::ApprovalNotRequested));
assert!(!dir.path().join(".claude/settings.local.json").exists());
let with = Ctx {
approve: true,
..without
};
let steps = mcp::install(&with, &[Harness::Claude], Some(&command));
let approval = steps
.iter()
.find(|s| s.path.as_deref() == Some(".claude/settings.local.json"))
.unwrap();
assert_eq!(approval.status, StepStatus::Written, "{steps:?}");
assert_eq!(approval.scope, Scope::Local);
let value: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join(".claude/settings.local.json")).unwrap(),
)
.unwrap();
assert_eq!(value["enabledMcpjsonServers"][0], "fallow");
std::fs::write(
dir.path().join(".claude/settings.local.json"),
"{\n \"disabledMcpjsonServers\": [\"fallow\"]\n}\n",
)
.unwrap();
let steps = mcp::install(&with, &[Harness::Claude], Some(&command));
let approval = steps
.iter()
.find(|s| s.path.as_deref() == Some(".claude/settings.local.json"))
.unwrap();
assert_eq!(approval.status, StepStatus::Written, "{steps:?}");
let value: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join(".claude/settings.local.json")).unwrap(),
)
.unwrap();
assert_eq!(value["disabledMcpjsonServers"], serde_json::json!([]));
assert_eq!(
value["enabledMcpjsonServers"],
serde_json::json!(["fallow"])
);
}
#[test]
fn cursor_hooks_are_reported_unsupported() {
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), Mode::Install);
let steps = hooks::install(&ctx, &[Harness::Cursor]);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].status, StepStatus::Skipped);
assert_eq!(steps[0].reason, Some(Reason::UnsupportedHarness));
}
fn step_status<'a>(steps: &'a [StepReport], path: &str) -> &'a StepReport {
steps
.iter()
.find(|s| s.path.as_deref() == Some(path))
.unwrap_or_else(|| panic!("no row for {path} in {steps:?}"))
}
#[test]
fn codex_hooks_install_the_native_gate_next_to_the_routing_block() {
let dir = tempfile::tempdir().unwrap();
let install = ctx(dir.path(), Mode::Install);
let steps = hooks::install(&install, &[Harness::Codex]);
for path in [
"AGENTS.md",
".codex/hooks.json",
".codex/hooks/fallow-gate.sh",
] {
let row = step_status(&steps, path);
assert_eq!(row.status, StepStatus::Written, "{row:?}");
assert_eq!(row.harness, Some(Harness::Codex));
assert_eq!(row.step, Step::Hooks);
}
assert_eq!(
step_status(&steps, "AGENTS.md").detail.as_deref(),
Some("routing block")
);
let again = hooks::install(&install, &[Harness::Codex]);
assert!(
again.iter().all(|s| s.status == StepStatus::Unchanged),
"{again:?}"
);
let uninstall = ctx(dir.path(), Mode::Uninstall);
let steps = hooks::uninstall(&uninstall, &[Harness::Codex]);
for path in [".codex/hooks.json", ".codex/hooks/fallow-gate.sh"] {
assert_eq!(step_status(&steps, path).status, StepStatus::Removed);
}
assert!(!dir.path().join(".codex/hooks.json").exists());
}
#[test]
fn codex_hooks_dry_run_reports_the_plan_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let dry = Ctx {
dry_run: true,
..ctx(dir.path(), Mode::Install)
};
let steps = hooks::install(&dry, &[Harness::Codex]);
let json = serde_json::to_value(&steps).unwrap();
let rows = json.as_array().unwrap();
for path in [
"AGENTS.md",
".codex/hooks.json",
".codex/hooks/fallow-gate.sh",
] {
assert!(
rows.iter().any(|row| row["path"] == path
&& row["status"] == "written"
&& row["harness"] == "codex"
&& row["step"] == "hooks"),
"{path} missing from {json}"
);
}
assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
}
#[test]
fn codex_hooks_under_user_scope_write_to_home_only() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let user = Ctx {
home: Some(home.path().to_path_buf()),
user: true,
..ctx(dir.path(), Mode::Install)
};
let steps = hooks::install(&user, &[Harness::Codex]);
assert_eq!(
step_status(&steps, "~/.codex/hooks.json").status,
StepStatus::Written
);
assert_eq!(
step_status(&steps, "~/.codex/hooks/fallow-gate.sh").scope,
Scope::Local
);
assert!(steps.iter().all(|s| s.path.as_deref() != Some("AGENTS.md")));
assert!(!dir.path().join("AGENTS.md").exists());
assert!(home.path().join(".codex/hooks.json").is_file());
}
#[test]
fn display_path_uses_root_then_home_then_absolute() {
let root = Path::new("/work/app");
let home = Path::new("/home/me");
assert_eq!(
display_path(root, Some(home), Path::new("/work/app/.mcp.json")),
".mcp.json"
);
assert_eq!(
display_path(root, Some(home), Path::new("/home/me/.codex/config.toml")),
"~/.codex/config.toml"
);
assert_eq!(
display_path(root, None, Path::new("/elsewhere/x")),
"/elsewhere/x"
);
let nested_home = Path::new("/work/app/home");
assert_eq!(
display_path(
root,
Some(nested_home),
Path::new("/work/app/home/.codex/config.toml")
),
"~/.codex/config.toml"
);
assert_eq!(
display_path(Path::new("C:\\work"), None, Path::new("D:\\other\\x.json")),
"D:/other/x.json"
);
}
#[test]
fn human_report_groups_scopes_and_summarizes_refusals() {
let report = Report {
kind: "agent-install",
schema_version: SCHEMA_VERSION,
fallow_version: env!("CARGO_PKG_VERSION"),
root: "/work/app".to_string(),
mode: Mode::Install,
dry_run: true,
harnesses: vec![Harness::Claude],
detected: true,
evidence: Vec::new(),
steps: vec![
StepReport::new(None, Step::Guide, StepStatus::Written, Scope::Shared)
.detail("scaffold with the fallow task map")
.with_path("AGENTS.md"),
StepReport::new(
Some(Harness::Claude),
Step::Mcp,
StepStatus::Skipped,
Scope::Local,
)
.reason(Reason::ApprovalNotRequested)
.detail("pass --approve to pre-approve the project MCP server for yourself")
.with_path(".claude/settings.local.json"),
StepReport::new(
Some(Harness::Claude),
Step::Skill,
StepStatus::Refused,
Scope::Shared,
)
.reason(Reason::SkillNameTaken)
.with_path(".claude/skills/fallow"),
],
next_actions: vec![NextAction {
id: "recommend-config",
command: "fallow recommend --format json".to_string(),
reason: "No fallow config was found.".to_string(),
mutating: false,
}],
};
let text = render_human(&report);
let expected = "\
fallow agent install (dry run)
root: /work/app
harnesses: claude (detected)
Shared with your team (commit these):
AGENTS.md would write guide scaffold with the fallow task map
.claude/skills/fallow refused skill (claude) skill_name_taken
Local to you:
.claude/settings.local.json skipped mcp (claude) approval_not_requested: pass --approve to pre-approve the project MCP server for yourself
1 step refused (existing content is not fallow-managed; pass --force to replace it); every other step still ran. Exit code 2.
Next:
fallow recommend --format json
No fallow config was found.
";
assert_eq!(text, expected);
}
impl StepReport {
fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
}
#[test]
fn resolve_root_prefers_git_toplevel_unless_explicit() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let status =
fallow_engine::changed_files::clear_ambient_git_env(&mut std::process::Command::new("git"))
.args(["init", "-q"])
.current_dir(&root)
.status()
.unwrap();
assert!(status.success());
let nested = root.join("packages").join("web");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(resolve_root(&nested, false), root);
assert_eq!(resolve_root(&nested, true), nested);
}