//! `vivac setup claude-code` — `t565` §7 and §11.
//!
//! Every test runs in a temporary sandbox with its own `VIVAC_HOME`: no test
//! here ever touches a real project. `--yes` stands in for a terminal
//! everywhere except the test that proves there is none.
mod common;
use common::Sandbox;
use std::path::Path;
const SESSION_START: &str = "vivac session start --hook";
const SESSION_END: &str = "vivac session end --hook";
fn settings_path(c: &Sandbox) -> std::path::PathBuf {
c.0.join(".claude").join("settings.json")
}
fn mcp_path(c: &Sandbox) -> std::path::PathBuf {
c.0.join(".mcp.json")
}
fn skill_path(c: &Sandbox) -> std::path::PathBuf {
c.0.join(".claude")
.join("skills")
.join("vivac-migrate")
.join("SKILL.md")
}
fn read(p: &Path) -> String {
std::fs::read_to_string(p).unwrap_or_else(|e| panic!("reading {p:?}: {e}"))
}
fn read_bytes(p: &Path) -> Vec<u8> {
std::fs::read(p).unwrap_or_else(|e| panic!("reading {p:?}: {e}"))
}
/// Whether `dir` holds a tree of its own: `store::already_planted`'s own
/// definition, `config` **or** `events`, copied here rather than asked of
/// the crate because an integration test has no `pub` path to it.
/// `t594`: three tests used to check `config` alone, which a
/// regression that planted only an `events` file would have slipped
/// straight past.
fn already_planted(dir: &Path) -> bool {
dir.join(".vivac").join("config").is_file() || dir.join(".vivac").join("events").is_file()
}
// `unlocked` used to live here: `d734` moved it to `common::Sandbox` once
// the same function existed, word for word bar the JSON read, in this
// file and two others (`f724`'s own lesson about two hand copies
// drifting apart). The two tests below are about the lock line a plan
// shows the moment a tree is not locked to `store::LANE_SENTENCE` yet,
// which a freshly seeded tree no longer is -- `Sandbox::unlocked` puts it
// back.
/// `p` the way the binary's own `current_dir()` would print it, for building
/// an expected text around a path.
///
/// On Unix, `current_dir()` returns the *physical* path: on macOS, a
/// temporary directory under `/var` comes back under `/private/var`, which
/// `canonicalize` also resolves to. On Windows it is the opposite:
/// `current_dir()` returns the path as given, short 8.3 names included (a
/// GitHub runner's `TEMP` is `C:\Users\RUNNER~1\...`), and `canonicalize`
/// would both expand those and prepend `\\?\`, breaking the very paths this
/// is meant to match.
#[cfg(unix)]
fn printed(p: &std::path::Path) -> std::path::PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|e| panic!("canonicalize {p:?}: {e}"))
}
#[cfg(not(unix))]
fn printed(p: &std::path::Path) -> std::path::PathBuf {
p.to_path_buf()
}
// ---------------------------------------------------------------------------
// 1. A fresh project.
// ---------------------------------------------------------------------------
/// `d723` piece B: `setup` no longer plants, so this folder needs a tree
/// of its own before `setup claude-code` writes its three pieces onto it --
/// this used to be one run that left four pieces behind, `.vivac/` among
/// them; now it is two, and what this test proves is the second one alone.
#[test]
fn a_fresh_project_gets_the_three_harness_pieces() {
let c = Sandbox::new_empty("setup-fresh");
c.ok(&["init", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("vivac setup claude-code, in"), "{out}");
assert!(out.contains("create, with two hooks"), "{out}");
assert!(out.contains(SESSION_START), "{out}");
assert!(out.contains(SESSION_END), "{out}");
assert!(out.contains("create, with the server \"vivac\""), "{out}");
assert!(out.contains("create: how an agent brings"), "{out}");
assert!(out.contains("Written."), "{out}");
assert!(
out.contains("Undo: vivac setup claude-code --undo"),
"{out}"
);
assert!(c.0.join(".vivac").exists(), "the tree must stay planted");
let settings: serde_json::Value = serde_json::from_str(&read(&settings_path(&c))).unwrap();
assert_eq!(
settings["hooks"]["SessionStart"][0]["hooks"][0]["command"],
SESSION_START
);
assert_eq!(
settings["hooks"]["Stop"][0]["hooks"][0]["command"],
SESSION_END
);
let mcp: serde_json::Value = serde_json::from_str(&read(&mcp_path(&c))).unwrap();
assert_eq!(mcp["mcpServers"]["vivac"]["command"], "vivac");
assert_eq!(mcp["mcpServers"]["vivac"]["args"][0], "mcp");
let skill = read(&skill_path(&c));
assert!(skill.starts_with("---\nname: vivac-migrate\n"), "{skill}");
assert!(
skill.contains("written by vivac setup; fingerprint "),
"{skill}"
);
}
// ---------------------------------------------------------------------------
// 2. Idempotent.
// ---------------------------------------------------------------------------
#[test]
fn a_second_run_writes_nothing() {
let c = Sandbox::new_empty("setup-idempotent");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
let settings_before = read_bytes(&settings_path(&c));
let mcp_before = read_bytes(&mcp_path(&c));
let skill_before = read_bytes(&skill_path(&c));
let log_before = c.log();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("Nothing to write: this project is already set up."),
"{out}"
);
assert_eq!(settings_before, read_bytes(&settings_path(&c)));
assert_eq!(mcp_before, read_bytes(&mcp_path(&c)));
assert_eq!(skill_before, read_bytes(&skill_path(&c)));
assert_eq!(log_before, c.log(), "the second run touched the tree");
}
// ---------------------------------------------------------------------------
// 3. A settings.json with its own content, out of order, CRLF.
// ---------------------------------------------------------------------------
const FOREIGN_SETTINGS: &str = "{\r\n \"otherKey\": \"z\",\r\n \"env\": {\r\n \"SOME_TOKEN\": \"shh\"\r\n },\r\n \"hooks\": {\r\n \"SessionStart\": [\r\n {\r\n \"matcher\": \"*\",\r\n \"hooks\": [\r\n {\r\n \"type\": \"command\",\r\n \"command\": \"some-other-tool\"\r\n }\r\n ]\r\n }\r\n ],\r\n \"Stop\": [\r\n {\r\n \"hooks\": [\r\n {\r\n \"type\": \"command\",\r\n \"command\": \"another-tool\"\r\n }\r\n ]\r\n }\r\n ]\r\n },\r\n \"aKey\": 1\r\n}\r\n";
const FOREIGN_SETTINGS_FOUR_SPACE: &str = "{\r\n \"otherKey\": \"z\",\r\n \"env\": {\r\n \"SOME_TOKEN\": \"shh\"\r\n },\r\n \"hooks\": {\r\n \"SessionStart\": [\r\n {\r\n \"matcher\": \"*\",\r\n \"hooks\": [\r\n {\r\n \"type\": \"command\",\r\n \"command\": \"some-other-tool\"\r\n }\r\n ]\r\n }\r\n ],\r\n \"Stop\": [\r\n {\r\n \"hooks\": [\r\n {\r\n \"type\": \"command\",\r\n \"command\": \"another-tool\"\r\n }\r\n ]\r\n }\r\n ]\r\n },\r\n \"aKey\": 1\r\n}\r\n";
#[test]
fn a_settings_file_with_its_own_content_keeps_it_and_gains_ours_at_the_end() {
let c = Sandbox::new_empty("setup-foreign-settings");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
std::fs::write(settings_path(&c), FOREIGN_SETTINGS).unwrap();
c.ok(&["setup", "claude-code", "--yes"]);
let after = read(&settings_path(&c));
// Order: the four original top-level keys, in their original order.
let order: Vec<usize> = ["otherKey", "env", "hooks", "aKey"]
.iter()
.map(|k| after.find(&format!("\"{k}\"")).unwrap())
.collect();
assert!(order.windows(2).all(|w| w[0] < w[1]), "{after}");
assert!(
after.contains("SOME_TOKEN"),
"the env block was lost:\n{after}"
);
assert!(after.contains("shh"), "{after}");
assert!(
after.contains("some-other-tool"),
"the foreign SessionStart hook was lost:\n{after}"
);
assert!(
after.contains("another-tool"),
"the foreign Stop hook was lost:\n{after}"
);
assert!(after.contains(SESSION_START), "{after}");
assert!(after.contains(SESSION_END), "{after}");
assert!(after.contains("\r\n"), "CRLF was lost:\n{after:?}");
}
// ---------------------------------------------------------------------------
// 4. The same file, indented at four spaces.
// ---------------------------------------------------------------------------
#[test]
fn indentation_of_an_existing_file_is_kept() {
let c = Sandbox::new_empty("setup-indent");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
std::fs::write(settings_path(&c), FOREIGN_SETTINGS_FOUR_SPACE).unwrap();
c.ok(&["setup", "claude-code", "--yes"]);
let after = read(&settings_path(&c));
assert!(after.contains("\r\n \"otherKey\""), "{after}");
assert!(after.contains("\r\n \"hooks\": {"), "{after}");
}
// ---------------------------------------------------------------------------
// 5. A SessionStart already running vivac, spelled differently.
// ---------------------------------------------------------------------------
#[test]
fn a_differently_spelled_session_start_is_left_alone_and_copied_into_the_plan() {
let c = Sandbox::new_empty("setup-different-spelling");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
let settings = serde_json::json!({
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "C:/tools/vivac.exe session start --hook" } ] }
]
}
});
std::fs::write(
settings_path(&c),
serde_json::to_string_pretty(&settings).unwrap(),
)
.unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("already runs C:/tools/vivac.exe session start --hook"),
"{out}"
);
let after: serde_json::Value = serde_json::from_str(&read(&settings_path(&c))).unwrap();
let start = after["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(
start.len(),
1,
"a second SessionStart entry was added:\n{after}"
);
}
// ---------------------------------------------------------------------------
// 6 and 7. The MCP server, taken and free.
// ---------------------------------------------------------------------------
#[test]
fn a_taken_vivac_server_name_is_a_conflict_and_nothing_is_written() {
let c = Sandbox::new_empty("setup-mcp-taken");
c.ok(&["init", "--yes"]);
let mcp = serde_json::json!({
"mcpServers": { "vivac": { "command": "something-else", "args": ["run"] } }
});
std::fs::write(mcp_path(&c), serde_json::to_string_pretty(&mcp).unwrap()).unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 1, "{out}");
assert!(
out.contains("already has a server called \"vivac\""),
"{out}"
);
assert!(out.contains("something-else run"), "{out}");
assert!(out.contains("Nothing written."), "{out}");
assert!(
!settings_path(&c).exists(),
"settings.json was written despite the conflict"
);
}
#[test]
fn another_name_already_running_vivac_mcp_is_left_as_it_is() {
let c = Sandbox::new_empty("setup-mcp-other-name");
c.ok(&["init", "--yes"]);
let mcp = serde_json::json!({
"mcpServers": { "vivac-tree": { "command": "vivac", "args": ["mcp"] } }
});
std::fs::write(mcp_path(&c), serde_json::to_string_pretty(&mcp).unwrap()).unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
plan_words(&out).contains("already runs vivac mcp as \"vivac-tree\""),
"{out}"
);
let after: serde_json::Value = serde_json::from_str(&read(&mcp_path(&c))).unwrap();
assert!(after["mcpServers"].get("vivac").is_none(), "{after}");
assert_eq!(after["mcpServers"]["vivac-tree"]["command"], "vivac");
}
// ---------------------------------------------------------------------------
// 8. The skill's three non-missing states.
// ---------------------------------------------------------------------------
#[test]
fn a_skill_with_no_marker_is_a_conflict() {
let c = Sandbox::new_empty("setup-skill-no-marker");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(skill_path(&c).parent().unwrap()).unwrap();
std::fs::write(skill_path(&c), "# Someone else's skill\n").unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 1, "{out}");
assert!(out.contains("SKILL.md is already there"), "{out}");
assert!(out.contains("Nothing written."), "{out}");
}
#[test]
fn a_skill_with_a_broken_fingerprint_is_a_conflict() {
let c = Sandbox::new_empty("setup-skill-bad-fingerprint");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(skill_path(&c).parent().unwrap()).unwrap();
std::fs::write(
skill_path(&c),
"---\nname: vivac-migrate\ndescription: x\n---\n<!-- written by vivac setup; fingerprint 0000000000000000; vivac setup claude-code --undo removes it while the text is unchanged -->\n\nedited\n",
)
.unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 1, "{out}");
assert!(out.contains("SKILL.md is already there"), "{out}");
}
#[test]
fn a_skill_an_earlier_vivac_wrote_is_replaced() {
let c = Sandbox::new_empty("setup-skill-replaceable");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(skill_path(&c).parent().unwrap()).unwrap();
// A valid marker over old body text: the fingerprint has to be the real
// FNV-1a of the content that follows, or this would read as a conflict
// instead.
let old_body = "---\nname: vivac-migrate\ndescription: an older description\n---\n";
let rest = "\n# An older vivac-migrate\n\nOlder text.\n";
let fp = fnv1a64(format!("{old_body}{rest}").as_bytes());
let text = format!(
"{old_body}<!-- written by vivac setup; fingerprint {fp:016x}; vivac setup claude-code --undo removes it while the text is unchanged -->\n{rest}"
);
std::fs::write(skill_path(&c), &text).unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
plan_words(&out).contains("replace the copy an earlier vivac wrote"),
"{out}"
);
let after = read(&skill_path(&c));
assert!(
after.contains("# Bringing what a project knows into vivac"),
"{after}"
);
assert!(!after.contains("An older vivac-migrate"), "{after}");
}
// ---------------------------------------------------------------------------
// 9. Broken JSON, and JSON whose root is not an object.
// ---------------------------------------------------------------------------
#[test]
fn broken_json_refuses_with_line_and_column_and_writes_nothing() {
let c = Sandbox::new_empty("setup-broken-json");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
std::fs::write(settings_path(&c), "{\n \"a\": ,\n}").unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 1, "{out}");
assert!(
out.contains(".claude/settings.json is not JSON setup can read"),
"{out}"
);
assert!(out.contains("line 2"), "{out}");
assert!(out.contains("Nothing written."), "{out}");
}
#[test]
fn a_json_root_that_is_not_an_object_is_a_conflict() {
let c = Sandbox::new_empty("setup-not-object");
c.ok(&["init", "--yes"]);
std::fs::write(mcp_path(&c), "[1, 2, 3]").unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 1, "{out}");
assert!(
out.contains(".mcp.json holds JSON whose top level is not an object"),
"{out}"
);
assert!(out.contains("Nothing written."), "{out}");
}
// ---------------------------------------------------------------------------
// 10. `--dry-run`.
// ---------------------------------------------------------------------------
#[test]
fn dry_run_writes_nothing() {
let c = Sandbox::new_empty("setup-dry-run");
c.ok(&["init", "--yes"]);
let before: Vec<_> = std::fs::read_dir(&c.0).unwrap().collect();
let (out, code) = c.run(&["setup", "claude-code", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("Nothing written: --dry-run."), "{out}");
let after: Vec<_> = std::fs::read_dir(&c.0).unwrap().collect();
assert_eq!(before.len(), after.len(), "dry-run created something");
assert!(!c.0.join(".claude").exists());
}
// ---------------------------------------------------------------------------
// 11. No terminal and no `--yes`.
// ---------------------------------------------------------------------------
/// `f718`: the same guard on the other door. `--undo` reached its own
/// question without ever passing the check, so a run with nobody to answer
/// printed the question anyway, removed nothing and exited 0 -- and 0 with
/// nothing done is what a script reads as done. The command it hands back
/// keeps the `--undo` it was given, for the reason `f675` already gave:
/// without it, the advice is advice for the opposite run.
#[test]
fn no_terminal_and_no_yes_refuses_an_undo_too_and_removes_nothing() {
let c = Sandbox::new_empty("setup-no-terminal-undo");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code", "--undo"]);
assert_eq!(code, 1, "{out}");
assert!(out.contains("there is no terminal to ask"), "{out}");
assert!(
out.contains("vivac setup claude-code --undo --dry-run"),
"{out}"
);
assert!(
out.contains("vivac setup claude-code --undo --yes"),
"{out}"
);
assert!(
c.0.join(".claude").join("settings.json").is_file(),
"the run removed something it had not been allowed to confirm"
);
assert!(c.0.join(".mcp.json").is_file(), "{out}");
}
#[test]
fn no_terminal_and_no_yes_refuses_without_a_plan() {
let c = Sandbox::new_empty("setup-no-terminal");
c.ok(&["init", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code"]);
assert_eq!(code, 1, "{out}");
assert!(out.contains("there is no terminal here to ask"), "{out}");
assert!(out.contains("vivac setup claude-code --dry-run"), "{out}");
assert!(out.contains("vivac setup claude-code --yes"), "{out}");
assert!(
!out.contains("vivac setup claude-code, in"),
"a plan was shown:\n{out}"
);
assert!(!c.0.join(".claude").exists());
}
// ---------------------------------------------------------------------------
// `t594` §4.5.2, §6.1: the lane plan lines, for a folder that becomes a
// brand new lane of the tree above it.
// ---------------------------------------------------------------------------
/// Where a wrapped status's continuation lines start: `f720`'s own
/// `claude_code::PIECE_STATUS_COLUMN`, not reachable from here since an
/// integration test only sees what the binary prints.
const PLAN_STATUS_COLUMN: usize = 45;
/// Whether the plan shows a line for `label` whose status contains
/// `rest`, once a status `render::wrap` (`f720`) split across more than
/// one line is put back together. Width wraps a long status now, not a
/// hand-picked cut, so a test that cares about the words has to stop
/// caring which line they landed on -- the same shift `tests/check.rs`'s
/// own `words` already made for `copy_notice`'s prose.
fn lane_line_containing(out: &str, label: &str, rest: &str) -> bool {
let lines: Vec<&str> = out.lines().collect();
lines.iter().enumerate().any(|(i, l)| {
if !l.trim_start().starts_with(label) {
return false;
}
let mut joined = l.trim_start().to_string();
for cont in &lines[i + 1..] {
if !cont.starts_with(&" ".repeat(PLAN_STATUS_COLUMN)) {
break;
}
joined.push(' ');
joined.push_str(cont.trim());
}
joined
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.contains(rest)
})
}
/// The words of `out`, run together regardless of which line
/// `render::wrap` (`f720`) put them on -- for a plain `.contains` check
/// that does not anchor on a label the way `lane_line_containing` does.
/// The same shift `tests/check.rs`'s own `words` already made for
/// `copy_notice`'s prose, for the same reason: width wraps a status now,
/// not a hand-picked cut.
fn plan_words(out: &str) -> String {
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
// `d723` piece B: showing the lane's own plan lines is `init`'s job now --
// `setup` never resolves into a folder that is not yet one of the tree's
// own lanes (`Failure::not_a_lane_yet`), so `init --yes` is what these
// three tests run instead of `setup claude-code --yes`.
#[test]
fn the_plan_for_a_new_lane_shows_the_three_lines_the_spec_gives() {
let c = Sandbox::unlocked("setup-lane-plan-lines");
let second = c.0.join("v2");
std::fs::create_dir_all(&second).unwrap();
let (out, code) = run_in(&second, c.global_home(), &["init", "--yes"]);
assert_eq!(code, 0, "{out}");
// `t640`, point 9: the plan names the product on this line too,
// derived here since nobody fixed one with `--name`.
let product = c.0.file_name().unwrap().to_string_lossy().into_owned();
assert!(
lane_line_containing(
&out,
".vivac/lane",
&format!("create: this folder becomes lane \"v2\" of \"{product}\""),
),
"{out}"
);
assert!(
lane_line_containing(
&out,
".vivac/.gitignore",
"create: keeps .vivac/ out of version control",
),
"{out}"
);
assert!(
lane_line_containing(
&out,
"config",
"lock: from now on this tree needs vivac 0.12 or newer",
),
"{out}"
);
}
/// The config warning is part of the *plan*, not of what gets printed
/// after writing: `--dry-run` never writes anything, and it still shows
/// the line, exactly where the plan showed it above.
#[test]
fn the_lane_config_warning_shows_up_before_anything_is_written() {
let c = Sandbox::unlocked("setup-lane-plan-dry-run");
let second = c.0.join("v2");
std::fs::create_dir_all(&second).unwrap();
let (out, code) = run_in(&second, c.global_home(), &["init", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("Nothing written: --dry-run."), "{out}");
assert!(
lane_line_containing(
&out,
"config",
"lock: from now on this tree needs vivac 0.12 or newer",
),
"{out}"
);
assert!(!second.join(".vivac").exists(), "dry-run wrote something");
}
/// Finding 6 (baja): `plan_lane` used to read the tree's config through
/// `Store::open`, which fills a missing one back in on its own -- a write
/// `--dry-run` must never cause, even one this indirect, and even over a
/// tree it is not planting.
#[test]
fn dry_run_never_regenerates_a_missing_config() {
let c = Sandbox::new_seeded("setup-dry-run-no-config");
std::fs::remove_file(c.0.join(".vivac").join("config")).unwrap();
assert!(!c.0.join(".vivac").join("config").exists());
let second = c.0.join("v2");
std::fs::create_dir_all(&second).unwrap();
let (out, code) = run_in(&second, c.global_home(), &["init", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("Nothing written: --dry-run."), "{out}");
assert!(
!c.0.join(".vivac").join("config").exists(),
"--dry-run regenerated the tree's config:\n{out}"
);
}
/// `t594`: an already-set-up project runs into
/// `nothing_to_write` before it ever reaches `--dry-run`'s own check, and
/// that branch notes the machine's registry (`note_registry`) -- a write
/// `--dry-run` must never make, in the registry or anywhere else. The
/// registry is deleted first, so its own directory reappearing is exactly
/// the write this catches.
#[test]
fn dry_run_never_writes_the_machine_registry_either() {
let c = Sandbox::new_empty("setup-dry-run-no-registry");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
std::fs::remove_dir_all(c.global_home()).ok();
assert!(!c.global_home().exists());
let (out, code) = c.run(&["setup", "claude-code", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("Nothing written: --dry-run."), "{out}");
assert!(
!c.global_home().exists(),
"--dry-run wrote to the machine's registry:\n{out}"
);
}
// ---------------------------------------------------------------------------
// 12 and 13. `--undo`.
// ---------------------------------------------------------------------------
#[test]
fn undo_after_a_fresh_setup_leaves_only_the_tree() {
let c = Sandbox::new_empty("setup-undo-fresh");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
let vivac_before = std::fs::read(c.0.join(".vivac").join("config")).unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--undo", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("Undone. The tree in .vivac/ is untouched."),
"{out}"
);
assert!(!c.0.join(".claude").exists(), "{:?}", list(&c.0));
assert!(!mcp_path(&c).exists());
assert!(c.0.join(".vivac").exists());
assert_eq!(
vivac_before,
std::fs::read(c.0.join(".vivac").join("config")).unwrap()
);
}
#[test]
fn undo_over_a_file_with_its_own_content_returns_it_to_the_original_text() {
let c = Sandbox::new_empty("setup-undo-restore");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
std::fs::write(settings_path(&c), FOREIGN_SETTINGS).unwrap();
c.ok(&["setup", "claude-code", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code", "--undo", "--yes"]);
assert_eq!(code, 0, "{out}");
assert_eq!(read(&settings_path(&c)), FOREIGN_SETTINGS, "{out}");
}
// ---------------------------------------------------------------------------
// 14. `--undo` leaves what it does not recognise, and says so.
// ---------------------------------------------------------------------------
#[test]
fn undo_leaves_a_differently_spelled_hook_and_an_edited_skill() {
let c = Sandbox::new_empty("setup-undo-leaves");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
let settings = serde_json::json!({
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "C:/tools/vivac.exe session start --hook" } ] }
]
}
});
std::fs::write(
settings_path(&c),
serde_json::to_string_pretty(&settings).unwrap(),
)
.unwrap();
c.ok(&["setup", "claude-code", "--yes"]);
// Edit the skill after setup wrote it, so its fingerprint no longer
// matches: the way an edited file looks to `--undo`.
let skill = read(&skill_path(&c));
std::fs::write(skill_path(&c), format!("{skill}\nedited by hand\n")).unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--undo", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("runs vivac another way; left as it is"),
"{out}"
);
assert!(
plan_words(&out).contains("changed since setup wrote it; left as it is"),
"{out}"
);
assert!(
read(&settings_path(&c)).contains("C:/tools/vivac.exe session start --hook"),
"the differently-spelled hook was removed"
);
assert!(skill_path(&c).exists(), "the edited skill was removed");
}
// ---------------------------------------------------------------------------
// `d680` used to live here: `--undo` and the lane file `--join` leaves
// behind. `d723` piece B moved both `--join` and `--undo`'s own reach into
// `.vivac/lane` to `init` -- `setup --undo` never touches the lane at all
// any more, so none of the four scenarios this section covered (an
// unwritten lane removed and unblocking `relocate`, a written lane kept,
// a lane removed even with nothing else left to undo, a hand-edited
// `.gitignore` kept) has anything left to say about `setup`. `init.rs`'s
// own `init_undo_removes_a_joined_lane_and_leaves_the_target_log_growing_only`
// covers the first case through `init --undo`; the other three are not
// ported there yet -- a gap, not a decision, and named as one in the
// report this piece ends with.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 19. The `hooks` tombstone.
// ---------------------------------------------------------------------------
#[test]
fn hooks_is_a_tombstone() {
let c = Sandbox::new_empty("setup-hooks-tombstone");
let (out, code) = c.run(&["hooks"]);
assert_eq!(code, 2, "{out}");
assert!(
out.contains("vivac hooks is gone: vivac setup claude-code writes the hooks itself,"),
"{out}"
);
assert!(!out.contains("Paste this into"), "{out}");
}
// ---------------------------------------------------------------------------
// 20. The registry's own folder. `d723` piece B: `setup` finds no tree to
// resolve from inside the registry's own folder at all -- nothing marks it
// as one -- so it refuses with `Failure::SetupNoTree`, the generic answer,
// before it ever reaches the registry-specific one. Following that
// refusal's own advice (`vivac init`) is what actually meets the
// registry-specific text: `resolve_roots`, unchanged, still catches the
// tree root resolving to the registry's own folder.
// ---------------------------------------------------------------------------
#[test]
fn setup_refuses_inside_the_registry_folder() {
let c = Sandbox::new_empty("setup-registry-root");
// Marks `global_home()` as the registry the way `registry::note` does,
// without needing a second project to trigger it for real.
std::fs::create_dir_all(c.global_home()).unwrap();
std::fs::write(c.global_home().join("projects"), "{}").unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_vivac"))
.current_dir(c.global_home())
.env("VIVAC_HOME", c.global_home())
.args(["setup", "claude-code", "--yes"])
.output()
.unwrap();
let text =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{text}");
assert!(
text.contains("setup writes what an agent reads, and there is no tree here"),
"{text}"
);
let init_out = std::process::Command::new(env!("CARGO_BIN_EXE_vivac"))
.current_dir(c.global_home())
.env("VIVAC_HOME", c.global_home())
.args(["init", "--yes"])
.output()
.unwrap();
let init_text = String::from_utf8_lossy(&init_out.stdout).into_owned()
+ &String::from_utf8_lossy(&init_out.stderr);
assert_eq!(init_out.status.code(), Some(1), "{init_text}");
assert!(
init_text.contains("holds the registry of the trees on this machine"),
"{init_text}"
);
}
// ---------------------------------------------------------------------------
// 21. The skill, golden, and its fingerprint.
// ---------------------------------------------------------------------------
const FRONTMATTER: &str = include_str!("../src/setup/skill-frontmatter.md");
const BODY: &str = include_str!("../src/setup/skill-body.md");
fn fnv1a64(data: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for &b in data {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[test]
fn the_skill_is_golden_and_its_fingerprint_is_the_real_hash_of_its_text() {
let c = Sandbox::new_empty("setup-skill-golden");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
let skill = read(&skill_path(&c));
let mut lines: Vec<&str> = skill.split('\n').collect();
let marker = lines[4];
let fp_hex = marker
.strip_prefix("<!-- written by vivac setup; fingerprint ")
.unwrap()
.split(';')
.next()
.unwrap();
let claimed = u64::from_str_radix(fp_hex, 16).unwrap();
lines.remove(4);
let without_marker = lines.join("\n");
assert_eq!(
claimed,
fnv1a64(without_marker.as_bytes()),
"the fingerprint is not the FNV-1a of the text it claims to cover"
);
// The marker names no harness. It named `claude-code` until `f714`,
// and this is the same file byte for byte wherever setup writes it, so
// a project set up with Codex was handed a line proposing a command for
// the other harness.
let expected = format!(
"{FRONTMATTER}<!-- written by vivac setup; fingerprint {claimed:016x}; setup removes \
it with --undo while the text is unchanged -->\n{BODY}"
);
assert_eq!(skill, expected, "the skill drifted from the literal text");
}
// ---------------------------------------------------------------------------
// 22. Help names setup, not hooks.
// ---------------------------------------------------------------------------
#[test]
fn help_names_setup_and_not_hooks() {
let c = Sandbox::new_empty("setup-help");
let help = c.ok(&["--help"]);
assert!(help.contains("vivac setup claude-code"), "{help}");
assert!(!help.contains("vivac hooks"), "{help}");
}
#[test]
fn setup_no_longer_says_to_commit_the_tree() {
let c = Sandbox::new_empty("setup-files");
c.ok(&["init", "--yes"]);
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(out.contains(".vivac/ is never committed"), "{out}");
}
// ---------------------------------------------------------------------------
// Coordinator's review: C, D, E, F.
// ---------------------------------------------------------------------------
/// C: an existing `settings.json` with neither hook says "add", not
/// "create" -- "create" is only for a file that does not exist yet.
#[test]
fn an_existing_settings_file_with_no_hooks_says_add_not_create() {
let c = Sandbox::new_empty("setup-add-not-create-settings");
c.ok(&["init", "--yes"]);
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
std::fs::write(settings_path(&c), "{\n \"otherKey\": 1\n}\n").unwrap();
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(out.contains("add two hooks"), "{out}");
assert!(!out.contains("create, with two hooks"), "{out}");
}
/// C: the same for `.mcp.json` with no server of ours.
#[test]
fn an_existing_mcp_file_with_no_server_says_add_not_create() {
let c = Sandbox::new_empty("setup-add-not-create-mcp");
c.ok(&["init", "--yes"]);
std::fs::write(mcp_path(&c), "{\n \"mcpServers\": {}\n}\n").unwrap();
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(out.contains("add the server \"vivac\""), "{out}");
assert!(!out.contains("create, with the server"), "{out}");
}
/// D: `--undo --dry-run` shows the plan and writes nothing, without asking.
#[test]
fn undo_dry_run_shows_the_plan_and_writes_nothing() {
let c = Sandbox::new_empty("setup-undo-dry-run");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
let before = read_bytes(&settings_path(&c));
let (out, code) = c.run(&["setup", "claude-code", "--undo", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("vivac setup claude-code --undo, in"), "{out}");
assert!(out.contains("Nothing written: --dry-run."), "{out}");
assert!(!out.contains("Undo it?"), "{out}");
assert_eq!(before, read_bytes(&settings_path(&c)));
assert!(c.0.join(".vivac").exists());
}
/// E: when `.mcp.json` will be removed because it holds nothing else, the
/// undo plan says so in the one sentence `settings.json`'s own removal
/// already uses -- wrapped by width now (`f720`), not by a hand-picked
/// cut, so this checks the sentence and not which line it landed on.
#[test]
fn undo_plan_wraps_the_mcp_removal_when_the_file_would_empty_out() {
let c = Sandbox::new_empty("setup-undo-mcp-wrap");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
let (out, _) = c.run(&["setup", "claude-code", "--undo", "--dry-run"]);
assert!(
plan_words(&out).contains("remove the server \"vivac\"; nothing else is left, so it goes"),
"{out}"
);
}
/// F: `--dry-run` with `--yes` is a usage error with the literal text, and
/// touches nothing.
#[test]
fn dry_run_with_yes_is_a_usage_error() {
let c = Sandbox::new_empty("setup-dry-run-yes");
let (out, code) = c.run(&["setup", "claude-code", "--dry-run", "--yes"]);
assert_eq!(code, 2, "{out}");
assert!(
out.contains("--dry-run writes nothing, so there is nothing for --yes to confirm."),
"{out}"
);
assert!(out.contains("Give one or the other."), "{out}");
assert!(!c.0.join(".vivac").exists());
}
fn list(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// t579 §4/§9: two roots -- Claude Code's own files always in the current
// directory, the tree wherever it is found walking up.
// ---------------------------------------------------------------------------
fn run_in(dir: &Path, home: &Path, args: &[&str]) -> (String, i32) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_vivac"))
.current_dir(dir)
.env("VIVAC_HOME", home)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr),
out.status.code().unwrap_or(-1),
)
}
fn create_git_dir(at: &Path) {
std::fs::create_dir_all(at.join(".git")).unwrap();
}
fn create_git_worktree_file(at: &Path) {
std::fs::write(at.join(".git"), "gitdir: ../elsewhere/.git/worktrees/x\n").unwrap();
}
/// (a): a subfolder of a repository, its own tree already planted there
/// (`d723` piece B: `setup` no longer plants it, `init` already has), still
/// gets Claude Code's files, and the plan still warns with the
/// repository's root.
#[test]
fn a_subfolder_of_a_repository_gets_its_own_files_with_a_warning() {
let c = Sandbox::new_empty("setup-two-roots-subfolder");
create_git_dir(&c.0);
let sub = c.0.join("packages").join("app");
std::fs::create_dir_all(&sub).unwrap();
run_in(&sub, c.global_home(), &["init", "--yes"]);
let (out, code) = run_in(&sub, c.global_home(), &["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains(&format!(
"This folder is inside the repository at {}, not at its root.",
printed(&c.0).display()
)),
"{out}"
);
assert!(
out.contains("Claude Code reads these files only from the folder it is opened in: if"),
"{out}"
);
assert!(
out.contains(&format!(
"you open it at {}, run setup there instead.",
printed(&c.0).display()
)),
"{out}"
);
assert!(sub.join(".claude").join("settings.json").exists());
assert!(sub.join(".mcp.json").exists());
assert!(
!c.0.join(".claude").exists(),
"Claude Code files leaked into the repository root"
);
assert!(
!c.0.join(".vivac").exists(),
"a tree was planted above the current directory"
);
}
/// (b), first half: `.git` as a folder, right at the current directory,
/// gets no warning.
#[test]
fn the_root_of_a_repository_gets_no_warning_with_git_as_a_folder() {
let c = Sandbox::new_empty("setup-two-roots-git-folder");
create_git_dir(&c.0);
c.ok(&["init", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(!out.contains("is inside the repository at"), "{out}");
}
/// (b), second half: `.git` as a worktree's file gets no warning either.
#[test]
fn the_root_of_a_repository_gets_no_warning_with_git_as_a_worktree_file() {
let c = Sandbox::new_empty("setup-two-roots-git-file");
create_git_worktree_file(&c.0);
c.ok(&["init", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(!out.contains("is inside the repository at"), "{out}");
}
/// (c): no `.git` anywhere in the ancestry gets no warning.
#[test]
fn no_git_anywhere_gets_no_warning() {
let c = Sandbox::new_empty("setup-two-roots-no-git");
c.ok(&["init", "--yes"]);
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(!out.contains("is inside the repository at"), "{out}");
}
/// (d): with a tree already planted above, and this folder already one of
/// its lanes, Claude Code's files still land in the current directory, and
/// nothing is written into the folder above.
#[test]
fn a_tree_above_keeps_claude_codes_files_in_the_lane_below() {
let c = Sandbox::new_seeded("setup-two-roots-tree-above");
let sub = c.0.join("workdir");
std::fs::create_dir_all(&sub).unwrap();
// `d723` piece B: `sub` has to be one of the tree's own lanes before
// `setup` will write into it -- `init --yes` is what declares it one
// now, the same join `setup` used to do implicitly by itself.
run_in(&sub, c.global_home(), &["init", "--yes"]);
let (out, code) = run_in(&sub, c.global_home(), &["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(sub.join(".claude").join("settings.json").exists());
assert!(sub.join(".mcp.json").exists());
assert!(
sub.join(".vivac").join("lane").exists(),
"the subfolder never joined the tree above as a lane"
);
assert!(
!already_planted(&sub),
"a second tree was planted below the existing one"
);
assert!(
!c.0.join(".claude").exists(),
"Claude Code files were written above the current directory"
);
assert!(!c.0.join(".mcp.json").exists());
}
/// (f): `--undo` in a subfolder of a tree removes only what `setup` wrote
/// there, and leaves the tree above -- and the lane `init --join` declared
/// for this folder, `d723` piece B moved that far out of `setup --undo`'s
/// own reach -- exactly as they were: `--undo` never touches the log, or
/// the lane, any more.
#[test]
fn undo_in_a_subfolder_removes_only_that_folders_files() {
let c = Sandbox::new_seeded("setup-two-roots-undo-subfolder");
let sub = c.0.join("workdir");
std::fs::create_dir_all(&sub).unwrap();
run_in(&sub, c.global_home(), &["init", "--yes"]);
let (setup_out, setup_code) = run_in(&sub, c.global_home(), &["setup", "claude-code", "--yes"]);
assert_eq!(setup_code, 0, "{setup_out}");
assert!(
sub.join(".claude").join("settings.json").exists(),
"setup did not write into the subfolder it ran in"
);
let config_after_setup = std::fs::read(c.0.join(".vivac").join("config")).unwrap();
let events_after_setup = std::fs::read(c.0.join(".vivac").join("events")).unwrap();
let (out, code) = run_in(
&sub,
c.global_home(),
&["setup", "claude-code", "--undo", "--yes"],
);
assert_eq!(code, 0, "{out}");
assert!(!sub.join(".claude").exists());
assert!(!sub.join(".mcp.json").exists());
assert!(
sub.join(".vivac").join("lane").exists(),
"setup --undo took the lane, which is init --undo's alone since d723 piece B"
);
assert!(
c.0.join(".vivac").exists(),
"the tree above was touched by undo"
);
// `undo` never touches the log, checked rather than only claimed
// (`t594`): both `config` and `events` stay exactly
// as the earlier `setup` left them.
assert_eq!(
config_after_setup,
std::fs::read(c.0.join(".vivac").join("config")).unwrap(),
"undo changed the tree's config"
);
assert_eq!(
events_after_setup,
std::fs::read(c.0.join(".vivac").join("events")).unwrap(),
"undo changed the tree's log"
);
}
/// (g): a workspace with a repository inside it -- setup at the workspace
/// root and again inside the repository leaves two sets of Claude Code
/// files and a single shared tree.
#[test]
fn a_workspace_and_a_repository_inside_it_share_one_tree_and_get_two_sets_of_files() {
let c = Sandbox::new_empty("setup-two-roots-workspace");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
assert!(c.0.join(".vivac").exists());
let repository = c.0.join("service");
std::fs::create_dir_all(&repository).unwrap();
create_git_dir(&repository);
run_in(&repository, c.global_home(), &["init", "--yes"]);
let (out, code) = run_in(
&repository,
c.global_home(),
&["setup", "claude-code", "--yes"],
);
assert_eq!(code, 0, "{out}");
assert!(!out.contains("is inside the repository at"), "{out}");
assert!(c.0.join(".claude").join("settings.json").exists());
assert!(repository.join(".claude").join("settings.json").exists());
assert!(
repository.join(".vivac").join("lane").exists(),
"the repository never joined the shared tree as a lane"
);
assert!(
!already_planted(&repository),
"a second tree was planted inside the repository"
);
}
// ---------------------------------------------------------------------------
// t579 §4.1/§9: the user's home folder, and a `.vivac/` that is the global
// store. Both are refused before the plan, and only for `apply`.
// ---------------------------------------------------------------------------
fn run_with_home(dir: &Path, home: &Path, vivac_home: &Path, args: &[&str]) -> (String, i32) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_vivac"))
.current_dir(dir)
.env("HOME", home)
.env("USERPROFILE", home)
.env("VIVAC_HOME", vivac_home)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr),
out.status.code().unwrap_or(-1),
)
}
fn home_folder_text(here: &Path) -> String {
format!(
" {} is your home folder. Claude Code's settings and skills here are\n \
yours for every project, not this one's, and setup never writes there.\n \
Run setup in the folder you open Claude Code in, inside a project.",
here.display()
)
}
/// (h), first half: setup in the user's home folder refuses before showing a
/// plan, and writes nothing.
#[test]
fn setup_refuses_in_the_home_folder() {
let c = Sandbox::new_empty("setup-two-roots-home");
// `d723` piece B: `setup` refuses with no tree at all before it ever
// reaches the home-folder guard, so a tree has to be here for that
// guard to be the one this run actually meets.
c.ok(&["init", "--yes"]);
let (out, code) = run_with_home(
&c.0,
&c.0,
c.global_home(),
&["setup", "claude-code", "--yes"],
);
assert_eq!(code, 1, "{out}");
assert!(out.contains(&home_folder_text(&printed(&c.0))), "{out}");
assert!(
!out.contains("vivac setup claude-code, in"),
"a plan was shown:\n{out}"
);
assert!(!c.0.join(".claude").exists());
assert!(!c.0.join(".mcp.json").exists());
}
/// (h), second half: the same refusal holds with `--dry-run`.
#[test]
fn setup_refuses_in_the_home_folder_with_dry_run_too() {
let c = Sandbox::new_empty("setup-two-roots-home-dry-run");
c.ok(&["init", "--yes"]);
let (out, code) = run_with_home(
&c.0,
&c.0,
c.global_home(),
&["setup", "claude-code", "--dry-run"],
);
assert_eq!(code, 1, "{out}");
assert!(out.contains(&home_folder_text(&printed(&c.0))), "{out}");
assert!(!c.0.join(".claude").exists());
}
/// (i): a `.vivac/` that is the global store, found as the tree's own root.
/// `d723` piece B: `find_root` already skips a folder the registry marks as
/// its own, the same way it always has, so `setup` never resolves a tree
/// here at all -- what used to be the registry's own refusal is
/// `Failure::SetupNoTree` now, the same as any other folder with no tree
/// to configure. The protection this guarded -- a project's tree never
/// living inside the registry's own folder -- is still whole: it is
/// `init`'s own `resolve_roots`, unchanged, that a person following
/// `SetupNoTree`'s own advice (`vivac init`) would actually reach.
#[test]
fn setup_refuses_when_the_trees_own_vivac_is_the_global_store() {
let c = Sandbox::new_empty("setup-two-roots-store-is-tree");
let store = c.0.join(".vivac");
std::fs::create_dir_all(&store).unwrap();
std::fs::write(store.join("projects"), "{}").unwrap();
let (out, code) = run_with_home(
&c.0,
c.global_home(),
&store,
&["setup", "claude-code", "--yes"],
);
assert_eq!(code, 4, "{out}");
assert!(
out.contains("setup writes what an agent reads, and there is no tree here"),
"{out}"
);
assert!(!c.0.join(".claude").exists());
assert!(!c.0.join(".mcp.json").exists());
}
// ---------------------------------------------------------------------------
// t579 §5.4, §14.7 and §15.6: upgrading from the exact SKILL.md each earlier
// release wrote.
// ---------------------------------------------------------------------------
/// The literal SKILL.md each earlier release wrote: its frontmatter and body
/// (`git show <tag>:src/setup/skill-frontmatter.md` and `skill-body.md`),
/// joined by the marker line with the fingerprint that version's own
/// `fnv1a64` computed over that text. Setup already knows how to replace a
/// copy an earlier vivac wrote; these fixtures are what those copies actually
/// looked like. The ones from v0.11.0 on are byte for byte the copies those
/// releases wrote into real projects.
const EARLIER_RELEASE_SKILLS: [(&str, &str); 4] = [
("v0.10.0", include_str!("data/skill-v0.10.0.md")),
("v0.11.0", include_str!("data/skill-v0.11.0.md")),
("v0.11.1", include_str!("data/skill-v0.11.1.md")),
("v0.11.2", include_str!("data/skill-v0.11.2.md")),
];
#[test]
fn every_earlier_release_skill_is_replaced_by_the_new_one() {
let fresh = Sandbox::new_empty("setup-skill-fresh");
fresh.ok(&["init", "--yes"]);
fresh.ok(&["setup", "claude-code", "--yes"]);
let expected = read(&skill_path(&fresh));
// An upgrade: setup ran with that release, so everything else is
// already there and only the skill is behind.
for (release, old) in EARLIER_RELEASE_SKILLS {
let c = Sandbox::new_empty(&format!("setup-skill-{release}-upgrade"));
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
std::fs::write(skill_path(&c), old).unwrap();
let (out, code) = c.run(&["setup", "claude-code", "--yes"]);
assert_eq!(code, 0, "{release}: {out}");
assert!(
plan_words(&out).contains("replace the copy an earlier vivac wrote"),
"{release}: {out}"
);
assert_eq!(read(&skill_path(&c)), expected, "{release}");
assert_eq!(written_part(&out), SKILL_REPLACED_MESSAGE, "{release}");
}
}
// ---------------------------------------------------------------------------
// t579 §5.2/§9: the skill only teaches commands that exist. Every bare
// "vivac <word>" and every "vivac_<word>" in its text is either a verb
// `vivac --help` lists or a tool `vivac mcp` serves.
// ---------------------------------------------------------------------------
/// Words that follow a bare "vivac" in the skill's text without naming a
/// command. Each entry is a real sentence read out of the text, not a
/// guess: "vivac never imports anything by itself", "... is another
/// map." and "offer to set vivac up there". Widening this list to let a
/// typo pass is the wrong fix; renaming or rewriting the sentence is the
/// right one.
const NOT_A_COMMAND: &[&str] = &["never", "is", "up"];
/// Every verb `--help` lists on its own line, four spaces in.
fn help_commands(help: &str) -> std::collections::BTreeSet<String> {
help.lines()
.filter_map(|l| l.strip_prefix(" vivac "))
.filter_map(|rest| rest.split_whitespace().next())
.map(str::to_string)
.collect()
}
const MCP_HELLO: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
const MCP_INITIALIZE_DONE: &str = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
const MCP_TOOLS_LIST: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
/// The tool names `vivac mcp` actually serves, asked over the wire the same
/// way `tests/mcp.rs` does, so a tool the skill names and the server does
/// not have cannot pass by assumption.
fn mcp_tool_names(c: &Sandbox) -> std::collections::BTreeSet<String> {
use std::io::{BufRead, BufReader, Write};
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_vivac"))
.current_dir(&c.0)
.env("VIVAC_HOME", c.global_home())
.arg("mcp")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
writeln!(stdin, "{MCP_HELLO}").unwrap();
stdin.flush().unwrap();
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
writeln!(stdin, "{MCP_INITIALIZE_DONE}").unwrap();
stdin.flush().unwrap();
writeln!(stdin, "{MCP_TOOLS_LIST}").unwrap();
stdin.flush().unwrap();
let mut answer = String::new();
stdout.read_line(&mut answer).unwrap();
let response: serde_json::Value = serde_json::from_str(&answer).unwrap();
let names = response["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
let _ = child.kill();
let _ = child.wait();
names
}
/// Every "vivac <word>" and "vivac_<word>" the text names.
///
/// Split into paragraphs first, so a heading's trailing "vivac" is never
/// paired with the next paragraph's opening word, and only a bare "vivac"
/// -- nothing glued to it, like "vivac's" or the "vivac," of a mid-sentence
/// pause -- is read as naming a command.
fn command_words_in(text: &str) -> Vec<String> {
let mut found = Vec::new();
for paragraph in text.split("\n\n") {
let words: Vec<&str> = paragraph.split_whitespace().collect();
for (i, word) in words.iter().enumerate() {
if let Some(name) = word.strip_prefix("vivac_") {
let clean: String = name
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
found.push(format!("vivac_{clean}"));
} else if *word == "vivac" {
if let Some(next) = words.get(i + 1) {
let clean: String = next
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect();
if !clean.is_empty() {
found.push(clean);
}
}
}
}
}
found
}
#[test]
fn the_skill_only_names_commands_that_exist() {
let help_sandbox = Sandbox::new_empty("setup-skill-commands-help");
let commands = help_commands(&help_sandbox.ok(&["--help"]));
let mcp_sandbox = Sandbox::new_seeded("setup-skill-commands-mcp");
let tools = mcp_tool_names(&mcp_sandbox);
let mut total = 0;
for text in [FRONTMATTER, BODY] {
for word in command_words_in(text) {
total += 1;
if let Some(rest) = word.strip_prefix("vivac_") {
assert!(
tools.contains(&word),
"the skill names the tool \"vivac_{rest}\", and vivac mcp serves no such tool"
);
continue;
}
if NOT_A_COMMAND.contains(&word.as_str()) {
continue;
}
assert!(
commands.contains(&word),
"the skill says \"vivac {word}\", and the help lists no such command"
);
}
}
assert!(
total >= 10,
"only {total} command word(s) found: what broke is the parsing, not the skill"
);
}
/// (j): `--undo` in the home folder is never refused, and removes only the
/// two hooks setup would have written, leaving every other key as it was.
#[test]
fn undo_in_the_home_folder_removes_only_the_hooks_setup_wrote() {
let c = Sandbox::new_empty("setup-two-roots-undo-home");
std::fs::create_dir_all(c.0.join(".claude")).unwrap();
let settings = serde_json::json!({
"otherKey": "z",
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": SESSION_START } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": SESSION_END } ] }
]
}
});
std::fs::write(
settings_path(&c),
serde_json::to_string_pretty(&settings).unwrap(),
)
.unwrap();
let (out, code) = run_with_home(
&c.0,
&c.0,
c.global_home(),
&["setup", "claude-code", "--undo", "--yes"],
);
assert_eq!(code, 0, "{out}");
let after: serde_json::Value = serde_json::from_str(&read(&settings_path(&c))).unwrap();
assert!(after.get("hooks").is_none(), "{after}");
assert_eq!(after["otherKey"], "z");
}
// ---------------------------------------------------------------------------
// t579 §6, §14.3 and §15.5: the written message, golden, from " Written."
// to the end. It only says what is true of the run that printed it.
// ---------------------------------------------------------------------------
fn written_part(out: &str) -> &str {
let idx = out
.find(" Written.")
.unwrap_or_else(|| panic!("no \"Written.\" in the output:\n{out}"));
&out[idx..]
}
// `d723` piece B: `setup` no longer touches the tree, so this no longer
// carries `tree_paragraph`'s sentence or the migration nudge that used to
// follow it -- both moved to `init` with the rest of the tree's own
// writes. Captured from a real run rather than hand-edited from the
// pre-piece-B text, the same discipline this section always held itself to.
const WRITTEN_MESSAGE: &str = " Written.\n\n Open a new Claude Code session in this folder. The brief arrives on its\n own when it starts. If Claude Code asks whether to use the \"vivac\" server\n from .mcp.json, say yes: it is what lets the agent write to the tree.\n\n The tree was here before this server was. If you once registered vivac\n by hand with claude mcp add, Claude Code keeps using that registration\n and not this one. To keep only this one, run from this folder:\n\n claude mcp remove vivac -s local\n\n The hooks, the server and the skill are plain files in this project:\n commit them if everyone who works here uses vivac, and keep them out of\n version control if only you do. .vivac/ is never committed: it is this\n machine's record, and a copy of it in every clone would diverge from the\n others. Its own .gitignore keeps it out.\n\n Undo: vivac setup claude-code --undo\n";
#[test]
fn a_fresh_setup_prints_the_written_message_verbatim() {
let c = Sandbox::new_empty("setup-written-golden");
c.ok(&["init", "--yes"]);
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert_eq!(written_part(&out), WRITTEN_MESSAGE);
}
// `d723` piece B removed this section's own (b): a folder joining a tree
// above it by merely resolving into it. Resolving into a tree this folder
// is not yet a lane of is `Failure::not_a_lane_yet` now; joining it is
// `init --join`'s alone.
/// (c): only the skill, which is what an upgrade writes.
const SKILL_REPLACED_MESSAGE: &str = " Written.\n\n The vivac-migrate skill is now the one this version of vivac ships.\n Sessions opened from now on use it.\n\n The hooks, the server and the skill are plain files in this project:\n commit them if everyone who works here uses vivac, and keep them out of\n version control if only you do. .vivac/ is never committed: it is this\n machine's record, and a copy of it in every clone would diverge from the\n others. Its own .gitignore keeps it out.\n";
/// (d): only the server. `--undo` would take the hooks and the skill as
/// well, so it is not offered.
///
/// `f638`, `d641`: every tree `setup` writes into already existed before
/// this run, so `HAND_REGISTERED_PARAGRAPH` follows the session paragraph
/// whenever the server is the one piece this run adds -- unconditionally
/// now, since `d723` piece B took away the only case that used to exempt
/// it (a plant, which `setup` no longer does).
const SERVER_ADDED_MESSAGE: &str = " Written.\n\n Open a new Claude Code session in this folder. The brief arrives on its\n own when it starts. If Claude Code asks whether to use the \"vivac\" server\n from .mcp.json, say yes: it is what lets the agent write to the tree.\n\n The tree was here before this server was. If you once registered vivac\n by hand with claude mcp add, Claude Code keeps using that registration\n and not this one. To keep only this one, run from this folder:\n\n claude mcp remove vivac -s local\n\n The hooks, the server and the skill are plain files in this project:\n commit them if everyone who works here uses vivac, and keep them out of\n version control if only you do. .vivac/ is never committed: it is this\n machine's record, and a copy of it in every clone would diverge from the\n others. Its own .gitignore keeps it out.\n";
// (e) used to live here: a run that only plants the tree. `d723` piece B
// removed it -- `setup` never plants, so there is no "only the tree" case
// left for it to print a message about.
#[test]
fn a_run_that_adds_only_the_server_offers_no_undo() {
let c = Sandbox::new_empty("setup-written-server-only");
c.ok(&["init", "--yes"]);
c.ok(&["setup", "claude-code", "--yes"]);
std::fs::remove_file(c.0.join(".mcp.json")).unwrap();
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert_eq!(written_part(&out), SERVER_ADDED_MESSAGE);
}
// ---------------------------------------------------------------------------
// `f638`, `d641`: the hand-registered-server paragraph.
//
// Before `setup` existed, the README told people to run
// `claude mcp add vivac -- vivac mcp`, which registers the server in Claude
// Code's local scope. `setup claude-code` only ever looks at the project's
// own `.mcp.json`, so for someone who did that, a later `setup` run adds a
// second, unused entry there -- and Claude Code never asks to approve it,
// contradicting the session paragraph's own "say yes" just above it.
//
// `setup` never opens a harness's personal configuration to check for a
// hand-made registration directly: for Claude Code that file also holds the
// sign-in session, and the security pillar vetoes opening it. So this is
// inferred from the project instead, and only when both hold: the tree was
// already here before this run, and this run is the one adding the "vivac"
// server to `.mcp.json`.
// ---------------------------------------------------------------------------
const HAND_REGISTERED_MARKER: &str = "The tree was here before this server was.";
/// The session paragraph's own last line, unique to it: the one text this
/// paragraph is required to follow immediately.
const SESSION_PARAGRAPH_END: &str =
"from .mcp.json, say yes: it is what lets the agent write to the tree.\n";
/// Whether `out` carries the hand-registered paragraph exactly once, right
/// after the session paragraph and nowhere else.
fn hand_registered_paragraph_is_right_after_session(out: &str) -> bool {
if out.matches(HAND_REGISTERED_MARKER).count() != 1 {
return false;
}
let Some(session_end) = out.find(SESSION_PARAGRAPH_END) else {
return false;
};
out[session_end + SESSION_PARAGRAPH_END.len()..]
.starts_with(&format!("\n {HAND_REGISTERED_MARKER}"))
}
#[test]
fn a_tree_planted_before_this_run_with_no_mcp_json_gets_the_hand_registered_paragraph() {
let c = Sandbox::new_seeded("setup-hand-registered-fresh-mcp");
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(
hand_registered_paragraph_is_right_after_session(&out),
"{out}"
);
}
#[test]
fn a_tree_planted_before_this_run_with_a_different_mcp_server_gets_the_hand_registered_paragraph() {
let c = Sandbox::new_seeded("setup-hand-registered-other-server");
std::fs::write(
mcp_path(&c),
"{\n \"mcpServers\": {\n \"other\": {\n \"type\": \"stdio\",\n \
\"command\": \"other-tool\"\n }\n }\n}\n",
)
.unwrap();
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(
hand_registered_paragraph_is_right_after_session(&out),
"{out}"
);
}
/// `d723` piece B: every tree `setup` writes into already existed before
/// this run, so there is no longer a "freshly planted, so no risk of a
/// hand-made registration" case for it to stay quiet about -- `setup` on a
/// tree `init` just planted a moment ago gets the paragraph exactly the
/// same as one that has been there for years, since `setup` itself cannot
/// tell the two apart any more (that information belonged to the plan of
/// the tree side, which `setup` no longer builds).
#[test]
fn a_tree_init_just_planted_still_gets_the_hand_registered_paragraph() {
let c = Sandbox::new_empty("setup-hand-registered-fresh-plant");
c.ok(&["init", "--yes"]);
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(
hand_registered_paragraph_is_right_after_session(&out),
"{out}"
);
}
#[test]
fn a_tree_that_already_has_the_vivac_server_never_gets_the_hand_registered_paragraph() {
let c = Sandbox::new_seeded("setup-hand-registered-server-present");
std::fs::write(
mcp_path(&c),
"{\n \"mcpServers\": {\n \"vivac\": {\n \"type\": \"stdio\",\n \
\"command\": \"vivac\",\n \"args\": [\"mcp\"]\n }\n }\n}\n",
)
.unwrap();
let out = c.ok(&["setup", "claude-code", "--yes"]);
assert!(!out.contains(HAND_REGISTERED_MARKER), "{out}");
}
#[test]
fn dry_run_never_prints_the_hand_registered_paragraph() {
let c = Sandbox::new_seeded("setup-hand-registered-dry-run");
let (out, code) = c.run(&["setup", "claude-code", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(!out.contains(HAND_REGISTERED_MARKER), "{out}");
}
// ---------------------------------------------------------------------------
// `t594`: the tracked-log warning, in all three runs.
// ---------------------------------------------------------------------------
fn git(dir: &Path, args: &[&str]) {
let st = std::process::Command::new("git")
.current_dir(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@example.invalid"])
.args(args)
.status()
.unwrap();
assert!(st.success(), "git {args:?}");
}
fn track_the_log(c: &Sandbox) {
git(&c.0, &["init", "-q"]);
git(&c.0, &["add", "-f", ".vivac/events"]);
git(&c.0, &["commit", "-q", "-m", "track the log by mistake"]);
}
/// The warning used to sit after `--dry-run`'s own early exit, so a plan
/// never carried it. `d723` piece B: showing it is `init`'s job now, since
/// `setup` no longer builds a plan of the tree side at all.
#[test]
fn dry_run_warns_about_a_tracked_log() {
let c = Sandbox::new_seeded("tracked-dry-run");
track_the_log(&c);
let (out, code) = c.run(&["init", "--dry-run"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("Nothing written: --dry-run."), "{out}");
assert!(
out.contains(".vivac/events is tracked by git here"),
"{out}"
);
}
/// And it used to sit after "nothing to write" as well, so whoever was
/// already set up -- the one person who never reaches a run that writes
/// something -- never saw it at all. `d723` piece B: `init`'s own.
#[test]
fn an_already_set_up_project_still_warns_about_a_tracked_log() {
let c = Sandbox::new_seeded("tracked-nothing-to-write");
// Before the first `init --yes`, not after: tracking the log with `git
// init` also turns this folder into a repository of its own, and a
// repository appearing *between* two runs is a real change for the
// lane to redeclare, not nothing to write.
track_the_log(&c);
c.ok(&["init", "--yes"]);
let (out, code) = c.run(&["init", "--yes"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("Nothing to write: this project is already set up."),
"{out}"
);
assert!(
out.contains(".vivac/events is tracked by git here"),
"{out}"
);
}
// ---------------------------------------------------------------------------
// A planted tree never shows up in `git status`: `.vivac/.gitignore` is the
// whole promise, checked against real git rather than assumed.
// ---------------------------------------------------------------------------
/// A repository that already has something to report keeps reporting it,
/// and nothing about the tree planted beside it joins that report.
#[test]
fn git_status_shows_nothing_of_a_planted_tree() {
let c = Sandbox::new_empty("git-status-hides-vivac");
git(&c.0, &["init", "-q"]);
std::fs::write(c.0.join("tracked.txt"), "kept").unwrap();
git(&c.0, &["add", "tracked.txt"]);
git(
&c.0,
&["commit", "-q", "-m", "a file the repository already has"],
);
std::fs::write(c.0.join("untracked.txt"), "new").unwrap();
c.ok(&["init", "--yes"]);
let out = std::process::Command::new("git")
.current_dir(&c.0)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(out.status.success(), "git status failed to run");
let status = String::from_utf8_lossy(&out.stdout);
assert!(
status.contains("untracked.txt"),
"git status stopped reporting a real change: {status}"
);
assert!(
!status.contains(".vivac"),
"git status mentioned the planted tree: {status}"
);
}
// ---------------------------------------------------------------------------
// `d723` piece B split four of the original `t594`/`t640` tests in two:
// each asserted a single `setup` run doing both the tree's own write and
// the harness's, which no longer happens in one command. Neither half was
// dropped; each is covered by name, one line per retired test:
//
// join_yes_in_a_clean_folder_writes_the_harness_and_the_lane
// tree half: tests/init.rs::init_join_declares_a_lane_of_the_target_rather_than_planting
// harness half: a_fresh_project_gets_the_three_harness_pieces (above)
// a_new_folder_under_a_tree_is_told_the_tree_was_already_there
// tree half: tests/init.rs::init_join_declares_a_lane_of_the_target_rather_than_planting
// harness half: a_fresh_setup_prints_the_written_message_verbatim (above)
// a_run_that_plants_only_the_tree_invites_a_migration
// tree half: tests/init.rs::planting_a_fresh_tree_still_carries_the_plants_own_migrate_paragraph
// harness half: a_fresh_setup_prints_the_written_message_verbatim (above)
// undo_removes_an_unwritten_lane_file_even_when_nothing_else_is_left
// covered whole by the three `d680` tests tests/init.rs already ports:
// undo_after_a_join_that_wrote_nothing_removes_the_lane_file_and_unblocks_relocate,
// undo_after_a_join_that_wrote_something_keeps_the_lane_file,
// undo_leaves_a_vivac_dir_whose_gitignore_was_hand_edited
// ---------------------------------------------------------------------------