use crate::anchor;
use crate::failure::Failure;
use crate::{clock, id};
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
pub const DIR: &str = ".vivac";
pub const LOG: &str = "events";
pub const CONFIG: &str = "config";
pub const INDEX: &str = "index";
pub const LOCK: &str = "lock";
pub const LANE: &str = "lane";
pub(crate) const LOCK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5);
const LOCK_SPIN: std::time::Duration = std::time::Duration::from_millis(50);
static WROTE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn mark_write() {
WROTE.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn wrote() -> bool {
WROTE.load(std::sync::atomic::Ordering::Relaxed)
}
static SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn mark_shown() {
SHOWN.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn shown() -> bool {
SHOWN.load(std::sync::atomic::Ordering::Relaxed)
}
static RESIDENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn mark_resident() {
RESIDENT.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn is_resident() -> bool {
RESIDENT.load(std::sync::atomic::Ordering::Relaxed)
}
pub const GITIGNORE: &str = ".gitignore";
pub fn store_dir() -> Option<PathBuf> {
let vivac_home = std::env::var_os("VIVAC_HOME");
#[cfg(test)]
if non_blank(vivac_home.as_deref()).is_none() {
panic!(
"store::store_dir() called with no VIVAC_HOME set. A test that reaches \
this would read or write this machine's real registry. Set VIVAC_HOME to \
a fresh temporary directory before calling anything that resolves the \
store -- store::locate, registry::note and its own callers, relocate -- \
the same way every test under tests/ already does through Sandbox."
);
}
resolve_store_dir(
vivac_home.as_deref(),
std::env::var_os("HOME").as_deref(),
std::env::var_os("USERPROFILE").as_deref(),
)
}
fn resolve_store_dir(
vivac_home: Option<&OsStr>,
home: Option<&OsStr>,
userprofile: Option<&OsStr>,
) -> Option<PathBuf> {
if let Some(v) = non_blank(vivac_home) {
return Some(PathBuf::from(v));
}
resolve_home_dir(home, userprofile).map(|h| h.join(DIR))
}
pub fn home_dir() -> Option<PathBuf> {
resolve_home_dir(
std::env::var_os("HOME").as_deref(),
std::env::var_os("USERPROFILE").as_deref(),
)
}
fn resolve_home_dir(home: Option<&OsStr>, userprofile: Option<&OsStr>) -> Option<PathBuf> {
if let Some(h) = non_blank(home) {
return Some(PathBuf::from(h));
}
if let Some(u) = non_blank(userprofile) {
return Some(PathBuf::from(u));
}
None
}
fn non_blank(v: Option<&OsStr>) -> Option<&OsStr> {
let v = v?;
match v.to_str() {
Some(s) if s.trim().is_empty() => None,
_ => Some(v),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigVersion {
One,
Locked,
Lanes,
}
pub const LOCK_SENTENCE: &str =
"this tree holds pillars and rules, and this vivac is too old to read them: update vivac";
pub const LANE_SENTENCE: &str =
"this tree holds lanes, and this vivac is too old to read them: update vivac";
impl Serialize for ConfigVersion {
fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ConfigVersion::One => s.serialize_u32(1),
ConfigVersion::Locked => s.serialize_str(LOCK_SENTENCE),
ConfigVersion::Lanes => s.serialize_str(LANE_SENTENCE),
}
}
}
impl<'de> Deserialize<'de> for ConfigVersion {
fn deserialize<D>(d: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(d)?;
match &v {
serde_json::Value::Number(n) if n.as_u64() == Some(1) => Ok(ConfigVersion::One),
serde_json::Value::String(s) if s == LOCK_SENTENCE => Ok(ConfigVersion::Locked),
serde_json::Value::String(s) if s == LANE_SENTENCE => Ok(ConfigVersion::Lanes),
_ => Err(serde::de::Error::custom("unsupported config version")),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub version: ConfigVersion,
pub project_id: String,
pub actor: String,
}
impl Config {
fn new_seeded() -> Config {
Config {
version: ConfigVersion::One,
project_id: id::ulid(),
actor: format!("a_{}", &id::ulid()[..12]),
}
}
}
pub struct Store {
pub root: PathBuf,
pub config: Config,
log_present: bool,
}
pub fn find_root(from_dir: &Path) -> Result<Option<PathBuf>, Failure> {
let mut d = from_dir.to_path_buf();
let mut passed_a_hollow_one = false;
loop {
let candidate = d.join(DIR);
if candidate.is_dir() && !crate::registry::marks_global_store(&candidate) {
if already_planted(&d) || candidate.join(LANE).is_file() {
if passed_a_hollow_one {
return Err(hollow_vivac_refusal(&d));
}
return Ok(Some(d));
}
passed_a_hollow_one = true;
}
if !d.pop() {
return Ok(None);
}
}
}
const REFUSAL_WIDTH: usize = 76;
const REFUSAL_INDENT: &str = " ";
fn hollow_vivac_refusal(tree_above: &Path) -> Failure {
let name = tree_above
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.and_then(|n| match crate::redact::check_field("folder name", &n) {
Some(_) => None,
None => Some(n),
});
let label = crate::registry::label_for(name.as_deref());
let prose = format!(
"This folder has a .vivac/ that is neither a tree nor a lane: no log, no \
config, no lane file. A tree sits above it, in {label}. Which of the two \
this folder belongs to is a guess, and this tool does not guess."
);
let mut lines =
crate::render::wrap(&prose, REFUSAL_WIDTH - REFUSAL_INDENT.len(), REFUSAL_INDENT);
lines.push(String::new());
lines.push(format!(
"{REFUSAL_INDENT}Make it a tree of its own: vivac init"
));
lines.push(format!(
"{REFUSAL_INDENT}Or hand it back to the tree above by deleting the empty .vivac/ here."
));
Failure::Model(lines.join("\n"))
}
#[derive(Debug)]
pub struct Located {
pub root: PathBuf,
pub lane_dir: PathBuf,
pub lane: Option<crate::lane::Lane>,
pub worktree: Option<PathBuf>,
}
pub fn locate(from_dir: &Path) -> Result<Option<Located>, Failure> {
locate_from(from_dir, store_dir().as_deref())
}
fn locate_from(from_dir: &Path, registry_dir: Option<&Path>) -> Result<Option<Located>, Failure> {
let worktree = anchor::linked_worktree(from_dir);
let mut found = locate_here(from_dir, registry_dir)?;
if found.is_none() {
if let Some(worktree_root) = &worktree {
if let Some(main_root) = anchor::main_copy_of(worktree_root) {
found = locate_here(&main_root, registry_dir)?;
}
}
}
Ok(found.map(|mut l| {
l.worktree = worktree;
l
}))
}
fn locate_here(from_dir: &Path, registry_dir: Option<&Path>) -> Result<Option<Located>, Failure> {
let Some(d) = find_root(from_dir)? else {
return Ok(None);
};
match crate::lane::read(&d.join(DIR))? {
Some(l) => resolve_lane(&d, l, registry_dir).map(Some),
None => Ok(Some(Located {
root: d.clone(),
lane_dir: d,
lane: None,
worktree: None,
})),
}
}
fn resolve_lane(
lane_dir: &Path,
lane: crate::lane::Lane,
registry_dir: Option<&Path>,
) -> Result<Located, Failure> {
if already_planted(lane_dir)
&& first_event_id(lane_dir).as_deref() == Some(lane.project.as_str())
{
return Ok(Located {
root: lane_dir.to_path_buf(),
lane_dir: lane_dir.to_path_buf(),
lane: Some(lane),
worktree: None,
});
}
let mut up = lane_dir.to_path_buf();
while up.pop() {
if already_planted(&up) && first_event_id(&up).as_deref() == Some(lane.project.as_str()) {
return Ok(Located {
root: up,
lane_dir: lane_dir.to_path_buf(),
lane: Some(lane),
worktree: None,
});
}
}
if let Some(registry_dir) = registry_dir {
if let Some(root) = crate::registry::root_of(registry_dir, &lane.project) {
return Ok(Located {
root,
lane_dir: lane_dir.to_path_buf(),
lane: Some(lane),
worktree: None,
});
}
}
if let Some(worktree_root) = crate::anchor::linked_worktree(lane_dir) {
if let Some(main_root) = crate::anchor::main_copy_of(&worktree_root) {
let mut up = main_root;
loop {
if already_planted(&up)
&& first_event_id(&up).as_deref() == Some(lane.project.as_str())
{
return Ok(Located {
root: up,
lane_dir: lane_dir.to_path_buf(),
lane: Some(lane),
worktree: None,
});
}
if !up.pop() {
break;
}
}
}
}
Err(Failure::tree_not_found())
}
pub fn already_planted(root: &Path) -> bool {
let dir = root.join(DIR);
dir.join(CONFIG).is_file() || dir.join(LOG).is_file()
}
pub fn write_gitignore(vivac_dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(vivac_dir)?;
let ignore = vivac_dir.join(GITIGNORE);
if !ignore.exists() {
fs::write(&ignore, "*\n")?;
}
Ok(())
}
pub(crate) fn fingerprint(log: &Path) -> (u64, Option<std::time::SystemTime>) {
match fs::metadata(log) {
Ok(m) => (m.len(), m.modified().ok()),
Err(_) => (0, None),
}
}
pub(crate) fn fingerprint_in(f: &File) -> (u64, Option<std::time::SystemTime>) {
match f.metadata() {
Ok(m) => (m.len(), m.modified().ok()),
Err(_) => (0, None),
}
}
pub struct WriteLock {
file: File,
path: PathBuf,
}
impl WriteLock {
pub fn covers(&self, lock_path: &Path) -> bool {
self.path == lock_path
}
}
impl Drop for WriteLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub(crate) fn lock_with_deadline(
path: &Path,
deadline: std::time::Duration,
) -> Result<WriteLock, Failure> {
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)?;
let start = std::time::Instant::now();
loop {
match file.try_lock() {
Ok(()) => {
return Ok(WriteLock {
file,
path: path.to_path_buf(),
})
}
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Err(e.into()),
}
let waited = start.elapsed();
if waited >= deadline {
return Err(Failure::busy(deadline));
}
if waited < LOCK_SPIN {
std::thread::yield_now();
} else {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
pub fn first_event_id(root: &Path) -> Option<String> {
let f = File::open(root.join(DIR).join(LOG)).ok()?;
let mut line = String::new();
BufReader::new(f).read_line(&mut line).ok()?;
if line.trim().is_empty() {
return None;
}
let e: crate::event::Event = serde_json::from_str(line.trim_end()).ok()?;
Some(e.id)
}
impl Store {
pub fn open(root: PathBuf) -> Result<Store, Failure> {
let p = root.join(DIR).join(CONFIG);
let config = match fs::read_to_string(&p) {
Ok(s) => read_config(&s)?,
Err(_) => {
let c = Config {
version: regenerated_version(&root),
..Config::new_seeded()
};
write_config(&root, &c)?;
c
}
};
let log_present = root.join(DIR).join(LOG).is_file();
Ok(Store {
root,
config,
log_present,
})
}
pub fn create(root: &Path) -> std::io::Result<Store> {
let d = root.join(DIR);
fs::create_dir_all(&d)?;
let config = Config::new_seeded();
write_config(root, &config)?;
if !d.join(LOG).exists() {
File::create(d.join(LOG))?;
}
write_gitignore(&d)?;
Ok(Store {
root: root.to_path_buf(),
config,
log_present: true,
})
}
pub fn log(&self) -> PathBuf {
self.root.join(DIR).join(LOG)
}
pub fn index_path(&self) -> PathBuf {
self.root.join(DIR).join(INDEX)
}
pub fn lock_path(&self) -> PathBuf {
self.root.join(DIR).join(LOCK)
}
pub fn lock_for_write(&self) -> Result<WriteLock, Failure> {
lock_with_deadline(&self.lock_path(), LOCK_DEADLINE)
}
}
fn write_config(root: &Path, c: &Config) -> std::io::Result<()> {
let mut f = File::create(root.join(DIR).join(CONFIG))?;
f.write_all(serde_json::to_string_pretty(c)?.as_bytes())?;
f.write_all(b"\n")
}
fn write_config_atomic(root: &Path, c: &Config) -> std::io::Result<()> {
let dir = root.join(DIR);
let tmp = dir.join("config.tmp");
{
let mut f = File::create(&tmp)?;
f.write_all(serde_json::to_string_pretty(c)?.as_bytes())?;
f.write_all(b"\n")?;
}
fs::rename(&tmp, dir.join(CONFIG))
}
fn read_config(raw: &str) -> Result<Config, Failure> {
let v: serde_json::Value =
serde_json::from_str(raw).map_err(|e| Failure::Io(std::io::Error::other(e)))?;
check_config_version(v.get("version"))?;
serde_json::from_value(v).map_err(|e| Failure::Io(std::io::Error::other(e)))
}
fn check_config_version(version: Option<&serde_json::Value>) -> Result<(), Failure> {
match version {
Some(serde_json::Value::Number(n)) => match n.as_u64() {
Some(1) => Ok(()),
Some(other) => Err(Failure::newer_vivac(format!(
"This tree was written by a newer vivac: its config has version {other}, \
which this version does not know. Update vivac to read it. Nothing was \
written."
))),
None => Ok(()),
},
Some(serde_json::Value::String(s)) if s == LOCK_SENTENCE => Ok(()),
Some(serde_json::Value::String(s)) if s == LANE_SENTENCE => Ok(()),
Some(serde_json::Value::String(s)) => Err(Failure::newer_vivac(format!(
"This tree was written by a newer vivac: its config says {s:?}. Update vivac \
to read it. Nothing was written."
))),
_ => Ok(()),
}
}
fn regenerated_version(root: &Path) -> ConfigVersion {
let Ok(f) = File::open(root.join(DIR).join(LOG)) else {
return ConfigVersion::One;
};
let mut governed = false;
for line in BufReader::new(f).lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
match v["payload"]["type"].as_str() {
Some("lane.declared") | Some("lane.claimed") => return ConfigVersion::Lanes,
Some("node.created")
if matches!(v["payload"]["kind"].as_str(), Some("pillar") | Some("rule")) =>
{
governed = true;
}
_ => {}
}
}
if governed {
ConfigVersion::Locked
} else {
ConfigVersion::One
}
}
pub(crate) fn peek_config_version(root: &Path) -> Option<ConfigVersion> {
let raw = fs::read_to_string(root.join(DIR).join(CONFIG)).ok()?;
read_config(&raw).ok().map(|c| c.version)
}
pub struct Appended {
pub events: Vec<crate::event::Event>,
pub previous_len: u64,
pub last_line_offset: u64,
pub end_offset: u64,
}
fn log_ends_with_newline(path: &Path) -> std::io::Result<bool> {
let mut f = File::open(path)?;
let len = f.metadata()?.len();
if len == 0 {
return Ok(true);
}
f.seek(SeekFrom::End(-1))?;
let mut last = [0u8; 1];
f.read_exact(&mut last)?;
Ok(last[0] == b'\n')
}
impl Store {
pub fn read_all(&self) -> Result<(Vec<crate::event::Event>, usize), Failure> {
read_all_from(&self.log())
}
pub fn append(
&mut self,
lock: &WriteLock,
lane: &str,
body: Vec<crate::event::Body>,
from_seq: u64,
tree_already_governed: bool,
) -> std::io::Result<Appended> {
if !lock.covers(&self.lock_path()) {
return Err(std::io::Error::other("write lock does not cover this tree"));
}
self.lock_if_needed(&body, tree_already_governed)?;
let mut buf = String::with_capacity(256 * body.len());
let mut written = Vec::with_capacity(body.len());
let mut last_line_start = 0usize;
for (i, c) in body.into_iter().enumerate() {
let e = crate::event::Event {
seq: from_seq + i as u64 + 1,
id: id::ulid(),
ts: clock::now_rfc3339(),
actor: self.config.actor.clone(),
lane: lane.to_string(),
payload: c,
};
last_line_start = buf.len();
buf.push_str(&serde_json::to_string(&e).map_err(std::io::Error::other)?);
buf.push('\n');
written.push(e);
}
let needs_newline_first = self.log_present && !log_ends_with_newline(&self.log())?;
let mut f = OpenOptions::new()
.create(!self.log_present)
.append(true)
.open(self.log())?;
let previous_len = f.metadata()?.len();
let prefix_len: u64 = if needs_newline_first {
f.write_all(b"\n")?;
1
} else {
0
};
f.write_all(buf.as_bytes())?;
self.log_present = true;
if !written.is_empty() {
mark_write();
}
Ok(Appended {
previous_len,
last_line_offset: previous_len + prefix_len + last_line_start as u64,
end_offset: previous_len + prefix_len + buf.len() as u64,
events: written,
})
}
fn lock_if_needed(
&mut self,
body: &[crate::event::Body],
tree_already_governed: bool,
) -> std::io::Result<()> {
if self.config.version != ConfigVersion::One {
return Ok(());
}
let creates_governance = body.iter().any(|b| {
matches!(
b,
crate::event::Body::NodeCreated {
kind: crate::event::Kind::Pillar | crate::event::Kind::Rule,
..
}
)
});
if !tree_already_governed && !creates_governance {
return Ok(());
}
let locked = Config {
version: ConfigVersion::Locked,
project_id: self.config.project_id.clone(),
actor: self.config.actor.clone(),
};
write_config_atomic(&self.root, &locked)?;
self.config = locked;
Ok(())
}
pub fn lock_lanes_in_config(&mut self, lock: &WriteLock) -> std::io::Result<()> {
if !lock.covers(&self.lock_path()) {
return Err(std::io::Error::other("write lock does not cover this tree"));
}
if self.config.version == ConfigVersion::Lanes {
return Ok(());
}
let locked = Config {
version: ConfigVersion::Lanes,
project_id: self.config.project_id.clone(),
actor: self.config.actor.clone(),
};
write_config_atomic(&self.root, &locked)?;
self.config = locked;
Ok(())
}
}
pub(crate) fn read_all_from(path: &Path) -> Result<(Vec<crate::event::Event>, usize), Failure> {
let f = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], 0)),
Err(e) => return Err(e.into()),
};
let mut reader = BufReader::new(f);
let mut events = Vec::new();
let mut broken = 0usize;
let mut line_no = 0usize;
let mut raw = Vec::new();
loop {
raw.clear();
let n = reader.read_until(b'\n', &mut raw)?;
if n == 0 {
break;
}
line_no += 1;
if raw.last() != Some(&b'\n') {
if !String::from_utf8_lossy(&raw).trim().is_empty() {
broken += 1;
}
break;
}
let mut bytes = raw.as_slice();
if bytes.last() == Some(&b'\n') {
bytes = &bytes[..bytes.len() - 1];
}
if bytes.last() == Some(&b'\r') {
bytes = &bytes[..bytes.len() - 1];
}
let line = String::from_utf8(bytes.to_vec()).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
)
})?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str(&line) {
Ok(e) => events.push(e),
Err(_) => match crate::event::unknown_reason_for(&line) {
Some(reason) => return Err(newer_vivac_failure(line_no, reason)),
None => broken += 1,
},
}
}
Ok((events, broken))
}
pub(crate) struct RepeatedSeq {
pub(crate) seq: u64,
pub(crate) first_line: usize,
pub(crate) second_line: usize,
}
pub(crate) struct LogScan {
pub(crate) repeated_seqs: Vec<RepeatedSeq>,
pub(crate) torn_tail: Option<usize>,
}
pub(crate) fn scan_log(path: &Path) -> Result<LogScan, Failure> {
let f = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LogScan {
repeated_seqs: Vec::new(),
torn_tail: None,
})
}
Err(e) => return Err(e.into()),
};
let mut reader = BufReader::new(f);
let mut seen: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
let mut repeated_seqs = Vec::new();
let mut torn_tail = None;
let mut line_no = 0usize;
let mut raw = Vec::new();
loop {
raw.clear();
let n = reader.read_until(b'\n', &mut raw)?;
if n == 0 {
break;
}
line_no += 1;
if raw.last() != Some(&b'\n') {
if !String::from_utf8_lossy(&raw).trim().is_empty() {
torn_tail = Some(line_no);
}
break;
}
let Ok(line) = std::str::from_utf8(&raw) else {
continue;
};
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(e) = serde_json::from_str::<crate::event::Event>(line) else {
continue;
};
if let Some(&first_line) = seen.get(&e.seq) {
repeated_seqs.push(RepeatedSeq {
seq: e.seq,
first_line,
second_line: line_no,
});
} else {
seen.insert(e.seq, line_no);
}
}
Ok(LogScan {
repeated_seqs,
torn_tail,
})
}
pub(crate) fn newer_vivac_failure(line_no: usize, reason: crate::event::UnknownReason) -> Failure {
let path = format!("{DIR}/{LOG}");
let detail = match reason {
crate::event::UnknownReason::EventType(t) => {
format!("is an event this version does not know ({t})")
}
crate::event::UnknownReason::NodeKind(k) => {
format!("creates a node of a type this version does not know ({k})")
}
crate::event::UnknownReason::Shape(t) => {
format!("is a {t} event whose fields this version cannot read")
}
};
Failure::newer_vivac(format!(
"This tree was written by a newer vivac: line {line_no} of {path} {detail}. \
Update vivac to read it. Nothing was written."
))
}
impl Store {
pub fn write_raw(
&self,
lock: &WriteLock,
events: &[crate::event::Event],
) -> std::io::Result<()> {
if !lock.covers(&self.lock_path()) {
return Err(std::io::Error::other("write lock does not cover this tree"));
}
let mut buf = String::with_capacity(256 * events.len());
for e in events {
buf.push_str(&serde_json::to_string(e).map_err(std::io::Error::other)?);
buf.push('\n');
}
let mut f = OpenOptions::new()
.create(!self.log_present)
.append(true)
.open(self.log())?;
f.write_all(buf.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_upward() {
let tmp = std::env::temp_dir().join(format!("vivac-t-{}", id::ulid()));
let depth_of = tmp.join("a").join("b").join("c");
fs::create_dir_all(&depth_of).unwrap();
assert!(find_root(&depth_of).unwrap().is_none());
Store::create(&tmp).unwrap();
assert_eq!(find_root(&depth_of).unwrap().unwrap(), tmp);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn the_global_store_does_not_answer_the_walk() {
let tmp = std::env::temp_dir().join(format!("vivac-t-{}", id::ulid()));
let deep = tmp.join("a").join("b");
fs::create_dir_all(&deep).unwrap();
Store::create(&tmp).unwrap();
assert_eq!(find_root(&deep).unwrap().unwrap(), tmp);
crate::registry::note(
&tmp.join(DIR),
"01aaaaaaaaaaaaaaaaaaaaaaaa",
crate::registry::Sighting {
root: &deep,
lane: None,
repos: None,
},
);
assert_ne!(find_root(&deep).unwrap(), Some(tmp.clone()));
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_project_under_the_global_store_still_wins() {
let tmp = std::env::temp_dir().join(format!("vivac-t-{}", id::ulid()));
let project = tmp.join("work");
let deep = project.join("src").join("deep");
fs::create_dir_all(&deep).unwrap();
Store::create(&tmp).unwrap();
crate::registry::note(
&tmp.join(DIR),
"01aaaaaaaaaaaaaaaaaaaaaaaa",
crate::registry::Sighting {
root: &project,
lane: None,
repos: None,
},
);
Store::create(&project).unwrap();
assert_eq!(find_root(&deep).unwrap().unwrap(), project);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn the_actor_carries_no_personal_data() {
let c = Config::new_seeded();
assert!(c.actor.starts_with("a_"));
assert!(!c.actor.contains('@'));
assert_ne!(c.actor, whoami_ish());
}
fn whoami_ish() -> String {
std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.unwrap_or_default()
}
#[test]
fn vivac_home_wins_and_is_used_as_is() {
let got = resolve_store_dir(
Some(OsStr::new("/somewhere/store")),
Some(OsStr::new("/home/anyone")),
Some(OsStr::new("C:\\Users\\anyone")),
);
assert_eq!(got, Some(PathBuf::from("/somewhere/store")));
}
#[test]
fn blank_vivac_home_falls_through() {
let got = resolve_store_dir(
Some(OsStr::new(" ")),
Some(OsStr::new("/home/anyone")),
None,
);
assert_eq!(got, Some(PathBuf::from("/home/anyone").join(DIR)));
}
#[test]
fn home_alone_appends_dir() {
let got = resolve_store_dir(None, Some(OsStr::new("/home/anyone")), None);
assert_eq!(got, Some(PathBuf::from("/home/anyone").join(DIR)));
}
#[test]
fn userprofile_used_when_home_is_absent() {
let got = resolve_store_dir(None, None, Some(OsStr::new("C:\\Users\\anyone")));
assert_eq!(got, Some(PathBuf::from("C:\\Users\\anyone").join(DIR)));
}
#[test]
fn home_wins_over_userprofile() {
let got = resolve_store_dir(
None,
Some(OsStr::new("/home/anyone")),
Some(OsStr::new("C:\\Users\\anyone")),
);
assert_eq!(got, Some(PathBuf::from("/home/anyone").join(DIR)));
}
#[test]
fn nothing_set_means_no_global_store() {
assert_eq!(resolve_store_dir(None, None, None), None);
}
#[test]
fn first_event_id_on_an_empty_log_is_none() {
let tmp = std::env::temp_dir().join(format!("vivac-fe-{}", id::ulid()));
Store::create(&tmp).unwrap();
assert_eq!(first_event_id(&tmp), None);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn first_event_id_reads_line_one_without_folding() {
let tmp = std::env::temp_dir().join(format!("vivac-fe-{}", id::ulid()));
let mut s = Store::create(&tmp).unwrap();
let lock = s.lock_for_write().unwrap();
for _ in 0..500 {
s.append(
&lock,
crate::lane::MAIN,
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "filler".into(),
}],
0,
false,
)
.unwrap();
}
let first_line = fs::read_to_string(s.log())
.unwrap()
.lines()
.next()
.unwrap()
.to_string();
let want: crate::event::Event = serde_json::from_str(&first_line).unwrap();
assert_eq!(first_event_id(&tmp), Some(want.id));
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_second_writer_waits_for_the_lock_and_then_gives_up() {
let tmp = std::env::temp_dir().join(format!("vivac-lock-{}", id::ulid()));
fs::create_dir_all(&tmp).unwrap();
Store::create(&tmp).unwrap();
let s = Store::open(tmp.clone()).unwrap();
let held = s.lock_for_write().unwrap();
let second = lock_with_deadline(&s.lock_path(), std::time::Duration::from_millis(200));
assert!(
matches!(second, Err(Failure::Busy(_))),
"the lock let a second writer in"
);
drop(held);
assert!(
lock_with_deadline(&s.lock_path(), std::time::Duration::from_millis(200)).is_ok(),
"dropping the first lock did not release it"
);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn the_busy_failure_names_the_deadline_it_was_given() {
let f = Failure::busy(std::time::Duration::from_secs(5));
assert_eq!(f.code(), 5);
assert!(
f.message().contains("held this tree for 5 seconds"),
"{}",
f.message()
);
}
#[test]
fn append_never_recreates_a_log_that_vanished() {
let tmp = std::env::temp_dir().join(format!("vivac-vanished-{}", id::ulid()));
fs::create_dir_all(&tmp).unwrap();
Store::create(&tmp).unwrap();
let mut s = Store::open(tmp.clone()).unwrap();
let lock = s.lock_for_write().unwrap();
fs::remove_file(s.log()).unwrap();
let body = vec![crate::event::Body::NodeNoted {
node: "01VANISHEDAAAAAAAAAAAAAAAA".into(),
note: "x".into(),
}];
assert!(
s.append(&lock, crate::lane::MAIN, body, 0, false).is_err(),
"append wrote into a log that is gone"
);
assert!(
!s.log().exists(),
"append created a new log where the old one was"
);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn appending_with_another_trees_lock_is_refused() {
let a = std::env::temp_dir().join(format!("vivac-locka-{}", id::ulid()));
let b = std::env::temp_dir().join(format!("vivac-lockb-{}", id::ulid()));
Store::create(&a).unwrap();
Store::create(&b).unwrap();
let mut sa = Store::open(a.clone()).unwrap();
let sb = Store::open(b.clone()).unwrap();
let wrong = sb.lock_for_write().unwrap();
let body = vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "x".into(),
}];
assert!(
sa.append(&wrong, crate::lane::MAIN, body, 0, false)
.is_err(),
"append accepted a lock taken on a different tree"
);
fs::remove_dir_all(&a).ok();
fs::remove_dir_all(&b).ok();
}
fn locate_tmp(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!("vivac-locate-{prefix}-{}", id::ulid()))
}
fn write_git_file(working_dir: &Path, gitdir: &Path) {
fs::create_dir_all(working_dir).unwrap();
fs::create_dir_all(gitdir).unwrap();
fs::write(
working_dir.join(".git"),
format!("gitdir: {}\n", gitdir.display()),
)
.unwrap();
}
#[test]
fn the_tree_in_this_very_folder() {
let tmp = locate_tmp("here");
Store::create(&tmp).unwrap();
let located = locate_from(&tmp, None).unwrap().unwrap();
assert_eq!(located.root, tmp);
assert_eq!(located.lane_dir, tmp);
assert!(located.lane.is_none());
assert!(located.worktree.is_none());
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn an_empty_vivac_directory_with_nothing_above_answers_as_no_store() {
let tmp = locate_tmp("empty-vivac");
fs::create_dir_all(tmp.join(DIR)).unwrap();
assert!(locate_from(&tmp, None).unwrap().is_none());
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn an_empty_vivac_directory_under_a_real_tree_refuses_rather_than_guessing() {
let tmp = locate_tmp("hollow-under-tree");
let hollow = tmp.join("hollow");
Store::create(&tmp).unwrap();
fs::create_dir_all(hollow.join(DIR)).unwrap();
let err = locate_from(&hollow, None).unwrap_err();
assert_eq!(err.code(), 1);
assert!(
err.message()
.contains("This folder has a .vivac/ that is neither a tree nor a lane"),
"{}",
err.message()
);
assert!(err.message().contains("vivac init"), "{}", err.message());
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn hollow_vivac_refusal_never_prints_a_line_past_76_columns() {
let long_name = "folder name ".repeat(9).trim().to_string();
let tree_above = PathBuf::from("/tmp").join(&long_name);
let message = hollow_vivac_refusal(&tree_above).message();
let unwrapped: String = message.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(unwrapped.contains(&long_name), "{message}");
for line in message.lines() {
let trimmed = line.trim_start();
let is_a_way_out = trimmed.starts_with("Make it a tree of its own")
|| trimmed.starts_with("Or hand it back to the tree above");
assert!(
is_a_way_out || line.chars().count() <= 76,
"a wrapped line ran past 76 columns ({} chars): {line:?}\nfull message:\n{message}",
line.chars().count()
);
}
}
#[test]
fn a_lane_below_the_tree_finds_it_walking_up() {
let tmp = locate_tmp("below");
let mut s = Store::create(&tmp).unwrap();
let lock = s.lock_for_write().unwrap();
s.append(
&lock,
crate::lane::MAIN,
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "seed".into(),
}],
0,
false,
)
.unwrap();
drop(lock);
let project = first_event_id(&tmp).unwrap();
let lane_dir = tmp.join("lane");
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: project.clone(),
};
crate::lane::write(&lane_dir.join(DIR), &lane).unwrap();
let located = locate_from(&lane_dir, None).unwrap().unwrap();
assert_eq!(located.root, tmp);
assert_eq!(located.lane_dir, lane_dir);
assert_eq!(located.lane.unwrap().project, project);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_lane_outside_the_tree_finds_it_through_the_registry() {
let lane_dir = locate_tmp("outside");
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: "01OUTSIDEPROJECTAAAAAAAAAA".into(),
};
crate::lane::write(&lane_dir.join(DIR), &lane).unwrap();
let registry_dir = locate_tmp("outside-registry");
let noted_root = locate_tmp("outside-fake-root");
crate::registry::note(
®istry_dir,
&lane.project,
crate::registry::Sighting {
root: ¬ed_root,
lane: None,
repos: None,
},
);
let located = locate_from(&lane_dir, Some(®istry_dir))
.unwrap()
.unwrap();
assert_eq!(located.root, noted_root);
assert_eq!(located.lane_dir, lane_dir);
fs::remove_dir_all(&lane_dir).ok();
fs::remove_dir_all(®istry_dir).ok();
}
#[test]
fn a_lane_folder_with_an_orphaned_config_is_not_read_as_its_own_tree() {
let lane_dir = locate_tmp("orphan-config");
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: "01ORPHANPROJECTAAAAAAAAAA".into(),
};
crate::lane::write(&lane_dir.join(DIR), &lane).unwrap();
Store::open(lane_dir.clone()).unwrap();
assert!(
lane_dir.join(DIR).join(CONFIG).is_file(),
"the setup itself must have regenerated a config here"
);
assert!(!lane_dir.join(DIR).join(LOG).is_file());
let registry_dir = locate_tmp("orphan-config-registry");
let noted_root = locate_tmp("orphan-config-fake-root");
crate::registry::note(
®istry_dir,
&lane.project,
crate::registry::Sighting {
root: ¬ed_root,
lane: None,
repos: None,
},
);
let located = locate_from(&lane_dir, Some(®istry_dir))
.unwrap()
.unwrap();
assert_eq!(
located.root, noted_root,
"an orphaned config must not make this folder answer as its own tree"
);
assert_eq!(located.lane_dir, lane_dir);
fs::remove_dir_all(&lane_dir).ok();
fs::remove_dir_all(®istry_dir).ok();
}
#[test]
fn a_lane_whose_tree_the_registry_does_not_know_refuses_with_exit_4() {
let lane_dir = locate_tmp("unknown");
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: "01UNKNOWNPROJECTAAAAAAAAAA".into(),
};
crate::lane::write(&lane_dir.join(DIR), &lane).unwrap();
let registry_dir = locate_tmp("unknown-registry");
let err = locate_from(&lane_dir, Some(®istry_dir)).unwrap_err();
assert_eq!(err.code(), 4);
assert!(
err.message().contains("registry does not know"),
"{}",
err.message()
);
fs::remove_dir_all(&lane_dir).ok();
}
#[test]
fn a_subfolder_belongs_to_the_nearest_lane_above() {
let outer = locate_tmp("nearest-outer");
Store::create(&outer).unwrap(); let lane_dir = outer.join("consumer");
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: "01NEARESTPROJECTAAAAAAAAAA".into(),
};
crate::lane::write(&lane_dir.join(DIR), &lane).unwrap();
let deep = lane_dir.join("x").join("y");
fs::create_dir_all(&deep).unwrap();
let registry_dir = locate_tmp("nearest-registry");
let noted_root = locate_tmp("nearest-fake-root");
crate::registry::note(
®istry_dir,
&lane.project,
crate::registry::Sighting {
root: ¬ed_root,
lane: None,
repos: None,
},
);
let located = locate_from(&deep, Some(®istry_dir)).unwrap().unwrap();
assert_eq!(
located.lane_dir, lane_dir,
"picked a farther .vivac/ than the nearest one"
);
assert_eq!(located.root, noted_root);
fs::remove_dir_all(&outer).ok();
fs::remove_dir_all(®istry_dir).ok();
}
#[test]
fn a_linked_worktree_inside_the_lane_is_reported_as_a_worktree() {
let tmp = locate_tmp("wt-inside");
let worktree_dir = tmp.join("feature");
let gitdir = tmp
.join("main")
.join(".git")
.join("worktrees")
.join("feature");
write_git_file(&worktree_dir, &gitdir);
fs::write(gitdir.join("commondir"), "../..\n").unwrap();
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: "01WTINSIDEPROJECTAAAAAAAAA".into(),
};
crate::lane::write(&worktree_dir.join(DIR), &lane).unwrap();
let registry_dir = locate_tmp("wt-inside-registry");
let noted_root = locate_tmp("wt-inside-fake-root");
crate::registry::note(
®istry_dir,
&lane.project,
crate::registry::Sighting {
root: ¬ed_root,
lane: None,
repos: None,
},
);
let deep = worktree_dir.join("src").join("deep");
fs::create_dir_all(&deep).unwrap();
let located = locate_from(&deep, Some(®istry_dir)).unwrap().unwrap();
assert_eq!(located.root, noted_root);
assert_eq!(
located.lane_dir, worktree_dir,
"who the lane really is gets decided elsewhere, not here"
);
assert_eq!(located.worktree, Some(worktree_dir.clone()));
fs::remove_dir_all(&tmp).ok();
fs::remove_dir_all(®istry_dir).ok();
}
#[test]
fn a_linked_worktree_outside_any_lane_finds_the_tree_through_its_main_copy() {
let tmp = locate_tmp("wt-outside");
let main_dir = tmp.join("main");
Store::create(&main_dir).unwrap();
let worktree_dir = tmp.join("feature");
let gitdir = main_dir.join(".git").join("worktrees").join("feature");
write_git_file(&worktree_dir, &gitdir);
fs::write(gitdir.join("commondir"), "../..\n").unwrap();
let located = locate_from(&worktree_dir, None).unwrap().unwrap();
assert_eq!(located.root, main_dir);
assert_eq!(located.lane_dir, main_dir);
assert!(located.lane.is_none());
assert_eq!(located.worktree, Some(worktree_dir));
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_lane_file_inside_a_linked_worktree_still_resolves_through_its_main_copy() {
let tmp = locate_tmp("wt-lane-fallback");
let main_dir = tmp.join("main");
let mut s = Store::create(&main_dir).unwrap();
let lock = s.lock_for_write().unwrap();
s.append(
&lock,
crate::lane::MAIN,
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "seed".into(),
}],
0,
false,
)
.unwrap();
drop(lock);
let project = first_event_id(&main_dir).unwrap();
let worktree_dir = tmp.join("feature");
let gitdir = main_dir.join(".git").join("worktrees").join("feature");
write_git_file(&worktree_dir, &gitdir);
fs::write(gitdir.join("commondir"), "../..\n").unwrap();
let lane = crate::lane::Lane {
version: 1,
id: crate::lane::new_id(),
project: project.clone(),
};
crate::lane::write(&worktree_dir.join(DIR), &lane).unwrap();
let located = locate_from(&worktree_dir, None).unwrap().unwrap();
assert_eq!(located.root, main_dir);
assert_eq!(located.lane_dir, worktree_dir);
assert_eq!(located.lane.unwrap().project, project);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_submodule_is_not_a_worktree() {
let tmp = locate_tmp("submodule");
Store::create(&tmp).unwrap();
let sub_dir = tmp.join("vendor").join("lib");
let gitdir = tmp.join(".git-modules").join("lib");
write_git_file(&sub_dir, &gitdir);
let located = locate_from(&sub_dir, None).unwrap().unwrap();
assert_eq!(located.root, tmp);
assert!(
located.worktree.is_none(),
"a submodule was reported as a linked worktree"
);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn the_global_store_still_does_not_answer_the_walk() {
let tmp = locate_tmp("global");
let deep = tmp.join("a").join("b");
fs::create_dir_all(&deep).unwrap();
Store::create(&tmp).unwrap();
crate::registry::note(
&tmp.join(DIR),
"01aaaaaaaaaaaaaaaaaaaaaaaa",
crate::registry::Sighting {
root: &deep,
lane: None,
repos: None,
},
);
assert!(locate_from(&deep, None).unwrap().is_none());
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn an_event_is_signed_by_whatever_lane_append_is_given() {
let tmp = std::env::temp_dir().join(format!("vivac-sign-{}", id::ulid()));
Store::create(&tmp).unwrap();
let mut s = Store::open(tmp.clone()).unwrap();
let lock = s.lock_for_write().unwrap();
let w = s
.append(
&lock,
"01M2XYZ",
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "x".into(),
}],
0,
false,
)
.unwrap();
assert_eq!(w.events[0].lane, "01M2XYZ");
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_caller_that_passes_main_signs_main() {
let tmp = std::env::temp_dir().join(format!("vivac-signmain-{}", id::ulid()));
Store::create(&tmp).unwrap();
let mut s = Store::open(tmp.clone()).unwrap();
let lock = s.lock_for_write().unwrap();
let w = s
.append(
&lock,
crate::lane::MAIN,
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "x".into(),
}],
0,
false,
)
.unwrap();
assert_eq!(w.events[0].lane, crate::lane::MAIN);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn locking_the_config_for_lanes_is_idempotent_and_atomic() {
let tmp = std::env::temp_dir().join(format!("vivac-lanelock-{}", id::ulid()));
let mut s = Store::create(&tmp).unwrap();
let lock = s.lock_for_write().unwrap();
s.lock_lanes_in_config(&lock).unwrap();
assert_eq!(s.config.version, ConfigVersion::Lanes);
let text = fs::read_to_string(tmp.join(DIR).join(CONFIG)).unwrap();
assert!(text.contains(LANE_SENTENCE));
s.lock_lanes_in_config(&lock).unwrap();
assert_eq!(s.config.version, ConfigVersion::Lanes);
assert!(
fs::read_dir(tmp.join(DIR))
.unwrap()
.filter_map(|e| e.ok())
.all(|e| !e.file_name().to_string_lossy().ends_with(".tmp")),
"a temporary file was left behind"
);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_missing_config_regenerates_the_lanes_sentence_when_the_log_has_a_lane_event() {
let tmp = std::env::temp_dir().join(format!("vivac-relock-{}", id::ulid()));
let mut s = Store::create(&tmp).unwrap();
let lock = s.lock_for_write().unwrap();
s.append(
&lock,
crate::lane::MAIN,
vec![crate::event::Body::LaneDeclared {
lane: crate::lane::MAIN.to_string(),
name: crate::lane::MAIN.to_string(),
repos: vec![],
}],
0,
false,
)
.unwrap();
drop(lock);
fs::remove_file(tmp.join(DIR).join(CONFIG)).unwrap();
let reopened = Store::open(tmp.clone()).unwrap();
assert_eq!(reopened.config.version, ConfigVersion::Lanes);
fs::remove_dir_all(&tmp).ok();
}
}