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>,
}
impl Registry {
pub fn open(
roots: Vec<PathBuf>,
here: Option<(PathBuf, store::Located)>,
) -> Result<Registry, Failure> {
if roots.is_empty() {
return Err(Failure::usage(
"vivac needs at least one root to serve.".to_string(),
));
}
let unique = dedup_by_target(roots);
let pairs = assign_names_and_slugs(&unique);
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(unique.len());
for (root, (name, id)) in unique.into_iter().zip(pairs) {
let key = std::fs::canonicalize(&root).unwrap_or_else(|_| 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,
};
projects.push(Project::open(root, name, id, whose)?);
}
Ok(Registry { projects })
}
pub fn first(&mut self) -> &mut Project {
&mut self.projects[0]
}
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
}
}
fn dedup_by_target(roots: Vec<PathBuf>) -> Vec<PathBuf> {
let mut keys: Vec<PathBuf> = Vec::new();
let mut out: Vec<PathBuf> = Vec::new();
for root in roots {
let key = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
if keys.contains(&key) {
continue;
}
keys.push(key);
out.push(root);
}
out
}
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]) -> Vec<(String, String)> {
roots
.iter()
.map(|r| {
let name = r
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.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()
}
#[test]
fn a_single_root_gets_its_bare_directory_name() {
let slugs = slugs_of(assign_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(assign_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(assign_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 = assign_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 =
assign_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 = assign_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(vec![tmp.clone(), tmp.clone()], None)
.unwrap_or_else(|e| panic!("{}", e.message()));
assert_eq!(registry.first().slug, want);
std::fs::remove_dir_all(&tmp).ok();
}
#[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(vec![tmp.clone()], 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(vec![a.clone(), b.clone()], Some((a.clone(), located_a)))
.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();
}
}