use crate::event::Event;
use crate::failure::Failure;
use crate::{ops, store};
pub use crate::store::Located;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub struct Project {
pub root: PathBuf,
pub slug: String,
pub name: String,
ctx: ops::Ctx,
log: Vec<Event>,
seen: (u64, Option<SystemTime>),
fold_end: u64,
last: Option<crate::index::LastEvent>,
committed_broken: usize,
log_file: Option<File>,
#[cfg(test)]
pub(crate) full_folds: usize,
}
impl Project {
pub fn open(
root: PathBuf,
name: String,
slug: String,
whose: ops::Whose<'_>,
) -> Result<Project, Failure> {
let store = store::Store::open(root.clone())?;
let seen = store::fingerprint(&store.log());
let log_file = File::open(store.log()).ok();
let read = crate::index::read_tracked(&store.log(), 0)?;
let committed_broken = read.broken;
let mut ctx = ops::Ctx::from_events(store, &read.events, committed_broken, seen, whose);
ctx.tree.broken_lines = committed_broken + usize::from(read.unterminated);
Ok(Project {
root,
slug,
name,
ctx,
log: read.events,
seen,
fold_end: read.end_offset,
last: read.last,
committed_broken,
log_file,
#[cfg(test)]
full_folds: 0,
})
}
pub fn ulid(&self) -> Option<&str> {
self.log.first().map(|e| e.id.as_str())
}
pub fn current(&mut self) -> Result<&ops::Ctx, Failure> {
self.current_with_log().map(|(c, _)| c)
}
pub fn current_with_log(&mut self) -> Result<(&ops::Ctx, &[Event]), Failure> {
self.refresh_if_stale()?;
Ok((&self.ctx, &self.log))
}
fn refresh_if_stale(&mut self) -> Result<(), Failure> {
let log_path = self.ctx.store.log();
let now = store::fingerprint(&log_path);
if now == self.seen {
return Ok(());
}
let handle_is_current = self
.log_file
.as_ref()
.is_some_and(|f| store::fingerprint_in(f) == now);
if !handle_is_current {
self.log_file = File::open(&log_path).ok();
}
let grew = now.0 >= self.fold_end
&& match &self.last {
Some(last) => match &self.log_file {
Some(f) => crate::index::event_still_at_in(f, last),
None => crate::index::event_still_at(&log_path, last),
},
None => self.fold_end == 0,
};
if grew {
let tail = self.tracked(&log_path, self.fold_end)?;
for e in &tail.events {
self.ctx.tree.apply(e.seq, &e.ts, &e.lane, &e.payload);
}
self.ctx.tree.sort_nodes();
self.committed_broken += tail.broken;
self.ctx.tree.broken_lines = self.committed_broken + usize::from(tail.unterminated);
self.log.extend(tail.events);
self.fold_end = tail.end_offset;
if tail.last.is_some() {
self.last = tail.last;
}
self.ctx.seen = now;
} else {
let store = store::Store::open(self.root.clone())?;
let store_log = store.log();
self.log_file = File::open(&store_log).ok();
let read = self.tracked(&store_log, 0)?;
self.committed_broken = read.broken;
self.ctx
.refold(store, &read.events, self.committed_broken, now);
self.ctx.tree.broken_lines = self.committed_broken + usize::from(read.unterminated);
self.log = read.events;
self.fold_end = read.end_offset;
self.last = read.last;
#[cfg(test)]
{
self.full_folds += 1;
}
}
self.seen = now;
Ok(())
}
fn tracked(&mut self, path: &Path, from_offset: u64) -> Result<crate::index::Tracked, Failure> {
if let Some(f) = &self.log_file {
match crate::index::read_tracked_in(f, path, from_offset) {
Ok(t) => return Ok(t),
Err(_) => self.log_file = None,
}
}
crate::index::read_tracked(path, from_offset)
}
pub fn write<T>(
&mut self,
f: impl FnOnce(&mut ops::Ctx) -> Result<T, Failure>,
) -> Result<T, Failure> {
self.ctx.lock_for_write()?;
let result = self.locked_write(f);
self.ctx.unlock();
result
}
fn locked_write<T>(
&mut self,
f: impl FnOnce(&mut ops::Ctx) -> Result<T, Failure>,
) -> Result<T, Failure> {
self.refresh_if_stale()?;
self.ctx.wrote = None;
let result = f(&mut self.ctx);
if let Some(w) = self.ctx.wrote.take() {
if w.previous_len == self.fold_end {
if let Some(e) = w.events.last() {
self.last = Some(crate::index::LastEvent {
line_offset: w.last_line_offset,
id: e.id.clone(),
seq: e.seq,
});
}
self.fold_end = w.end_offset;
self.log.extend(w.events);
self.seen = store::fingerprint(&self.ctx.store.log());
} else {
self.last = None;
self.seen = (0, None);
}
}
result
}
}
pub struct Registry {
projects: Vec<Project>,
unreachable: Vec<String>,
}
impl Registry {
pub fn open(
required: Vec<PathBuf>,
optional: Vec<PathBuf>,
here: Option<(PathBuf, store::Located)>,
) -> Result<Registry, Failure> {
let store_dir = store::store_dir();
Registry::open_in(required, optional, here, store_dir.as_deref())
}
pub fn open_in(
required: Vec<PathBuf>,
optional: Vec<PathBuf>,
here: Option<(PathBuf, store::Located)>,
store_dir: Option<&Path>,
) -> Result<Registry, Failure> {
if required.is_empty() && optional.is_empty() {
return Err(Failure::usage(
"vivac needs at least one root to serve.".to_string(),
));
}
let asks = dedup_by_target(required, optional);
let roots: Vec<PathBuf> = asks.iter().map(|a| a.root.clone()).collect();
let pairs = assign_names_and_slugs(&roots, store_dir);
let here_key = here
.as_ref()
.map(|(root, _)| std::fs::canonicalize(root).unwrap_or_else(|_| root.clone()));
let (_, mut here_located) = here.map_or((None, None), |(r, l)| (Some(r), Some(l)));
let mut projects = Vec::with_capacity(asks.len());
let mut unreachable: Vec<String> = Vec::new();
for (ask, (name, id)) in asks.into_iter().zip(pairs) {
let key = std::fs::canonicalize(&ask.root).unwrap_or_else(|_| ask.root.clone());
let located_here = if here_key.as_ref() == Some(&key) {
here_located.take()
} else {
None
};
let whose = match &located_here {
Some(l) => ops::Whose::Resolved(l),
None => ops::Whose::Founding,
};
match Project::open(ask.root, name.clone(), id, whose) {
Ok(p) => projects.push(p),
Err(e) if ask.required => return Err(e),
Err(_) => unreachable.push(name),
}
}
unreachable.sort();
if projects.is_empty() {
return Err(Failure::usage(none_reachable_message(&unreachable)));
}
Ok(Registry {
projects,
unreachable,
})
}
pub fn first(&mut self) -> &mut Project {
&mut self.projects[0]
}
pub fn unreachable(&self) -> &[String] {
&self.unreachable
}
pub fn named(&mut self, id: &str) -> Named {
for p in &mut self.projects {
if p.ulid().is_none() {
let _ = p.current();
}
}
let slugs: Vec<String> = self.projects.iter().map(|p| p.slug.clone()).collect();
let ulids: Vec<Option<String>> = self
.projects
.iter()
.map(|p| p.ulid().map(str::to_string))
.collect();
name_or_ulid(id, &slugs, &ulids)
}
pub fn at(&mut self, i: usize) -> &mut Project {
&mut self.projects[i]
}
pub fn all(&mut self) -> &mut [Project] {
&mut self.projects
}
}
struct Ask {
root: PathBuf,
required: bool,
}
fn dedup_by_target(required: Vec<PathBuf>, optional: Vec<PathBuf>) -> Vec<Ask> {
let mut keys: Vec<PathBuf> = Vec::new();
let mut out: Vec<Ask> = Vec::new();
let asked = required
.into_iter()
.map(|r| (r, true))
.chain(optional.into_iter().map(|r| (r, false)));
for (root, required) in asked {
let key = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
if let Some(pos) = keys.iter().position(|k| *k == key) {
if required {
out[pos].required = true;
}
continue;
}
keys.push(key);
out.push(Ask { root, required });
}
out
}
fn none_reachable_message(names: &[String]) -> String {
match names.len() {
1 => format!(
"The only project this machine knows could not be opened: {}.",
names[0]
),
n => format!(
"None of the {n} projects this machine knows could be opened: {}.",
names.join(", ")
),
}
}
fn sanitize(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut in_run = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
out.push(c);
in_run = false;
} else if !in_run {
out.push('-');
in_run = true;
}
}
out
}
pub enum Named {
One(usize),
Ambiguous(Vec<usize>),
Unknown,
}
fn name_or_ulid(id: &str, slugs: &[String], ulids: &[Option<String>]) -> Named {
let by_ulid: Vec<usize> = ulids
.iter()
.enumerate()
.filter(|(_, u)| u.as_deref() == Some(id))
.map(|(i, _)| i)
.collect();
let hits = if by_ulid.is_empty() {
slugs
.iter()
.enumerate()
.filter(|(_, s)| *s == id)
.map(|(i, _)| i)
.collect()
} else {
by_ulid
};
match hits.len() {
0 => Named::Unknown,
1 => Named::One(hits[0]),
_ => Named::Ambiguous(hits),
}
}
fn assign_names_and_slugs(roots: &[PathBuf], store_dir: Option<&Path>) -> Vec<(String, String)> {
roots
.iter()
.map(|r| {
let name = store_dir
.and_then(|d| crate::registry::effective_name(d, r))
.or_else(|| crate::registry::folder_name(r))
.unwrap_or_else(|| "-".into());
let slug = sanitize(&name);
(name, slug)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn slugs_of(pairs: Vec<(String, String)>) -> Vec<String> {
pairs.into_iter().map(|(_, slug)| slug).collect()
}
fn names_and_slugs(roots: &[PathBuf]) -> Vec<(String, String)> {
assign_names_and_slugs(roots, None)
}
#[test]
fn a_single_root_gets_its_bare_directory_name() {
let slugs = slugs_of(names_and_slugs(&[PathBuf::from("/work/vivac")]));
assert_eq!(slugs, vec!["vivac".to_string()]);
}
#[test]
fn two_roots_with_the_same_directory_name_keep_the_same_slug() {
let slugs = slugs_of(names_and_slugs(&[
PathBuf::from("/a/vivac"),
PathBuf::from("/b/vivac"),
]));
assert_eq!(slugs, vec!["vivac".to_string(), "vivac".to_string()]);
}
#[test]
fn a_root_with_no_directory_name_falls_back_to_a_dash() {
let slugs = slugs_of(names_and_slugs(&[PathBuf::from("/")]));
assert_eq!(slugs, vec!["-".to_string()]);
}
#[test]
fn a_name_with_characters_a_url_cannot_carry_becomes_a_slug_that_can() {
let pairs = names_and_slugs(&[PathBuf::from("/work/my repo#1")]);
assert_eq!(
pairs,
vec![("my repo#1".to_string(), "my-repo-1".to_string())]
);
}
#[test]
fn the_name_is_left_alone_however_the_slug_comes_out() {
let pairs = names_and_slugs(&[PathBuf::from("/a/my repo"), PathBuf::from("/b/my-repo")]);
assert_eq!(
pairs,
vec![
("my repo".to_string(), "my-repo".to_string()),
("my-repo".to_string(), "my-repo".to_string()),
]
);
}
#[test]
fn a_name_with_nothing_a_url_can_carry_still_gets_a_slug() {
let pairs = names_and_slugs(&[PathBuf::from("/work/###")]);
assert!(!pairs[0].1.is_empty());
}
fn slugs(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn ulids(v: &[Option<&str>]) -> Vec<Option<String>> {
v.iter().map(|u| u.map(|s| s.to_string())).collect()
}
#[test]
fn an_unambiguous_name_names_its_project() {
let n = name_or_ulid("ridge", &slugs(&["vivac", "ridge"]), &ulids(&[None, None]));
assert!(matches!(n, Named::One(1)));
}
#[test]
fn a_name_two_roots_share_names_them_all_and_picks_none() {
let n = name_or_ulid(
"vivac",
&slugs(&["vivac", "ridge", "vivac"]),
&ulids(&[None, None, None]),
);
match n {
Named::Ambiguous(which) => assert_eq!(which, vec![0, 2]),
_ => panic!("an ambiguous name resolved to something"),
}
}
#[test]
fn a_ulid_resolves_even_when_the_name_it_carries_is_shared() {
let n = name_or_ulid(
"01m1b46bb82zxqrr24twpk12rw",
&slugs(&["vivac", "vivac"]),
&ulids(&[
Some("01m1zjvaj05n6tp9cw1aq1wq8h"),
Some("01m1b46bb82zxqrr24twpk12rw"),
]),
);
assert!(matches!(n, Named::One(1)));
}
#[test]
fn a_ulid_beats_a_name_that_happens_to_match_it() {
let n = name_or_ulid(
"01m1b46bb82zxqrr24twpk12rw",
&slugs(&["01m1b46bb82zxqrr24twpk12rw", "other"]),
&ulids(&[None, Some("01m1b46bb82zxqrr24twpk12rw")]),
);
assert!(matches!(n, Named::One(1)), "the readable form won");
}
#[test]
fn two_roots_carrying_the_same_ulid_are_reported_not_guessed() {
let n = name_or_ulid(
"01m1b46bb82zxqrr24twpk12rw",
&slugs(&["vivac", "vivac-copy"]),
&ulids(&[
Some("01m1b46bb82zxqrr24twpk12rw"),
Some("01m1b46bb82zxqrr24twpk12rw"),
]),
);
match n {
Named::Ambiguous(which) => assert_eq!(which, vec![0, 1]),
_ => panic!("a duplicated ULID resolved to one project"),
}
}
#[test]
fn an_id_nothing_carries_names_nothing() {
let n = name_or_ulid("nope", &slugs(&["vivac"]), &ulids(&[Some("01m1")]));
assert!(matches!(n, Named::Unknown));
}
#[test]
fn a_tree_with_no_first_event_is_never_matched_by_an_empty_id() {
let n = name_or_ulid("", &slugs(&["vivac"]), &ulids(&[None]));
assert!(matches!(n, Named::Unknown));
}
#[test]
fn the_same_root_given_twice_is_one_project() {
let tmp = std::env::temp_dir().join(format!("vivac-project-t-{}", crate::id::ulid()));
std::fs::create_dir_all(&tmp).unwrap();
store::Store::create(&tmp).unwrap();
let want = tmp.file_name().unwrap().to_string_lossy().into_owned();
let mut registry = Registry::open_in(vec![tmp.clone(), tmp.clone()], vec![], None, None)
.unwrap_or_else(|e| panic!("{}", e.message()));
assert_eq!(registry.first().slug, want);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn an_optional_root_that_fails_to_open_is_skipped_and_named() {
let live = std::env::temp_dir().join(format!("vivac-project-live-{}", crate::id::ulid()));
std::fs::create_dir_all(&live).unwrap();
store::Store::create(&live).unwrap();
let dead = std::env::temp_dir().join(format!("vivac-project-dead-{}", crate::id::ulid()));
let dead_name = dead.file_name().unwrap().to_string_lossy().into_owned();
let mut registry = Registry::open_in(vec![live.clone()], vec![dead], None, None)
.unwrap_or_else(|e| panic!("{}", e.message()));
assert_eq!(registry.all().len(), 1, "the live root was dropped too");
assert_eq!(registry.unreachable().to_vec(), vec![dead_name]);
std::fs::remove_dir_all(&live).ok();
}
#[test]
fn a_required_root_that_fails_to_open_still_fails_the_whole_call() {
let dead =
std::env::temp_dir().join(format!("vivac-project-req-dead-{}", crate::id::ulid()));
let err = Registry::open_in(vec![dead], vec![], None, None);
assert!(
matches!(err, Err(Failure::Io(_))),
"a required root's own failure must reach the caller untouched"
);
}
#[test]
fn a_root_repeated_as_optional_stays_required() {
let dead =
std::env::temp_dir().join(format!("vivac-project-repeat-dead-{}", crate::id::ulid()));
let err = Registry::open_in(vec![dead.clone()], vec![dead], None, None);
assert!(
matches!(err, Err(Failure::Io(_))),
"the required copy must not be softened by the optional one"
);
}
#[test]
fn one_unreachable_project_and_nothing_else_names_it_in_the_singular() {
let dead =
std::env::temp_dir().join(format!("vivac-project-only-dead-{}", crate::id::ulid()));
let name = dead.file_name().unwrap().to_string_lossy().into_owned();
let err = match Registry::open_in(vec![], vec![dead], None, None) {
Err(e) => e,
Ok(_) => panic!("a wholly unreachable root must not open"),
};
assert_eq!(
err.message(),
format!("The only project this machine knows could not be opened: {name}.")
);
}
#[test]
fn several_unreachable_projects_and_nothing_else_names_them_all() {
let a = std::env::temp_dir().join(format!("vivac-project-none-a-{}", crate::id::ulid()));
let b = std::env::temp_dir().join(format!("vivac-project-none-b-{}", crate::id::ulid()));
let (name_a, name_b) = (
a.file_name().unwrap().to_string_lossy().into_owned(),
b.file_name().unwrap().to_string_lossy().into_owned(),
);
let err = match Registry::open_in(vec![], vec![a, b], None, None) {
Err(e) => e,
Ok(_) => panic!("two wholly unreachable roots must not open"),
};
let mut names = [name_a, name_b];
names.sort();
assert_eq!(
err.message(),
format!(
"None of the 2 projects this machine knows could be opened: {}.",
names.join(", ")
)
);
}
#[test]
fn a_tree_with_no_events_yet_has_no_permanent_id() {
let tmp = std::env::temp_dir().join(format!("vivac-project-u-{}", crate::id::ulid()));
std::fs::create_dir_all(&tmp).unwrap();
store::Store::create(&tmp).unwrap();
let mut registry = Registry::open_in(vec![tmp.clone()], vec![], None, None)
.unwrap_or_else(|e| panic!("{}", e.message()));
assert_eq!(registry.first().ulid(), None);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn only_the_project_the_starting_folder_resolves_to_gets_its_lane() {
let a = std::env::temp_dir().join(format!("vivac-registry-a-{}", crate::id::ulid()));
let b = std::env::temp_dir().join(format!("vivac-registry-b-{}", crate::id::ulid()));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
store::Store::create(&a).unwrap();
store::Store::create(&b).unwrap();
let located_a = store::Located {
root: a.clone(),
lane_dir: a.clone(),
lane: Some(crate::lane::Lane {
version: 1,
id: "01mLANE".to_string(),
project: String::new(),
}),
worktree: None,
};
let mut registry = Registry::open_in(
vec![a.clone(), b.clone()],
vec![],
Some((a.clone(), located_a)),
None,
)
.unwrap_or_else(|e| panic!("{}", e.message()));
let with_lane = registry
.all()
.iter()
.find(|p| p.root == a)
.expect("project a is in the registry");
assert_eq!(with_lane.ctx.lane.as_deref(), Some("01mLANE"));
let without_lane = registry
.all()
.iter()
.find(|p| p.root == b)
.expect("project b is in the registry");
assert_eq!(without_lane.ctx.lane.as_deref(), Some(crate::lane::MAIN));
std::fs::remove_dir_all(&a).ok();
std::fs::remove_dir_all(&b).ok();
}
}