use crate::failure::Failure;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
const FILE: &str = "projects";
pub fn marks_global_store(dir: &Path) -> bool {
dir.join(FILE).is_file()
}
const VERSION: u32 = 2;
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
struct Project {
path: String,
#[serde(default)]
repos: Vec<String>,
#[serde(default)]
lanes: BTreeMap<String, String>,
#[serde(default)]
copies: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Entry {
V1(String),
V2(Project),
}
#[derive(Deserialize)]
struct OnDisk {
version: u32,
projects: BTreeMap<String, Entry>,
}
#[derive(Serialize)]
struct ToDisk<'a> {
version: u32,
projects: &'a BTreeMap<String, Project>,
}
pub struct Sighting<'a> {
pub root: &'a Path,
pub lane: Option<(&'a str, &'a Path)>,
pub repos: Option<&'a [String]>,
}
pub enum Noted {
Fine,
Copy {
first: Option<String>,
rest: Vec<Option<String>>,
},
}
pub fn note(store_dir: &Path, project_id: &str, s: Sighting<'_>) -> Noted {
try_note(store_dir, project_id, &s).unwrap_or(Noted::Fine)
}
pub fn record_move(store_dir: &Path, project_id: &str, s: Sighting<'_>) -> std::io::Result<()> {
std::fs::create_dir_all(store_dir)?;
let path = store_dir.join(FILE);
let _lock = crate::store::lock_with_deadline(&store_dir.join(LOCK), LOCK_WAIT)
.map_err(|e| std::io::Error::other(e.message()))?;
let Some(mut projects) = read(&path) else {
return Err(std::io::Error::other(
"the registry was written by a newer vivac and cannot be updated",
));
};
apply_sighting(&mut projects, project_id, &s);
write(store_dir, &path, &projects)
}
pub fn copy_of(store_dir: &Path, project_id: &str, root: &Path) -> Noted {
let Some(projects) = read(&store_dir.join(FILE)) else {
return Noted::Fine;
};
let Some(existing) = projects.get(project_id) else {
return Noted::Fine;
};
copy_or_fine(live_others(existing, project_id, root))
}
pub struct CopyNotice {
pub heading: &'static str,
pub body: String,
}
const NOTICE_WIDTH: usize = 76;
fn wrapped_with_command(prose: &str, command: &str) -> String {
let mut lines = crate::render::wrap(prose, NOTICE_WIDTH, "");
lines.push(format!(" {command}"));
lines.join("\n")
}
pub fn copy_notice(first: Option<&str>, rest: &[Option<String>]) -> CopyNotice {
if rest.is_empty() {
return match first {
Some(name) => CopyNotice {
heading: "COPY OF ANOTHER TREE",
body: wrapped_with_command(
&format!(
"This tree starts with the same event as the one in folder \"{name}\", \
so one of them is a copy, and copies diverge in silence. Keep one: \
delete the other, or delete this one and join this folder to it with"
),
&format!("vivac init --join {}", quote_if_needed(name)),
),
},
None => CopyNotice {
heading: "COPY OF ANOTHER TREE",
body: wrapped_with_command(
"This tree starts with the same event as one in another folder on this \
machine, so one of them is a copy, and copies diverge in silence. Keep \
one: delete the other, or delete this one and join this folder to it \
with",
"vivac init --join <path to that folder>",
),
},
};
}
let total = 1 + rest.len();
let named: Vec<&str> = first
.into_iter()
.chain(rest.iter().filter_map(|o| o.as_deref()))
.collect();
let names = named
.iter()
.map(|n| format!("\"{n}\""))
.collect::<Vec<_>>()
.join(", ");
let command = "vivac init --join <the folder you kept>";
let body = if named.len() == total {
wrapped_with_command(
&format!(
"These folders on this machine start with the same event as this one: \
{names}. They are copies of each other, and copies diverge in silence. \
Keep one, delete the rest, and join the folders you still work in to the \
one you kept:"
),
command,
)
} else if named.is_empty() {
wrapped_with_command(
"Other folders on this machine hold a tree that starts with the same event as \
this one, under names this tool will not write down. They are copies of each \
other, and copies diverge in silence. Keep one, delete the rest, and join the \
folders you still work in to the one you kept:",
command,
)
} else {
wrapped_with_command(
&format!(
"These folders on this machine start with the same event as this one: \
{names}. More hold it too, under names this tool will not write down. \
They are copies of each other, and copies diverge in silence. Keep one, \
delete the rest, and join the folders you still work in to the one you \
kept:"
),
command,
)
};
CopyNotice {
heading: "COPIES OF THIS TREE",
body,
}
}
static PENDING_NOTICE: std::sync::OnceLock<Noted> = std::sync::OnceLock::new();
pub fn set_pending(noted: Noted) {
let _ = PENDING_NOTICE.set(noted);
}
fn warn_once_if_copy(noted: &Noted) {
let Noted::Copy { first, rest } = noted else {
return;
};
crate::output::flush();
let notice = copy_notice(first.as_deref(), rest);
eprintln!();
eprintln!("{}", notice.heading);
eprintln!();
for line in notice.body.lines() {
eprintln!(" {line}");
}
eprintln!();
}
pub fn warn_if_wrote() {
if crate::store::is_resident() || crate::store::shown() || !crate::store::wrote() {
return;
}
if let Some(noted) = PENDING_NOTICE.get() {
warn_once_if_copy(noted);
}
}
fn shell_safe(name: &str) -> bool {
name.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
pub(crate) fn quote_if_needed(name: &str) -> String {
if shell_safe(name) {
name.to_string()
} else {
format!("\"{name}\"")
}
}
const LOCK: &str = "registry.lock";
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
enum Decision {
Done(Noted),
RecordCopy(Noted),
RecordSighting,
}
fn decide(projects: &BTreeMap<String, Project>, project_id: &str, s: &Sighting<'_>) -> Decision {
if let Some(existing) = projects.get(project_id) {
if path_disagrees(existing, project_id, s.root) {
let copy = copy_or_fine(live_others(existing, project_id, s.root));
let known = existing
.copies
.iter()
.any(|c| crate::anchor::same_folder(Path::new(c), s.root));
return if known {
Decision::Done(copy)
} else {
Decision::RecordCopy(copy)
};
}
}
if unchanged(projects, project_id, s) {
return Decision::Done(Noted::Fine);
}
Decision::RecordSighting
}
fn try_note(store_dir: &Path, project_id: &str, s: &Sighting<'_>) -> std::io::Result<Noted> {
let path = store_dir.join(FILE);
let Some(projects) = read(&path) else {
return Ok(Noted::Fine);
};
if let Decision::Done(outcome) = decide(&projects, project_id, s) {
return Ok(outcome);
}
std::fs::create_dir_all(store_dir)?;
let _lock = crate::store::lock_with_deadline(&store_dir.join(LOCK), LOCK_WAIT)
.map_err(|e| std::io::Error::other(e.message()))?;
let Some(mut projects) = read(&path) else {
return Ok(Noted::Fine);
};
match decide(&projects, project_id, s) {
Decision::Done(outcome) => Ok(outcome),
Decision::RecordCopy(outcome) => {
let entry = projects.entry(project_id.to_string()).or_default();
entry.copies.push(s.root.to_string_lossy().into_owned());
prune_dead_copies(entry, project_id);
write(store_dir, &path, &projects)?;
Ok(outcome)
}
Decision::RecordSighting => {
apply_sighting(&mut projects, project_id, s);
write(store_dir, &path, &projects)?;
Ok(Noted::Fine)
}
}
}
fn path_disagrees(existing: &Project, project_id: &str, root: &Path) -> bool {
let other = Path::new(&existing.path);
!crate::anchor::same_folder(other, root)
&& crate::store::first_event_id(other).as_deref() == Some(project_id)
}
#[derive(Debug)]
pub struct Elsewhere {
pub name: Option<String>,
}
pub fn path_elsewhere(store_dir: &Path, project_id: &str, root: &Path) -> Option<Elsewhere> {
let projects = read(&store_dir.join(FILE))?;
let existing = projects.get(project_id)?;
if !path_disagrees(existing, project_id, root) {
return None;
}
Some(Elsewhere {
name: folder_name(Path::new(&existing.path)),
})
}
fn live_others(existing: &Project, project_id: &str, root: &Path) -> Vec<Option<String>> {
std::iter::once(existing.path.as_str())
.chain(existing.copies.iter().map(String::as_str))
.map(Path::new)
.filter(|p| !crate::anchor::same_folder(p, root))
.filter(|p| crate::store::first_event_id(p).as_deref() == Some(project_id))
.map(folder_name)
.collect()
}
fn copy_or_fine(mut others: Vec<Option<String>>) -> Noted {
if others.is_empty() {
Noted::Fine
} else {
let first = others.remove(0);
Noted::Copy {
first,
rest: others,
}
}
}
pub fn folder_name(p: &Path) -> Option<String> {
let name = p.file_name()?.to_string_lossy().into_owned();
match crate::redact::check_field("project name", &name) {
Some(_) => None,
None => Some(name),
}
}
pub fn effective_name(store_dir: &Path, root: &Path) -> Option<String> {
let saved = crate::store::first_event_id(root)
.and_then(|project_id| saved_name(store_dir, &project_id));
saved.or_else(|| folder_name(root))
}
fn saved_name(store_dir: &Path, project_id: &str) -> Option<String> {
let name = read(&store_dir.join(FILE))?.get(project_id)?.name.clone()?;
match crate::redact::check_field("project name", &name) {
Some(_) => None,
None => Some(name),
}
}
pub fn another_project_answers_to(store_dir: &Path, name: &str) -> bool {
roots(store_dir)
.iter()
.any(|root| effective_name(store_dir, root).as_deref() == Some(name))
}
pub fn set_name(store_dir: &Path, project_id: &str, name: &str) {
let _ = try_set_name(store_dir, project_id, name);
}
fn try_set_name(store_dir: &Path, project_id: &str, name: &str) -> std::io::Result<()> {
let path = store_dir.join(FILE);
std::fs::create_dir_all(store_dir)?;
let _lock = crate::store::lock_with_deadline(&store_dir.join(LOCK), LOCK_WAIT)
.map_err(|e| std::io::Error::other(e.message()))?;
let Some(mut projects) = read(&path) else {
return Ok(());
};
let entry = projects.entry(project_id.to_string()).or_default();
entry.name = Some(name.to_string());
write(store_dir, &path, &projects)
}
pub(crate) fn label_for(name: Option<&str>) -> String {
match name {
Some(n) => format!("\"{n}\""),
None => "another folder".to_string(),
}
}
fn unchanged(projects: &BTreeMap<String, Project>, project_id: &str, s: &Sighting<'_>) -> bool {
let Some(p) = projects.get(project_id) else {
return false;
};
if !crate::anchor::same_folder(Path::new(&p.path), s.root) {
return false;
}
if let Some(repos) = s.repos {
if p.repos.as_slice() != repos {
return false;
}
}
if let Some((id, dir)) = s.lane {
let same = p
.lanes
.get(id)
.is_some_and(|existing| crate::anchor::same_folder(Path::new(existing), dir));
if !same {
return false;
}
}
true
}
fn prune_dead_copies(entry: &mut Project, project_id: &str) {
entry
.copies
.retain(|c| crate::store::first_event_id(Path::new(c)).as_deref() == Some(project_id));
}
pub fn lanes_with_missing_folder(store_dir: &Path, project_id: &str) -> Vec<String> {
let Some(projects) = read(&store_dir.join(FILE)) else {
return Vec::new();
};
let Some(entry) = projects.get(project_id) else {
return Vec::new();
};
let mut missing = Vec::new();
for (id, dir) in &entry.lanes {
if !Path::new(dir).exists() {
missing.push(id.clone());
}
}
missing
}
pub fn lane_folder(store_dir: &Path, project_id: &str, lane_id: &str) -> Option<PathBuf> {
let projects = read(&store_dir.join(FILE))?;
let entry = projects.get(project_id)?;
entry.lanes.get(lane_id).map(PathBuf::from)
}
fn apply_sighting(projects: &mut BTreeMap<String, Project>, project_id: &str, s: &Sighting<'_>) {
let entry = projects.entry(project_id.to_string()).or_default();
entry.path = s.root.to_string_lossy().into_owned();
prune_dead_copies(entry, project_id);
entry
.copies
.retain(|c| !crate::anchor::same_folder(Path::new(c), s.root));
if let Some(repos) = s.repos {
entry.repos = repos.to_vec();
}
if let Some((id, dir)) = s.lane {
entry
.lanes
.insert(id.to_string(), dir.to_string_lossy().into_owned());
}
}
pub fn root_of(store_dir: &Path, project_id: &str) -> Option<PathBuf> {
read(&store_dir.join(FILE))
.unwrap_or_default()
.get(project_id)
.map(|p| PathBuf::from(&p.path))
}
pub fn roots(store_dir: &Path) -> Vec<PathBuf> {
read(&store_dir.join(FILE))
.unwrap_or_default()
.into_values()
.map(|p| PathBuf::from(p.path))
.filter(|r| !marks_global_store(&r.join(crate::store::DIR)))
.collect()
}
#[derive(Debug)]
pub struct Sharing {
pub name: Option<String>,
pub root: PathBuf,
pub shared: Vec<String>,
}
pub fn sharing_repos(store_dir: &Path, repos: &[String]) -> Vec<Sharing> {
let Some(projects) = read(&store_dir.join(FILE)) else {
return Vec::new();
};
let mut found: Vec<Sharing> = projects
.iter()
.filter_map(|(id, p)| {
let root = PathBuf::from(&p.path);
if crate::store::first_event_id(&root).as_deref() != Some(id.as_str()) {
return None;
}
let shared: Vec<String> = p
.repos
.iter()
.filter(|r| repos.contains(r))
.cloned()
.collect();
if shared.is_empty() {
return None;
}
Some(Sharing {
name: effective_name(store_dir, &root),
root,
shared,
})
})
.collect();
found.sort_by(|a, b| {
b.shared.len().cmp(&a.shared.len()).then_with(|| {
a.name
.as_deref()
.unwrap_or("\u{10FFFF}")
.cmp(b.name.as_deref().unwrap_or("\u{10FFFF}"))
})
});
found
}
pub fn resolve(spec: &str) -> Result<PathBuf, Failure> {
let known = crate::store::store_dir()
.map(|d| roots(&d))
.unwrap_or_default();
let mut matches: Vec<PathBuf> = known
.into_iter()
.filter(|root| crate::render::project_name(root) == spec)
.collect();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => resolve_no_match(spec),
n => Err(Failure::usage(format!(
"\"{spec}\" names {n} projects on this machine. Pass a path instead."
))),
}
}
fn resolve_no_match(spec: &str) -> Result<PathBuf, Failure> {
let candidate = absolute(Path::new(spec));
if looks_like_a_path(spec) || candidate.is_dir() {
return Ok(candidate);
}
Err(Failure::Model(format!(
" No project named {spec} in the registry.\n\n \
These do: vivac vivacs\n \
If {spec} was meant as a folder, it has no tree: vivac init"
)))
}
fn looks_like_a_path(spec: &str) -> bool {
spec == "."
|| spec == ".."
|| spec.contains('/')
|| spec.contains('\\')
|| Path::new(spec).is_absolute()
}
fn absolute(p: &Path) -> PathBuf {
let based = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(p))
.unwrap_or_else(|_| p.to_path_buf())
};
crate::anchor::normalize(&based)
}
fn read(path: &Path) -> Option<BTreeMap<String, Project>> {
let Ok(text) = std::fs::read_to_string(path) else {
return Some(BTreeMap::new());
};
let on_disk: OnDisk = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => return Some(BTreeMap::new()),
};
if on_disk.version > VERSION {
return None;
}
Some(
on_disk
.projects
.into_iter()
.map(|(id, entry)| {
let project = match entry {
Entry::V1(path) => Project {
path,
..Project::default()
},
Entry::V2(p) => p,
};
(id, project)
})
.collect(),
)
}
fn write(
store_dir: &Path,
path: &Path,
projects: &BTreeMap<String, Project>,
) -> std::io::Result<()> {
std::fs::create_dir_all(store_dir)?;
let tmp = store_dir.join(format!("{FILE}.{}.tmp", crate::id::ulid()));
let payload = ToDisk {
version: VERSION,
projects,
};
{
let mut f = File::create(&tmp)?;
f.write_all(serde_json::to_string_pretty(&payload)?.as_bytes())?;
f.write_all(b"\n")?;
}
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{id, store};
fn temp_dir(prefix: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("vivac-{prefix}-{}", id::ulid()))
}
fn sighting(root: &Path) -> Sighting<'_> {
Sighting {
root,
lane: None,
repos: None,
}
}
fn seed_at(root: &Path) -> String {
let mut s = store::Store::create(root).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();
store::first_event_id(root).unwrap()
}
fn seeded_project(prefix: &str) -> (std::path::PathBuf, String) {
let root = temp_dir(prefix);
let id = seed_at(&root);
(root, id)
}
struct Owned(std::path::PathBuf);
impl Drop for Owned {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
#[test]
fn path_elsewhere_names_the_folder_that_still_holds_the_tree() {
let store_dir = Owned(temp_dir("reg-elsewhere-live-store"));
let (root, id) = seeded_project("elsewhere-live");
let root = Owned(root);
let copy = Owned(temp_dir("reg-elsewhere-live-copy"));
std::fs::create_dir_all(copy.0.join(store::DIR)).unwrap();
std::fs::copy(
root.0.join(store::DIR).join(store::LOG),
copy.0.join(store::DIR).join(store::LOG),
)
.unwrap();
note(&store_dir.0, &id, sighting(&root.0));
let elsewhere =
path_elsewhere(&store_dir.0, &id, ©.0).expect("the folder on path is still there");
assert_eq!(
elsewhere.name.as_deref(),
root.0.file_name().and_then(|n| n.to_str()),
"the folder still holding the tree was not named"
);
}
#[test]
fn path_elsewhere_answers_nothing_for_a_project_the_registry_never_heard_of() {
let store_dir = Owned(temp_dir("reg-elsewhere-unknown-store"));
let (root, id) = seeded_project("elsewhere-unknown");
let root = Owned(root);
assert!(
path_elsewhere(&store_dir.0, &id, &root.0).is_none(),
"a registry with no entry for this project must not claim one"
);
}
#[test]
fn path_elsewhere_answers_nothing_once_the_folder_on_path_holds_no_tree() {
let store_dir = Owned(temp_dir("reg-elsewhere-dead-store"));
let (root, id) = seeded_project("elsewhere-dead");
let survivor = Owned(temp_dir("reg-elsewhere-survivor"));
note(&store_dir.0, &id, sighting(&root));
std::fs::remove_dir_all(&root).unwrap();
assert!(
path_elsewhere(&store_dir.0, &id, &survivor.0).is_none(),
"a folder that no longer holds this tree must not block the one that does"
);
}
#[test]
fn path_elsewhere_answers_nothing_for_the_folder_on_path_by_another_spelling() {
let store_dir = Owned(temp_dir("reg-elsewhere-spelling-store"));
let (root, id) = seeded_project("Elsewhere-Spelling");
let root = Owned(root);
note(&store_dir.0, &id, sighting(&root.0));
let Some(second) = second_spelling(&root.0) else {
eprintln!(
"skipped: this platform offers no second spelling of the same folder to test with"
);
return;
};
assert!(
path_elsewhere(&store_dir.0, &id, &second).is_none(),
"a second spelling of the folder already on path read as another folder"
);
}
#[test]
fn path_elsewhere_withholds_a_folder_name_the_guard_rejects() {
let rejected_name = "someone@example.com";
assert!(
crate::redact::check_field("project name", rejected_name).is_some(),
"the guard must actually reject this name, or the test proves nothing"
);
let parent = Owned(temp_dir("reg-elsewhere-redacted-parent"));
std::fs::create_dir_all(&parent.0).unwrap();
let named = parent.0.join(rejected_name);
let id = seed_at(&named);
let store_dir = Owned(temp_dir("reg-elsewhere-redacted-store"));
let asking = Owned(temp_dir("reg-elsewhere-redacted-asking"));
note(&store_dir.0, &id, sighting(&named));
let elsewhere = path_elsewhere(&store_dir.0, &id, &asking.0)
.expect("the folder on path is still there");
assert_eq!(
elsewhere.name, None,
"a folder name the guard rejects must not reach the caller"
);
}
#[test]
fn a_fresh_registry_gets_the_project_inserted() {
let store_dir = temp_dir("reg");
let (root, id) = seeded_project("proj");
note(&store_dir, &id, sighting(&root));
let projects = read(&store_dir.join(FILE)).unwrap();
assert_eq!(
projects.get(&id).map(|p| p.path.as_str()),
Some(root.to_string_lossy().as_ref())
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn two_different_projects_both_appear() {
let store_dir = temp_dir("reg");
let (root_a, id_a) = seeded_project("proj-a");
let (root_b, id_b) = seeded_project("proj-b");
note(&store_dir, &id_a, sighting(&root_a));
note(&store_dir, &id_b, sighting(&root_b));
let projects = read(&store_dir.join(FILE)).unwrap();
assert_eq!(projects.len(), 2);
assert_eq!(projects.get(&id_a).unwrap().path, root_a.to_string_lossy());
assert_eq!(projects.get(&id_b).unwrap().path, root_b.to_string_lossy());
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root_a).ok();
std::fs::remove_dir_all(&root_b).ok();
}
#[test]
fn an_unwritable_store_directory_never_fails_the_caller() {
let blocked = temp_dir("blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let (root, id) = seeded_project("proj");
note(&blocked, &id, sighting(&root));
std::fs::remove_file(&blocked).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn record_move_points_path_at_the_destination_even_though_the_origin_still_shows_a_tree() {
let store_dir = temp_dir("reg-move");
let (origin, id) = seeded_project("move-origin");
let destination = temp_dir("move-destination");
note(&store_dir, &id, sighting(&origin));
record_move(&store_dir, &id, sighting(&destination)).unwrap();
let projects = read(&store_dir.join(FILE)).unwrap();
assert_eq!(
projects.get(&id).unwrap().path,
destination.to_string_lossy(),
"record_move must not read a live origin as reason to call this a copy"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&origin).ok();
}
#[test]
fn record_move_fails_the_caller_when_the_store_directory_cannot_be_written() {
let blocked = temp_dir("move-blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let (root, id) = seeded_project("move-blocked-proj");
let result = record_move(&blocked, &id, sighting(&root));
assert!(
result.is_err(),
"a blocked store directory must fail record_move"
);
std::fs::remove_file(&blocked).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_nonexistent_store_directory_never_fails_the_caller() {
let store_dir = temp_dir("does-not-exist-yet");
let (root, id) = seeded_project("proj");
note(&store_dir, &id, sighting(&root));
assert!(store_dir.join(FILE).exists());
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn garbage_in_projects_never_fails_the_caller_and_gets_replaced() {
let store_dir = temp_dir("reg");
std::fs::create_dir_all(&store_dir).unwrap();
std::fs::write(store_dir.join(FILE), b"not json at all").unwrap();
let (root, id) = seeded_project("proj");
note(&store_dir, &id, sighting(&root));
let projects = read(&store_dir.join(FILE)).unwrap();
assert_eq!(projects.get(&id).unwrap().path, root.to_string_lossy());
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn root_of_answers_a_noted_project_and_none_for_an_unknown_key() {
let store_dir = temp_dir("reg");
let (root, id) = seeded_project("proj");
note(&store_dir, &id, sighting(&root));
assert_eq!(root_of(&store_dir, &id), Some(root.clone()));
assert_eq!(root_of(&store_dir, "01nosuchprojectaaaaaaaaaaa"), None);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn two_writers_do_not_lose_a_project() {
let store_dir = temp_dir("reg-race");
std::fs::create_dir_all(&store_dir).unwrap();
let projects: Vec<(std::path::PathBuf, String)> = (0..8)
.map(|i| seeded_project(&format!("race-{i}")))
.collect();
std::thread::scope(|s| {
for (root, id) in &projects {
let dir = store_dir.clone();
s.spawn(move || note(&dir, id, sighting(root)));
}
});
let noted = read(&store_dir.join(FILE)).unwrap();
assert_eq!(
noted.len(),
8,
"a concurrent write dropped a project the registry already had"
);
std::fs::remove_dir_all(&store_dir).ok();
for (root, _) in &projects {
std::fs::remove_dir_all(root).ok();
}
}
#[test]
fn the_registry_is_never_left_half_written() {
let store_dir = temp_dir("reg-torn");
let (root, id) = seeded_project("torn");
note(&store_dir, &id, sighting(&root));
let before = std::fs::read(store_dir.join(FILE)).unwrap();
let (root2, id2) = seeded_project("torn2");
let reader = {
let dir = store_dir.clone();
std::thread::spawn(move || {
let mut empty = 0;
for _ in 0..2_000 {
if let Ok(t) = std::fs::read_to_string(dir.join(FILE)) {
if serde_json::from_str::<OnDisk>(&t)
.map(|c| c.projects.is_empty())
.unwrap_or(true)
{
empty += 1;
}
}
}
empty
})
};
let lane_a_dir = temp_dir("torn-lane-a");
let lane_b_dir = temp_dir("torn-lane-b");
for i in 0..200 {
let dir = if i % 2 == 0 { &lane_a_dir } else { &lane_b_dir };
note(
&store_dir,
&id2,
Sighting {
root: &root2,
lane: Some(("lane-torn", dir)),
repos: None,
},
);
}
assert_eq!(reader.join().unwrap(), 0, "a reader saw an empty registry");
assert!(!before.is_empty());
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&root2).ok();
}
#[test]
fn a_version_1_file_is_read_and_rewritten_as_version_2() {
let store_dir = temp_dir("reg-v1");
std::fs::create_dir_all(&store_dir).unwrap();
let id_a = "01aaaaaaaaaaaaaaaaaaaaaaaa";
let id_b = "01bbbbbbbbbbbbbbbbbbbbbbbb";
std::fs::write(
store_dir.join(FILE),
format!(r#"{{"version":1,"projects":{{"{id_a}":"/old/a","{id_b}":"/old/b"}}}}"#),
)
.unwrap();
let new_root = std::path::PathBuf::from("/new/a");
note(&store_dir, id_a, sighting(&new_root));
let text = std::fs::read_to_string(store_dir.join(FILE)).unwrap();
let on_disk: OnDisk = serde_json::from_str(&text).unwrap();
assert_eq!(on_disk.version, VERSION);
assert_eq!(on_disk.projects.len(), 2);
match &on_disk.projects[id_a] {
Entry::V2(p) => assert_eq!(p.path, new_root.to_string_lossy()),
Entry::V1(_) => panic!("id_a is still a bare string after the rewrite"),
}
match &on_disk.projects[id_b] {
Entry::V2(p) => assert_eq!(p.path, "/old/b"),
Entry::V1(_) => panic!("id_b is still a bare string after the rewrite"),
}
std::fs::remove_dir_all(&store_dir).ok();
}
#[test]
fn nothing_changed_writes_nothing() {
let store_dir = temp_dir("reg-nothing");
let (root, id) = seeded_project("nothing");
note(&store_dir, &id, sighting(&root));
let before = std::fs::metadata(store_dir.join(FILE))
.unwrap()
.modified()
.unwrap();
note(&store_dir, &id, sighting(&root));
let after = std::fs::metadata(store_dir.join(FILE))
.unwrap()
.modified()
.unwrap();
assert_eq!(
before, after,
"the no-write case is the one that protects the budget"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn copy_of_never_writes() {
let store_dir = temp_dir("reg-copy-of");
let (root, id) = seeded_project("copy-of");
note(&store_dir, &id, sighting(&root));
let before = std::fs::metadata(store_dir.join(FILE))
.unwrap()
.modified()
.unwrap();
let outcome = copy_of(&store_dir, &id, &root);
let after = std::fs::metadata(store_dir.join(FILE))
.unwrap()
.modified()
.unwrap();
assert!(matches!(outcome, Noted::Fine));
assert_eq!(before, after, "copy_of must never write to the registry");
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn copy_of_never_names_the_folder_asking_about_itself() {
let store_dir = temp_dir("reg-self-copy");
let (original, id) = seeded_project("self-copy-original");
let copy_root = temp_dir("self-copy-copy");
std::fs::create_dir_all(copy_root.join(store::DIR)).unwrap();
std::fs::copy(
original.join(store::DIR).join(store::LOG),
copy_root.join(store::DIR).join(store::LOG),
)
.unwrap();
note(&store_dir, &id, sighting(&original));
note(&store_dir, &id, sighting(©_root));
std::fs::remove_dir_all(&original).unwrap();
let outcome = copy_of(&store_dir, &id, ©_root);
assert!(
matches!(outcome, Noted::Fine),
"a stale self-reference in copies must never name the folder asking about itself"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(©_root).ok();
}
#[test]
fn a_newer_registry_is_left_alone() {
let store_dir = temp_dir("reg-newer");
std::fs::create_dir_all(&store_dir).unwrap();
std::fs::write(store_dir.join(FILE), br#"{"version":99,"projects":{}}"#).unwrap();
let before = std::fs::read(store_dir.join(FILE)).unwrap();
let (root, id) = seeded_project("too-new");
let outcome = note(&store_dir, &id, sighting(&root));
let after = std::fs::read(store_dir.join(FILE)).unwrap();
assert!(matches!(outcome, Noted::Fine));
assert_eq!(
before, after,
"a registry from a newer vivac must not be overwritten"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_second_folder_with_the_same_first_event_is_a_copy() {
let store_dir = temp_dir("reg-copy");
let (first, id) = seeded_project("copy-first");
let second = temp_dir("copy-second");
std::fs::create_dir_all(second.join(store::DIR)).unwrap();
std::fs::copy(
first.join(store::DIR).join(store::LOG),
second.join(store::DIR).join(store::LOG),
)
.unwrap();
let expected_name = first.file_name().unwrap().to_string_lossy().into_owned();
note(&store_dir, &id, sighting(&first));
let outcome = note(&store_dir, &id, sighting(&second));
match outcome {
Noted::Copy { first, rest } => {
assert_eq!(first, Some(expected_name));
assert!(rest.is_empty());
}
Noted::Fine => panic!("a second tree with the same first event is a copy, not a move"),
}
let projects = read(&store_dir.join(FILE)).unwrap();
assert_eq!(
projects.get(&id).unwrap().path,
first.to_string_lossy(),
"the registry must keep pointing at the folder already on file"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&first).ok();
std::fs::remove_dir_all(&second).ok();
}
#[test]
fn a_folder_that_moved_is_not_a_copy() {
let store_dir = temp_dir("reg-moved");
let (old_root, id) = seeded_project("moved-from");
let new_root = temp_dir("moved-to");
note(&store_dir, &id, sighting(&old_root));
std::fs::remove_dir_all(&old_root).unwrap();
let outcome = note(&store_dir, &id, sighting(&new_root));
assert!(matches!(outcome, Noted::Fine));
let projects = read(&store_dir.join(FILE)).unwrap();
assert_eq!(projects.get(&id).unwrap().path, new_root.to_string_lossy());
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&new_root).ok();
}
#[test]
fn a_copy_whose_folder_name_the_guard_rejects_is_not_named() {
let rejected_name = "someone@example.com";
assert!(
crate::redact::check_field("project name", rejected_name).is_some(),
"the guard must actually reject this name, or the test proves nothing"
);
let parent = temp_dir("reg-copy-redacted-parent");
std::fs::create_dir_all(&parent).unwrap();
let first = parent.join(rejected_name);
let id = seed_at(&first);
let second = temp_dir("reg-copy-redacted-second");
std::fs::create_dir_all(second.join(store::DIR)).unwrap();
std::fs::copy(
first.join(store::DIR).join(store::LOG),
second.join(store::DIR).join(store::LOG),
)
.unwrap();
let store_dir = temp_dir("reg-copy-redacted-store");
note(&store_dir, &id, sighting(&first));
let outcome = note(&store_dir, &id, sighting(&second));
assert!(
matches!(outcome, Noted::Copy { first: None, rest } if rest.is_empty()),
"a folder name the guard rejects must not reach the caller"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&parent).ok();
std::fs::remove_dir_all(&second).ok();
}
#[test]
fn lanes_learn_from_whoever_runs_a_command() {
let store_dir = temp_dir("reg-lanes");
let (root, id) = seeded_project("lanes");
let lane_a_dir = temp_dir("lane-a");
let lane_b_dir = temp_dir("lane-b");
note(
&store_dir,
&id,
Sighting {
root: &root,
lane: Some(("lane-a", &lane_a_dir)),
repos: None,
},
);
note(
&store_dir,
&id,
Sighting {
root: &root,
lane: Some(("lane-b", &lane_b_dir)),
repos: None,
},
);
let projects = read(&store_dir.join(FILE)).unwrap();
let project = projects.get(&id).unwrap();
assert_eq!(
project.lanes.get("lane-a").map(String::as_str),
Some(lane_a_dir.to_string_lossy().as_ref())
);
assert_eq!(
project.lanes.get("lane-b").map(String::as_str),
Some(lane_b_dir.to_string_lossy().as_ref())
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_lane_whose_folder_is_gone_is_known_to_be_gone_and_is_not_removed() {
let store_dir = temp_dir("reg-missing-folder");
let (root, id) = seeded_project("missing-folder");
let live_dir = temp_dir("lane-live");
std::fs::create_dir_all(&live_dir).unwrap();
let gone_dir = temp_dir("lane-gone");
note(
&store_dir,
&id,
Sighting {
root: &root,
lane: Some(("live", &live_dir)),
repos: None,
},
);
note(
&store_dir,
&id,
Sighting {
root: &root,
lane: Some(("gone", &gone_dir)),
repos: None,
},
);
assert_eq!(
lanes_with_missing_folder(&store_dir, &id),
vec!["gone".to_string()],
"only the lane whose folder does not exist should come back"
);
let projects = read(&store_dir.join(FILE)).unwrap();
let project = projects.get(&id).unwrap();
assert!(
project.lanes.contains_key("gone"),
"a folder that is gone must not erase the lane's own history"
);
assert!(
project.lanes.contains_key("live"),
"a live lane is unaffected by another one's missing folder"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&live_dir).ok();
}
#[cfg(windows)]
fn second_spelling(p: &Path) -> Option<PathBuf> {
let name = p.file_name()?.to_str()?;
let other: String = name
.chars()
.map(|c| {
if c.is_ascii_uppercase() {
c.to_ascii_lowercase()
} else if c.is_ascii_lowercase() {
c.to_ascii_uppercase()
} else {
c
}
})
.collect();
(other != name).then(|| p.with_file_name(other))
}
#[cfg(not(windows))]
fn second_spelling(_p: &Path) -> Option<PathBuf> {
None
}
#[test]
fn a_folder_reached_by_two_spellings_still_gets_its_lane_recorded() {
let store_dir = temp_dir("reg-spelling-lane");
let (root, id) = seeded_project("Spelling");
let lane_dir = temp_dir("spelling-lane-dir");
note(
&store_dir,
&id,
Sighting {
root: &root,
lane: Some(("lane-a", &lane_dir)),
repos: None,
},
);
let Some(second) = second_spelling(&root) else {
eprintln!(
"skipped: this platform offers no second spelling of the same folder to test with"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
return;
};
note(
&store_dir,
&id,
Sighting {
root: &second,
lane: Some(("lane-b", &lane_dir)),
repos: None,
},
);
let projects = read(&store_dir.join(FILE)).unwrap();
let project = projects.get(&id).unwrap();
assert!(
project.lanes.contains_key("lane-b"),
"the second spelling's own sighting never reached the registry: {:?}",
project.lanes
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn same_folder_by_two_spellings_writes_nothing_the_second_time() {
let store_dir = temp_dir("reg-spelling-unchanged");
let (root, id) = seeded_project("Spelling-Unchanged");
note(&store_dir, &id, sighting(&root));
let before = std::fs::metadata(store_dir.join(FILE))
.unwrap()
.modified()
.unwrap();
let Some(second) = second_spelling(&root) else {
eprintln!(
"skipped: this platform offers no second spelling of the same folder to test with"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
return;
};
note(&store_dir, &id, sighting(&second));
let after = std::fs::metadata(store_dir.join(FILE))
.unwrap()
.modified()
.unwrap();
assert_eq!(
before, after,
"a second spelling of the folder already on file must not write the registry again"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn absolute_resolves_dot_dot_lexically_against_the_current_directory() {
let cwd = std::env::current_dir().unwrap();
let resolved = absolute(Path::new("../elsewhere"));
assert!(resolved.is_absolute(), "{resolved:?} is still relative");
assert_eq!(resolved, cwd.parent().unwrap().join("elsewhere"));
}
#[test]
fn absolute_normalizes_a_path_already_absolute() {
let messy = std::env::current_dir()
.unwrap()
.join("a")
.join("..")
.join("b");
assert_eq!(absolute(&messy), std::env::current_dir().unwrap().join("b"));
}
#[test]
fn sharing_repos_orders_most_shared_first_then_by_name() {
let store_dir = temp_dir("reg-sharing-order");
let parent = temp_dir("reg-sharing-order-parent");
std::fs::create_dir_all(&parent).unwrap();
let root_beta = parent.join("Beta");
let root_alpha = parent.join("Alpha");
let root_two = parent.join("TwoRepos");
let id_beta = seed_at(&root_beta);
let id_alpha = seed_at(&root_alpha);
let id_two = seed_at(&root_two);
note(
&store_dir,
&id_beta,
Sighting {
root: &root_beta,
lane: None,
repos: Some(&["r1".to_string()]),
},
);
note(
&store_dir,
&id_alpha,
Sighting {
root: &root_alpha,
lane: None,
repos: Some(&["r1".to_string()]),
},
);
note(
&store_dir,
&id_two,
Sighting {
root: &root_two,
lane: None,
repos: Some(&["r1".to_string(), "r2".to_string()]),
},
);
let found = sharing_repos(&store_dir, &["r1".to_string(), "r2".to_string()]);
let names: Vec<String> = found.into_iter().map(|s| s.name.unwrap()).collect();
assert_eq!(names, vec!["TwoRepos", "Alpha", "Beta"]);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn sharing_repos_drops_a_project_whose_folder_no_longer_holds_that_tree() {
let store_dir = temp_dir("reg-sharing-dead");
let (root, id) = seeded_project("sharing-dead");
note(
&store_dir,
&id,
Sighting {
root: &root,
lane: None,
repos: Some(&["r1".to_string()]),
},
);
std::fs::remove_dir_all(&root).unwrap();
let found = sharing_repos(&store_dir, &["r1".to_string()]);
assert!(
found.is_empty(),
"a dead folder was still offered as a --join remedy: {found:?}"
);
std::fs::remove_dir_all(&store_dir).ok();
}
}