use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::git::{GitError, Repo};
use crate::store::{Store, StoreError};
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceError {
#[error("no project named `{name}` (known: {known})")]
UnknownProject {
name: String,
known: String,
},
#[error("this server hosts several projects ({known}); name one with `project`")]
AmbiguousProject {
known: String,
},
#[error("no projects registered")]
Empty,
#[error("project `{name}` has no graph yet — run `roteiro sync` in {}", .path.display())]
NoGraph {
name: String,
path: PathBuf,
},
#[error("failed to prepare project `{name}` on first access: {msg}")]
Prepare {
name: String,
msg: String,
},
#[error("store lock poisoned")]
Poisoned,
#[error(transparent)]
Git(#[from] GitError),
#[error(transparent)]
Store(#[from] StoreError),
}
#[derive(Clone)]
enum Source {
Path(PathBuf),
Open(Arc<Mutex<Store>>),
}
struct Inner {
projects: BTreeMap<String, Source>,
default: Option<String>,
cache: HashMap<String, (Source, Arc<Mutex<Store>>)>,
}
fn source_eq(a: &Source, b: &Source) -> bool {
match (a, b) {
(Source::Path(x), Source::Path(y)) => x == y,
(Source::Open(x), Source::Open(y)) => Arc::ptr_eq(x, y),
_ => false,
}
}
pub type OnOpen = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;
pub struct Workspace {
inner: Mutex<Inner>,
on_open: Option<OnOpen>,
}
impl Workspace {
#[must_use]
pub fn single(name: impl Into<String>, store: Store) -> Self {
let name = name.into();
let mut projects = BTreeMap::new();
projects.insert(name.clone(), Source::Open(Arc::new(Mutex::new(store))));
Self {
inner: Mutex::new(Inner {
projects,
default: Some(name),
cache: HashMap::new(),
}),
on_open: None,
}
}
pub fn from_repo_paths<I, P>(paths: I) -> Result<Self, WorkspaceError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let (projects, default) = build_registry(paths)?;
Ok(Self {
inner: Mutex::new(Inner {
projects,
default,
cache: HashMap::new(),
}),
on_open: None,
})
}
#[must_use]
pub fn with_on_open(mut self, hook: OnOpen) -> Self {
self.on_open = Some(hook);
self
}
pub fn reload_from<I, P>(&self, paths: I) -> Result<Vec<String>, WorkspaceError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let (projects, default) = build_registry(paths)?;
let names: Vec<String> = projects.keys().cloned().collect();
let mut inner = self.lock()?;
inner
.cache
.retain(|name, (src, _)| projects.get(name).is_some_and(|new| source_eq(new, src)));
inner.projects = projects;
inner.default = default;
Ok(names)
}
#[must_use]
pub fn names(&self) -> Vec<String> {
self.lock()
.map(|i| i.projects.keys().cloned().collect())
.unwrap_or_default()
}
#[must_use]
pub fn is_multi(&self) -> bool {
self.lock().is_ok_and(|i| i.projects.len() > 1)
}
pub fn resolve(&self, project: Option<&str>) -> Result<String, WorkspaceError> {
let inner = self.lock()?;
match project {
Some(name) if inner.projects.contains_key(name) => Ok(name.to_owned()),
Some(name) => Err(WorkspaceError::UnknownProject {
name: name.to_owned(),
known: keys(&inner.projects),
}),
None => inner.default.clone().ok_or_else(|| {
if inner.projects.is_empty() {
WorkspaceError::Empty
} else {
WorkspaceError::AmbiguousProject {
known: keys(&inner.projects),
}
}
}),
}
}
pub fn with_store<R>(
&self,
project: Option<&str>,
f: impl FnOnce(&Store) -> R,
) -> Result<R, WorkspaceError> {
let name = self.resolve(project)?;
let handle = self.handle(&name)?;
let store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
Ok(f(&store))
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Inner>, WorkspaceError> {
self.inner.lock().map_err(|_| WorkspaceError::Poisoned)
}
fn handle(&self, name: &str) -> Result<Arc<Mutex<Store>>, WorkspaceError> {
let db = {
let mut inner = self.lock()?;
if let Some((_, handle)) = inner.cache.get(name) {
return Ok(handle.clone());
}
match inner.projects.get(name) {
Some(Source::Open(handle)) => {
let handle = handle.clone();
inner.cache.insert(
name.to_owned(),
(Source::Open(handle.clone()), handle.clone()),
);
return Ok(handle);
}
Some(Source::Path(db)) => db.clone(),
None => {
return Err(WorkspaceError::UnknownProject {
name: name.to_owned(),
known: keys(&inner.projects),
});
}
}
};
if let Some(on_open) = &self.on_open {
on_open(&db).map_err(|msg| WorkspaceError::Prepare {
name: name.to_owned(),
msg,
})?;
}
if !db.exists() {
return Err(WorkspaceError::NoGraph {
name: name.to_owned(),
path: db
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.unwrap_or(&db)
.to_path_buf(),
});
}
let handle = Arc::new(Mutex::new(Store::open(&db)?));
let opened = Source::Path(db.clone());
let mut inner = self.lock()?;
if let Some((_, existing)) = inner.cache.get(name) {
return Ok(existing.clone());
}
if inner
.projects
.get(name)
.is_some_and(|current| source_eq(current, &opened))
{
inner
.cache
.insert(name.to_owned(), (opened, handle.clone()));
}
Ok(handle)
}
}
fn keys(projects: &BTreeMap<String, Source>) -> String {
projects.keys().cloned().collect::<Vec<_>>().join(", ")
}
type Registry = (BTreeMap<String, Source>, Option<String>);
fn build_registry<I, P>(paths: I) -> Result<Registry, WorkspaceError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut projects: BTreeMap<String, Source> = BTreeMap::new();
let mut seen_dbs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for path in paths {
let repo = Repo::discover(path.as_ref())?;
let db = repo.git_dir().join("roteiro").join("graph.db");
if !seen_dbs.insert(db.clone()) {
continue;
}
let base = repo
.workdir()
.and_then(Path::file_name)
.map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
let name = dedupe_name(&projects, base);
projects.insert(name, Source::Path(db));
}
if projects.is_empty() {
return Err(WorkspaceError::Empty);
}
let default = if projects.len() == 1 {
projects.keys().next().cloned()
} else {
None
};
Ok((projects, default))
}
fn dedupe_name(projects: &BTreeMap<String, Source>, base: String) -> String {
if !projects.contains_key(&base) {
return base;
}
let mut n = 2u32;
loop {
let candidate = format!("{base}-{n}");
if !projects.contains_key(&candidate) {
return candidate;
}
n += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
fn store() -> Store {
Store::open_in_memory().expect("in-memory store")
}
#[test]
fn single_project_is_the_default_and_resolves_bare() {
let ws = Workspace::single("myrepo", store());
assert_eq!(ws.names(), vec!["myrepo".to_owned()]);
assert!(!ws.is_multi());
assert_eq!(ws.resolve(None).unwrap(), "myrepo");
assert_eq!(ws.resolve(Some("myrepo")).unwrap(), "myrepo");
let n = ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
assert_eq!(n, 0);
}
#[test]
fn unknown_project_is_an_error_naming_the_known_ones() {
let ws = Workspace::single("a", store());
let err = ws.resolve(Some("b")).unwrap_err();
assert!(matches!(err, WorkspaceError::UnknownProject { .. }));
assert!(err.to_string().contains("known: a"));
}
#[test]
fn cached_store_handle_is_reused() {
let ws = Workspace::single("a", store());
ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
let again = ws.handle("a").unwrap();
assert!(Arc::strong_count(&again) >= 2);
}
}