mod claude_code;
pub mod json;
use crate::args::Args;
use crate::failure::Failure;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
const HARNESSES: &[&str] = &["claude-code"];
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 today.",
));
};
match harness {
"claude-code" => claude_code::run(&resolve_roots(cwd)?, a),
other => Err(Failure::usage(format!(
"vivac setup does not know \"{other}\" yet. It knows: {}",
HARNESSES.join(", ")
))),
}
}
pub struct Roots {
pub here: PathBuf,
pub tree: PathBuf,
}
pub fn resolve_roots(cwd: &Path) -> Result<Roots, Failure> {
let tree = crate::store::find_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
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));
if is_registry {
return Err(registry_refusal(&tree));
}
Ok(Roots {
here: cwd.to_path_buf(),
tree,
})
}
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()
}
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();
}
}