use crate::event::Event;
use crate::failure::Failure;
use crate::{ops, store};
use std::path::PathBuf;
use std::time::SystemTime;
fn fingerprint(log: &std::path::Path) -> (u64, Option<SystemTime>) {
match std::fs::metadata(log) {
Ok(m) => (m.len(), m.modified().ok()),
Err(_) => (0, None),
}
}
pub struct Project {
pub root: PathBuf,
pub slug: String,
pub name: String,
ctx: ops::Ctx,
log: Vec<Event>,
seen: (u64, Option<SystemTime>),
}
impl Project {
pub fn open(root: PathBuf, name: String, slug: String) -> Result<Project, Failure> {
let (ctx, log) = ops::Ctx::load_with_log(store::Store::open(root.clone())?)?;
let seen = fingerprint(&ctx.store.log());
Ok(Project {
root,
slug,
name,
ctx,
log,
seen,
})
}
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 now = fingerprint(&self.ctx.store.log());
if now != self.seen {
let (ctx, log) = ops::Ctx::load_with_log(store::Store::open(self.root.clone())?)?;
self.ctx = ctx;
self.log = log;
self.seen = now;
}
Ok(())
}
pub fn write<T>(
&mut self,
f: impl FnOnce(&mut ops::Ctx) -> Result<T, Failure>,
) -> Result<T, Failure> {
self.refresh_if_stale()?;
let result = f(&mut self.ctx);
self.seen = fingerprint(&self.ctx.store.log());
result
}
}
pub struct Registry {
projects: Vec<Project>,
}
impl Registry {
pub fn open(roots: Vec<PathBuf>) -> 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 mut projects = Vec::with_capacity(unique.len());
for (root, (name, id)) in unique.into_iter().zip(pairs) {
projects.push(Project::open(root, name, id)?);
}
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()])
.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()]).unwrap_or_else(|e| panic!("{}", e.message()));
assert_eq!(registry.first().ulid(), None);
std::fs::remove_dir_all(&tmp).ok();
}
}