#![cfg(unix)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const BEGIN: &str = "# >>> semlith >>>";
struct Machine {
_dir: tempfile::TempDir,
home: PathBuf,
store_home: PathBuf,
cache: PathBuf,
}
impl Machine {
fn new() -> Self {
let dir = tempfile::tempdir().expect("a temp directory");
let home = dir.path().join("home");
let cache = dir.path().join("cache");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("seeded"), b"weights would be here").unwrap();
std::fs::write(home.join(".zshrc"), "# a shell rc somebody already owns\n").unwrap();
Self {
store_home: home.join(".semlith"),
home,
cache,
_dir: dir,
}
}
fn setup_without_shell(&self, args: &[&str]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_semlith"));
command
.arg("setup")
.args(args)
.env("HOME", &self.home)
.env_remove("SHELL")
.env("SEMLITH_HOME", &self.store_home)
.env("SEMLITH_MODEL_CACHE", &self.cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null());
command.output().expect("running semlith setup")
}
fn setup(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("setup")
.args(args)
.env("HOME", &self.home)
.env("SHELL", "/bin/zsh")
.env("SEMLITH_HOME", &self.store_home)
.env("SEMLITH_MODEL_CACHE", &self.cache)
.env("PATH", "/usr/bin:/bin")
.stdin(std::process::Stdio::null())
.output()
.expect("running semlith setup")
}
fn said(out: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn rc(&self) -> String {
std::fs::read_to_string(self.home.join(".zshrc")).expect("the rc file")
}
}
fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(bytes) = std::fs::read(&path) {
out.insert(path, bytes);
}
}
}
out
}
#[test]
fn a_second_run_changes_nothing() {
let machine = Machine::new();
let first = machine.setup(&["--yes", "--airgap"]);
assert!(
first.status.success(),
"the first `semlith setup --yes --airgap` exited {:?}:\n{}",
first.status.code(),
String::from_utf8_lossy(&first.stderr)
);
assert_eq!(
machine.rc().matches(BEGIN).count(),
1,
"the first run should leave exactly one semlith block in the rc file, got:\n{}",
machine.rc()
);
let before = snapshot(&machine.home);
let second = machine.setup(&["--yes", "--airgap"]);
assert!(
second.status.success(),
"the second run exited {:?}:\n{}",
second.status.code(),
String::from_utf8_lossy(&second.stderr)
);
assert_eq!(
machine.rc().matches(BEGIN).count(),
1,
"the second run added a second semlith block:\n{}",
machine.rc()
);
assert_eq!(
snapshot(&machine.home),
before,
"the second run changed a file; setup is meant to be a no-op once it has run"
);
let said = Machine::said(&second);
assert!(
said.contains("Nothing changed"),
"the second run should say it changed nothing, it said:\n{said}"
);
}
#[test]
fn the_users_own_rc_file_survives() {
let machine = Machine::new();
let original = machine.rc();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(run.status.success());
let after = machine.rc();
assert!(
after.starts_with(&original),
"setup rewrote what was already in the rc file.\nbefore:\n{original}\nafter:\n{after}"
);
assert!(
after.contains(".semlith/bin"),
"the block should put the bin directory on PATH:\n{after}"
);
}
#[test]
fn yes_completes_with_stdin_closed_and_registers_no_agent() {
let machine = Machine::new();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(
run.status.success(),
"`--yes` with stdin closed exited {:?}",
run.status.code()
);
let said = Machine::said(&run);
assert!(
said.contains("registers no agent"),
"`--yes` should say it registered no agent, it said:\n{said}"
);
}
#[test]
fn airgap_with_an_empty_cache_skips_the_model_and_exits_zero() {
let machine = Machine::new();
std::fs::remove_file(machine.cache.join("seeded")).unwrap();
let run = machine.setup(&["--yes", "--airgap"]);
assert!(
run.status.success(),
"--airgap with an empty cache should still exit zero, it exited {:?}",
run.status.code()
);
let said = Machine::said(&run);
assert!(
said.contains("--airgap") && said.contains(machine.cache.to_str().unwrap()),
"the skip note should name --airgap and the cache to pre-seed:\n{said}"
);
}
#[test]
fn an_unset_shell_still_gets_a_path_block() {
let machine = Machine::new();
let run = machine.setup_without_shell(&["--yes", "--airgap"]);
assert!(
run.status.success(),
"setup with no SHELL exited {:?}:\n{}",
run.status.code(),
Machine::said(&run)
);
let profile = machine.home.join(".profile");
let written = std::fs::read_to_string(&profile)
.unwrap_or_else(|e| panic!("setup wrote no {}: {e}", profile.display()));
assert!(
written.contains(BEGIN) && written.contains(".semlith/bin"),
"the fallback rc file has no semlith block:\n{written}"
);
let said = Machine::said(&run);
assert!(
!said.contains("no HOME"),
"setup blamed a missing HOME when only SHELL was unset:\n{said}"
);
}