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 id: String,
pub name: String,
ctx: ops::Ctx,
log: Vec<Event>,
seen: (u64, Option<SystemTime>),
}
impl Project {
pub fn open(root: PathBuf, name: String, id: 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,
id,
name,
ctx,
log,
seen,
})
}
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_ids(&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 by_id(&mut self, id: &str) -> Option<&mut Project> {
self.projects.iter_mut().find(|p| p.id == id)
}
pub fn projects(&self) -> &[Project] {
&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
}
fn assign_names_and_ids(roots: &[PathBuf]) -> Vec<(String, String)> {
let names: Vec<String> = roots
.iter()
.map(|r| {
r.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "-".into())
})
.collect();
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut pairs = Vec::with_capacity(names.len());
for name in &names {
let bare = sanitize(name);
let id = if used.contains(&bare) {
let mut suffix = 2;
loop {
let candidate = format!("{bare}-{suffix}");
if !used.contains(&candidate) {
break candidate;
}
suffix += 1;
}
} else {
bare
};
used.insert(id.clone());
pairs.push((name.clone(), id));
}
pairs
}
#[cfg(test)]
mod tests {
use super::*;
fn ids_of(pairs: Vec<(String, String)>) -> Vec<String> {
pairs.into_iter().map(|(_, id)| id).collect()
}
#[test]
fn a_single_root_gets_its_bare_directory_name() {
let ids = ids_of(assign_names_and_ids(&[PathBuf::from("/work/vivac")]));
assert_eq!(ids, vec!["vivac".to_string()]);
}
#[test]
fn two_roots_with_the_same_directory_name_get_a_number() {
let ids = ids_of(assign_names_and_ids(&[
PathBuf::from("/a/vivac"),
PathBuf::from("/b/vivac"),
]));
assert_eq!(ids, vec!["vivac".to_string(), "vivac-2".to_string()]);
}
#[test]
fn three_roots_with_the_same_directory_name_count_up() {
let ids = ids_of(assign_names_and_ids(&[
PathBuf::from("/a/vivac"),
PathBuf::from("/b/vivac"),
PathBuf::from("/c/vivac"),
]));
assert_eq!(
ids,
vec![
"vivac".to_string(),
"vivac-2".to_string(),
"vivac-3".to_string(),
]
);
}
#[test]
fn a_root_with_no_directory_name_falls_back_to_a_dash() {
let ids = ids_of(assign_names_and_ids(&[PathBuf::from("/")]));
assert_eq!(ids, vec!["-".to_string()]);
}
#[test]
fn a_suffix_that_is_already_taken_is_skipped() {
let ids = ids_of(assign_names_and_ids(&[
PathBuf::from("/x/a"),
PathBuf::from("/y/a"),
PathBuf::from("/z/a-2"),
]));
let mut sorted = ids.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
ids.len(),
"a duplicate id slipped through: {ids:?}"
);
}
#[test]
fn a_name_with_characters_a_url_cannot_carry_becomes_an_id_that_can() {
let pairs = assign_names_and_ids(&[PathBuf::from("/work/my repo#1")]);
assert_eq!(
pairs,
vec![("my repo#1".to_string(), "my-repo-1".to_string())]
);
}
#[test]
fn collisions_are_resolved_on_the_id_and_the_names_are_left_alone() {
let pairs =
assign_names_and_ids(&[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-2".to_string()),
]
);
}
#[test]
fn a_name_with_nothing_a_url_can_carry_still_gets_an_id() {
let pairs = assign_names_and_ids(&[PathBuf::from("/work/###")]);
assert!(!pairs[0].1.is_empty());
}
#[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().id, want);
std::fs::remove_dir_all(&tmp).ok();
}
}