mod claude_code;
mod codex;
mod init;
pub mod json;
mod tree;
use crate::args::Args;
use crate::failure::Failure;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
const HARNESSES: &[&str] = &["claude-code", "codex"];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Harness {
ClaudeCode,
Codex,
}
impl Harness {
pub(super) fn word(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code",
Self::Codex => "codex",
}
}
}
const MOVED_TO_INIT: &[&str] = &["join", "new-tree", "lane-name", "name"];
fn moved_to_init_tombstone(a: &Args, h: Harness) -> Option<Failure> {
let flag = MOVED_TO_INIT.iter().find(|f| a.has(f))?;
let message = format!(
" --{flag} is vivac init's, not setup's: which tree this folder belongs to \
reads the same wherever an agent is opened, so it is not a harness's question \
to answer.\n\n Run vivac init --{flag} first, then vivac setup {} here.",
h.word()
);
Some(Failure::Usage(message))
}
pub fn dispatch(cwd: &Path, a: &Args) -> Result<i32, Failure> {
if a.has("dry-run") && a.has("yes") {
return Err(Failure::usage(
"--dry-run writes nothing, so there is nothing for --yes to confirm.\n\n \
Give one or the other.",
));
}
if let [first, ..] = a.extra(1) {
return Err(Failure::usage(format!(
"setup does not take \"{first}\".\n\n It takes one word of its own: the harness to set up."
)));
}
let Some(harness) = a.positional(0) else {
return Err(Failure::usage(
"vivac setup needs the harness to set up: vivac setup claude-code\n \
It knows claude-code and codex today.",
));
};
let h = match harness {
"claude-code" => Harness::ClaudeCode,
"codex" => Harness::Codex,
other => {
return Err(Failure::usage(format!(
"vivac setup does not know \"{other}\" yet. It knows: {}",
HARNESSES.join(", ")
)))
}
};
if let Some(tombstone) = moved_to_init_tombstone(a, h) {
return Err(tombstone);
}
match h {
Harness::ClaudeCode => claude_code::run(cwd, a),
Harness::Codex => codex::run(cwd, a),
}
}
pub fn init(cwd: &Path, a: &Args) -> Result<i32, Failure> {
if a.has("dry-run") && a.has("yes") {
return Err(Failure::usage(
"--dry-run writes nothing, so there is nothing for --yes to confirm.\n\n \
Give one or the other.",
));
}
if a.has("join") && a.has("new-tree") {
return Err(Failure::usage(
"--join joins a tree that already exists, and --new-tree plants a \
separate one, so they contradict each other.\n\n Give one or the other.",
));
}
if a.has("name") && a.has("join") {
return Err(Failure::usage(
"--name names a product for the first time, and --join always joins \
one that already has a name, so they contradict each other.\n\n \
Give one or the other.",
));
}
if a.has("name") && a.has("undo") {
return Err(Failure::usage(
"--name names a product while planting it, and --undo only takes \
earlier writes back, so they contradict each other.\n\n \
Give one or the other.",
));
}
if a.has("join") && a.opt("join").is_none() {
return Err(Failure::usage(
"--join needs the project to join, and nothing followed it. Without \
that word init plants instead of joining, which is a second tree \
for a product that already has one.\n\n \
vivac init --join <project>",
));
}
if a.has("name") && a.opt("name").is_none() {
return Err(Failure::usage(
"--name needs the product's own name, and nothing followed it. \
Without that word init has nothing to save.\n\n \
vivac init --name <name>",
));
}
init::run(cwd, a)
}
pub struct Roots {
pub here: PathBuf,
pub tree: PathBuf,
pub located: Option<crate::store::Located>,
}
fn refuse_registry_as_tree(tree: &Path) -> Option<Failure> {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let is_registry = crate::store::store_dir().is_some_and(|d| canon(&d) == canon(tree));
is_registry.then(|| registry_refusal(tree))
}
pub fn resolve_roots(cwd: &Path) -> Result<Roots, Failure> {
let located = crate::store::locate_for_planting(cwd)?;
let tree = located
.as_ref()
.map(|l| l.root.clone())
.unwrap_or_else(|| cwd.to_path_buf());
if let Some(refusal) = refuse_registry_as_tree(&tree) {
return Err(refusal);
}
Ok(Roots {
here: cwd.to_path_buf(),
tree,
located,
})
}
pub(super) fn resolve_for_setup(cwd: &Path) -> Result<Roots, Failure> {
let Some(located) = crate::store::locate(cwd)? else {
return Err(Failure::SetupNoTree);
};
if !crate::anchor::same_folder(&located.lane_dir, cwd) {
return Err(Failure::not_a_lane_yet());
}
let tree = located.root.clone();
if let Some(refusal) = refuse_registry_as_tree(&tree) {
return Err(refusal);
}
Ok(Roots {
here: cwd.to_path_buf(),
tree,
located: Some(located),
})
}
fn registry_refusal(path: &Path) -> Failure {
Failure::Model(format!(
" {} holds the registry of the trees on this machine, so it cannot\n \
hold a tree too. Run setup inside a project.",
path.display()
))
}
pub fn refuse_home_or_global_store(roots: &Roots) -> Option<Failure> {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
if crate::store::home_dir().is_some_and(|h| canon(&h) == canon(&roots.here)) {
return Some(Failure::Model(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.",
roots.here.display()
)));
}
let vivac_dir = roots.tree.join(crate::store::DIR);
let is_global_store = crate::registry::marks_global_store(&vivac_dir)
|| crate::store::store_dir().is_some_and(|d| canon(&d) == canon(&vivac_dir));
if is_global_store {
return Some(registry_refusal(&vivac_dir));
}
None
}
pub fn git_root_above(dir: &Path) -> Option<PathBuf> {
let mut d = dir.to_path_buf();
while d.pop() {
if d.join(".git").exists() {
return Some(d);
}
}
None
}
pub enum Action {
Write(String),
Delete,
}
type PreservedCheck = Box<dyn Fn(&json::Value) -> bool>;
pub struct PlannedWrite {
pub path: PathBuf,
pub action: Action,
pub original: Option<Vec<u8>>,
pub preserved: Option<PreservedCheck>,
}
impl PlannedWrite {
pub fn write(path: PathBuf, content: String, original: Option<Vec<u8>>) -> PlannedWrite {
PlannedWrite {
path,
action: Action::Write(content),
original,
preserved: None,
}
}
pub fn delete(path: PathBuf, original: Vec<u8>) -> PlannedWrite {
PlannedWrite {
path,
action: Action::Delete,
original: Some(original),
preserved: None,
}
}
}
fn sibling_temp(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".tmp");
path.with_file_name(name)
}
fn write_atomic(path: &Path, content: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = sibling_temp(path);
let written = std::fs::write(&tmp, content).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
fn remove_if_present(path: &Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn apply_action(w: &PlannedWrite) -> std::io::Result<()> {
match &w.action {
Action::Write(content) => write_atomic(&w.path, content.as_bytes()),
Action::Delete => remove_if_present(&w.path),
}
}
fn restore_one(w: &PlannedWrite) -> std::io::Result<()> {
match &w.original {
Some(bytes) => write_atomic(&w.path, bytes),
None => remove_if_present(&w.path),
}
}
fn verify_one(w: &PlannedWrite) -> bool {
match &w.action {
Action::Write(content) => {
let Ok(actual) = std::fs::read(&w.path) else {
return false;
};
if actual != content.as_bytes() {
return false;
}
match &w.preserved {
Some(check) => std::str::from_utf8(&actual)
.ok()
.and_then(|s| json::parse(s).ok())
.is_some_and(|v| check(&v)),
None => true,
}
}
Action::Delete => !w.path.exists(),
}
}
pub fn rollback(writes: &[PlannedWrite]) -> Vec<PathBuf> {
let mut failed = Vec::new();
for w in writes.iter().rev() {
if restore_one(w).is_err() {
failed.push(w.path.clone());
}
}
failed
}
enum Cause {
Write(std::io::Error),
Verify,
}
pub fn failure_with_rollback(clause: String, unrestored: &[PathBuf]) -> Failure {
let message = if unrestored.is_empty() {
format!("{clause}, so setup put every file it\n touched back as it was.")
} else {
let mut m = format!("{clause}, and setup could not put these back as they were:\n");
for p in unrestored {
m.push_str(&format!(" {}\n", p.display()));
}
m.push_str(
" setup keeps no copy on disk, so the only other copy is whatever\n \
version control holds.",
);
m
};
Failure::Io(std::io::Error::other(message))
}
fn rollback_failure(path: &Path, cause: Cause, writes: &[PlannedWrite]) -> Failure {
let unrestored = rollback(writes);
let clause = match cause {
Cause::Write(e) => format!("{} could not be written ({e})", path.display()),
Cause::Verify => format!("{} did not read back as written", path.display()),
};
failure_with_rollback(clause, &unrestored)
}
pub fn commit(writes: &[PlannedWrite]) -> Result<(), Failure> {
for (i, w) in writes.iter().enumerate() {
if let Err(e) = apply_action(w) {
return Err(rollback_failure(&w.path, Cause::Write(e), &writes[..i]));
}
}
for w in writes {
if !verify_one(w) {
return Err(rollback_failure(&w.path, Cause::Verify, writes));
}
}
Ok(())
}
pub fn is_yes(answer: &str) -> bool {
matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
pub fn ask(prompt: &str) -> bool {
use std::io::Write;
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
is_yes(&line)
}
pub fn stdin_is_terminal() -> bool {
std::io::stdin().is_terminal()
}
fn quoted_if_it_has_a_space(value: &str) -> String {
if value.contains(' ') {
format!("\"{value}\"")
} else {
value.to_string()
}
}
fn no_terminal_flags(a: &Args) -> String {
let mut s = String::new();
if a.has("undo") {
s.push_str(" --undo");
}
if let Some(v) = a.opt("join") {
s.push_str(" --join ");
s.push_str("ed_if_it_has_a_space(v));
}
if a.has("new-tree") {
s.push_str(" --new-tree");
}
if let Some(v) = a.opt("name") {
s.push_str(" --name ");
s.push_str("ed_if_it_has_a_space(v));
}
if let Some(v) = a.opt("lane-name") {
s.push_str(" --lane-name ");
s.push_str("ed_if_it_has_a_space(v));
}
s
}
pub(super) fn no_terminal_text(h: Harness, a: &Args) -> String {
let flags = no_terminal_flags(a);
let word = h.word();
if a.has("undo") {
return format!(
" setup asks before removing anything, and there is no terminal to ask.\n See what it would remove: vivac setup {word}{flags} --dry-run\n Then remove it: vivac setup {word}{flags} --yes"
);
}
format!(
" setup asks before writing, and there is no terminal here to ask.\n See what it would write: vivac setup {word}{flags} --dry-run\n Then write it: vivac setup {word}{flags} --yes"
)
}
pub(super) fn init_no_terminal_text(a: &Args) -> String {
let flags = no_terminal_flags(a);
if a.has("undo") {
return format!(
" init asks before removing anything, and there is no terminal to ask.\n See what it would remove: vivac init{flags} --dry-run\n Then remove it: vivac init{flags} --yes"
);
}
format!(
" init asks before writing, and there is no terminal here to ask.\n See what it would write: vivac init{flags} --dry-run\n Then write it: vivac init{flags} --yes"
)
}
pub fn fnv1a64(data: &[u8]) -> u64 {
const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x100000001b3;
let mut hash = OFFSET_BASIS;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv1a64_matches_the_reference_vectors() {
assert_eq!(fnv1a64(b""), 0xcbf29ce484222325);
assert_eq!(fnv1a64(b"a"), 0xaf63dc4c8601ec8c);
}
#[test]
fn is_yes_takes_any_case_of_y_or_yes() {
for s in ["y", "Y", "yes", "YES", "Yes", " y ", "y\n"] {
assert!(is_yes(s), "{s:?} should count as yes");
}
for s in ["n", "no", "", "yep", "sure"] {
assert!(!is_yes(s), "{s:?} should not count as yes");
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vivac-setup-{name}-{}", crate::id::ulid()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_failed_rename_leaves_no_temporary_behind() {
let dir = temp_dir("rename-fails");
let target = dir.join("settings.json");
std::fs::create_dir_all(target.join("occupied")).unwrap();
assert!(write_atomic(&target, b"{\"env\":{}}").is_err());
assert!(
!sibling_temp(&target).exists(),
"the temporary survived the failed rename"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_committed_write_leaves_every_file_as_written() {
let dir = temp_dir("commit-ok");
let writes = vec![
PlannedWrite::write(dir.join("a.txt"), "A".to_string(), None),
PlannedWrite::write(dir.join("b.txt"), "B".to_string(), None),
];
commit(&writes).unwrap();
assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "A");
assert_eq!(std::fs::read_to_string(dir.join("b.txt")).unwrap(), "B");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_write_failure_rolls_the_earlier_files_back() {
let dir = temp_dir("commit-write-fails");
let first = dir.join("first.txt");
let second = dir.join("second.txt");
std::fs::write(&first, "original first").unwrap();
std::fs::create_dir(&second).unwrap();
let writes = vec![
PlannedWrite::write(
first.clone(),
"new first".into(),
Some(b"original first".to_vec()),
),
PlannedWrite::write(second.clone(), "new second".into(), None),
];
let err = commit(&writes).unwrap_err();
let msg = err.message();
assert!(msg.contains("could not be written"), "{msg}");
assert!(msg.contains("put every file it"), "{msg}");
assert_eq!(std::fs::read_to_string(&first).unwrap(), "original first");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_verification_failure_rolls_every_file_back() {
let dir = temp_dir("commit-verify-fails");
let first = dir.join("first.txt");
let second = dir.join("second.txt");
std::fs::write(&first, "original first").unwrap();
let writes = vec![
PlannedWrite::write(
first.clone(),
"new first".into(),
Some(b"original first".to_vec()),
),
PlannedWrite {
path: second.clone(),
action: Action::Write("new second".into()),
original: None,
preserved: Some(Box::new(|_| false)),
},
];
let err = commit(&writes).unwrap_err();
assert!(err.message().contains("did not read back as written"));
assert_eq!(std::fs::read_to_string(&first).unwrap(), "original first");
assert!(
!second.exists(),
"a file with no original should be removed"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_failed_delete_rolls_a_sibling_write_back() {
let dir = temp_dir("commit-delete-fails");
let written = dir.join("written.txt");
let undone = dir.join("undone.txt");
std::fs::write(&written, "original written").unwrap();
std::fs::create_dir(&undone).unwrap();
let writes = vec![
PlannedWrite::write(
written.clone(),
"new written".into(),
Some(b"original written".to_vec()),
),
PlannedWrite::delete(undone.clone(), b"{}".to_vec()),
];
let err = commit(&writes).unwrap_err();
assert!(
err.message().contains("could not be written")
|| err.message().contains("Input/output")
);
assert_eq!(
std::fs::read_to_string(&written).unwrap(),
"original written"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unrestorable_file_is_named_rather_than_claimed_fixed() {
let dir = temp_dir("commit-unrestorable");
let first = dir.join("first.txt");
let second = dir.join("second.txt");
std::fs::write(&second, "second").unwrap();
std::fs::create_dir(&first).unwrap();
let writes = vec![
PlannedWrite::write(
first.clone(),
"new first".into(),
Some(b"original first".to_vec()),
),
PlannedWrite::write(
second.clone(),
"new second".into(),
Some(b"second".to_vec()),
),
];
let unrestored = rollback(&writes);
assert_eq!(unrestored, vec![first.clone()]);
let msg = failure_with_rollback("something failed".to_string(), &unrestored).message();
assert!(
msg.contains("could not put these back as they were"),
"{msg}"
);
assert!(msg.contains(&first.display().to_string()), "{msg}");
assert!(msg.contains("version control"), "{msg}");
std::fs::remove_dir_all(&dir).ok();
}
}