use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::git::{GitError, Repo};
use crate::model::Node;
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("no workspace named `{name}` (known: {known})")]
UnknownWorkspace {
name: String,
known: String,
},
#[error("several workspaces configured ({known}); select one with `--workspace-name`")]
AmbiguousWorkspace {
known: String,
},
#[error("reading workspace root `{}`: {msg}", .root.display())]
Discover {
root: PathBuf,
msg: String,
},
#[error("`{key}` is not a project-qualified key (expected `<project>::<key>`)")]
Unqualified {
key: String,
},
#[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 {
db: PathBuf,
root: Option<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 { db: x, .. }, Source::Path { db: 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,
}
}
#[must_use]
pub fn from_stores<I, S>(stores: I) -> Self
where
I: IntoIterator<Item = (S, Store)>,
S: Into<String>,
{
let mut projects = BTreeMap::new();
for (name, store) in stores {
let name = dedupe_name(&projects, name.into());
projects.insert(name, Source::Open(Arc::new(Mutex::new(store))));
}
let default = if projects.len() == 1 {
projects.keys().next().cloned()
} else {
None
};
Self {
inner: Mutex::new(Inner {
projects,
default,
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 from_named_dbs<I>(dbs: I) -> Self
where
I: IntoIterator<Item = (String, PathBuf)>,
{
let projects: BTreeMap<String, Source> = dbs
.into_iter()
.map(|(n, db)| (n, Source::Path { db, root: None }))
.collect();
let default = (projects.len() == 1)
.then(|| projects.keys().next().cloned())
.flatten();
Self {
inner: Mutex::new(Inner {
projects,
default,
cache: HashMap::new(),
}),
on_open: None,
}
}
#[must_use]
pub fn member_dbs(&self) -> Vec<PathBuf> {
self.lock()
.map(|i| {
i.projects
.values()
.filter_map(|s| match s {
Source::Path { db, .. } => Some(db.clone()),
Source::Open(_) => None,
})
.collect()
})
.unwrap_or_default()
}
pub fn project_root(&self, project: Option<&str>) -> Result<Option<PathBuf>, WorkspaceError> {
let name = self.resolve(project)?;
let inner = self.lock()?;
Ok(match inner.projects.get(&name) {
Some(Source::Path { root, .. }) => root.clone(),
_ => 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))
}
pub fn with_store_mut<R>(
&self,
project: Option<&str>,
f: impl FnOnce(&mut Store) -> R,
) -> Result<R, WorkspaceError> {
let name = self.resolve(project)?;
let handle = self.handle(&name)?;
let mut store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
Ok(f(&mut store))
}
pub fn resolve_qualified(&self, qualified: &str) -> Result<Option<Node>, WorkspaceError> {
let (project, key) =
parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
key: qualified.to_owned(),
})?;
let key = key.to_owned();
self.with_store(Some(project), move |s| s.get_node(&key))?
.map_err(WorkspaceError::from)
}
pub fn follow_external_ref(&self, node: &Node) -> Result<Option<Node>, WorkspaceError> {
match crate::external_ref_target(node) {
Some(qualified) => self.resolve_qualified(&qualified),
None => Ok(None),
}
}
pub fn follow_definition(&self, qualified: &str) -> Result<Follow, WorkspaceError> {
let (project, key) =
parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
key: qualified.to_owned(),
})?;
let key = key.to_owned();
self.with_store(Some(project), move |store| -> Result<Follow, StoreError> {
let Some(node) = store.get_node(&key)? else {
return Ok(Follow::Drift);
};
if node.kind.as_str() == crate::config_keys::KIND {
match bridge_config_key(store, &node)? {
Some((target, field)) => Ok(Follow::StructField {
node: target,
field,
}),
None => Ok(Follow::Node { node }),
}
} else {
Ok(Follow::Node { node })
}
})?
.map_err(WorkspaceError::from)
}
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, root) = {
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, root }) => (db.clone(), root.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: db.clone(),
root,
};
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(", ")
}
#[must_use]
pub fn parse_qualified(key: &str) -> Option<(&str, &str)> {
key.split_once("::")
.filter(|(project, bare)| !project.is_empty() && !bare.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Follow {
StructField {
node: Node,
field: String,
},
Node {
node: Node,
},
Drift,
}
fn bridge_config_key(store: &Store, cfg_node: &Node) -> Result<Option<(Node, String)>, StoreError> {
let dotted = cfg_node
.meta
.get("key")
.and_then(serde_json::Value::as_str)
.unwrap_or(cfg_node.name.as_str());
let Some((section, leaf)) = split_section_field(dotted) else {
return Ok(None);
};
let leaf_norm = crate::config_keys::normalize(leaf);
if leaf_norm.is_empty() {
return Ok(None);
}
let mut candidates: Vec<Node> = Vec::new();
for name in section_struct_names(section) {
candidates.extend(store.nodes_by_kind_named(&crate::NodeKind::Struct, &name)?);
}
let mut hits = candidates
.into_iter()
.filter(|s| struct_matches_section(&s.name, section))
.filter_map(|s| struct_field_matching(&s, &leaf_norm).map(|field| (s, field)));
match (hits.next(), hits.next()) {
(Some(one), None) => Ok(Some(one)),
_ => Ok(None),
}
}
fn split_section_field(dotted: &str) -> Option<(&str, &str)> {
dotted
.split_once('.')
.filter(|(section, leaf)| !section.is_empty() && !leaf.is_empty())
}
fn section_key(section: &str) -> String {
crate::config_keys::normalize(section).replace('.', "")
}
fn section_struct_names(section: &str) -> Vec<String> {
let want = section_key(section);
if want.is_empty() {
return Vec::new();
}
let with_config = format!("{want}config");
vec![want, with_config]
}
fn struct_matches_section(name: &str, section: &str) -> bool {
let lname = name.to_ascii_lowercase();
let base = lname.strip_suffix("config").unwrap_or(&lname);
let want = section_key(section);
!want.is_empty() && base == want
}
fn struct_field_matching(struct_node: &Node, leaf_norm: &str) -> Option<String> {
struct_node
.meta
.get("fields")?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_str)
.find(|field| crate::config_keys::normalize(field) == leaf_norm)
.map(ToOwned::to_owned)
}
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,
root: repo.workdir().map(Path::to_path_buf),
},
);
}
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;
}
}
pub fn discover_repos_under(root: &Path) -> Result<Vec<PathBuf>, WorkspaceError> {
Ok(scan_root(root)?.repos)
}
fn is_repo(dir: &Path) -> bool {
dir.join(".git").exists()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RootScan {
pub root: PathBuf,
pub repos: Vec<PathBuf>,
pub skipped: Vec<PathBuf>,
}
impl RootScan {
#[must_use]
pub fn nested_repo_parents(&self, limit: usize) -> Vec<&Path> {
self.skipped
.iter()
.take(limit)
.filter(|dir| {
std::fs::read_dir(dir).is_ok_and(|entries| {
entries
.filter_map(Result::ok)
.any(|e| e.path().is_dir() && is_repo(&e.path()))
})
})
.map(PathBuf::as_path)
.collect()
}
}
pub fn scan_root(root: &Path) -> Result<RootScan, WorkspaceError> {
let mut repos = Vec::new();
if is_repo(root) {
repos.push(root.to_path_buf());
}
let entries = std::fs::read_dir(root).map_err(|e| WorkspaceError::Discover {
root: root.to_path_buf(),
msg: e.to_string(),
})?;
let (mut children, mut skipped): (Vec<PathBuf>, Vec<PathBuf>) = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir())
.partition(|p| is_repo(p));
children.sort();
skipped.sort();
repos.extend(children);
Ok(RootScan {
root: root.to_path_buf(),
repos,
skipped,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedWorkspace {
pub name: String,
pub roots: Vec<String>,
pub repos: Vec<String>,
pub linked: bool,
}
struct WorkspaceEntry {
workspace: Arc<Workspace>,
linked: bool,
}
pub struct WorkspaceSet {
entries: BTreeMap<String, WorkspaceEntry>,
default: Option<String>,
}
impl WorkspaceSet {
#[must_use]
pub fn from_workspaces<I>(entries: I) -> Self
where
I: IntoIterator<Item = (String, Workspace, bool)>,
{
let entries: BTreeMap<String, WorkspaceEntry> = entries
.into_iter()
.map(|(name, workspace, linked)| {
(
name,
WorkspaceEntry {
workspace: Arc::new(workspace),
linked,
},
)
})
.collect();
let default = (entries.len() == 1)
.then(|| entries.keys().next().cloned())
.flatten();
Self { entries, default }
}
#[must_use]
pub fn from_single(name: impl Into<String>, workspace: Arc<Workspace>, linked: bool) -> Self {
let name = name.into();
let mut entries = BTreeMap::new();
entries.insert(name.clone(), WorkspaceEntry { workspace, linked });
Self {
entries,
default: Some(name),
}
}
pub fn from_resolved(resolved: Vec<ResolvedWorkspace>) -> Result<Self, WorkspaceError> {
let mut entries: BTreeMap<String, WorkspaceEntry> = BTreeMap::new();
for rw in resolved {
let mut paths: Vec<PathBuf> = Vec::new();
for root in &rw.roots {
paths.extend(discover_repos_under(Path::new(root))?);
}
for repo in &rw.repos {
paths.push(PathBuf::from(repo));
}
if paths.is_empty() {
continue;
}
if rw.linked {
let workspace = Workspace::from_repo_paths(&paths)?;
entries.insert(
rw.name.clone(),
WorkspaceEntry {
workspace: Arc::new(workspace),
linked: true,
},
);
} else {
for (i, path) in paths.iter().enumerate() {
let workspace = Workspace::from_repo_paths([path])?;
let name = if i == 0 {
rw.name.clone()
} else {
format!("{}-{}", rw.name, i + 1)
};
entries.insert(
name,
WorkspaceEntry {
workspace: Arc::new(workspace),
linked: false,
},
);
}
}
}
let default = (entries.len() == 1)
.then(|| entries.keys().next().cloned())
.flatten();
Ok(Self { entries, default })
}
#[must_use]
pub fn names(&self) -> Vec<String> {
self.entries.keys().cloned().collect()
}
#[must_use]
pub fn workspace_handles(&self) -> Vec<(String, Arc<Workspace>)> {
self.entries
.iter()
.map(|(name, entry)| (name.clone(), entry.workspace.clone()))
.collect()
}
#[must_use]
pub fn linked(&self, name: &str) -> Option<bool> {
self.entries.get(name).map(|e| e.linked)
}
pub fn select(&self, name: Option<&str>) -> Result<&Workspace, WorkspaceError> {
if let Some(n) = name {
return self
.entries
.get(n)
.map(|e| e.workspace.as_ref())
.ok_or_else(|| WorkspaceError::UnknownWorkspace {
name: n.to_owned(),
known: self.known(),
});
}
let name = self.default.as_ref().ok_or_else(|| {
if self.entries.is_empty() {
WorkspaceError::Empty
} else {
WorkspaceError::AmbiguousWorkspace {
known: self.known(),
}
}
})?;
Ok(self.entries[name].workspace.as_ref())
}
pub fn select_name(&self, name: Option<&str>) -> Result<&str, WorkspaceError> {
if let Some(n) = name {
return self
.entries
.get_key_value(n)
.map(|(k, _)| k.as_str())
.ok_or_else(|| WorkspaceError::UnknownWorkspace {
name: n.to_owned(),
known: self.known(),
});
}
self.default.as_deref().ok_or_else(|| {
if self.entries.is_empty() {
WorkspaceError::Empty
} else {
WorkspaceError::AmbiguousWorkspace {
known: self.known(),
}
}
})
}
#[must_use]
pub fn containing(&self, cwd_repo_db: &Path) -> Option<&str> {
self.entries.iter().find_map(|(name, e)| {
e.workspace
.member_dbs()
.iter()
.any(|db| db == cwd_repo_db)
.then_some(name.as_str())
})
}
fn known(&self) -> String {
self.entries.keys().cloned().collect::<Vec<_>>().join(", ")
}
}
#[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 from_stores_dedupes_colliding_names() {
let ws = Workspace::from_stores([("repo", store()), ("repo", store())]);
let mut names = ws.names();
names.sort();
assert_eq!(names, vec!["repo".to_owned(), "repo-2".to_owned()]);
assert!(ws.is_multi());
}
#[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);
}
#[test]
fn parse_qualified_splits_on_the_first_double_colon_only() {
assert_eq!(
parse_qualified("app::sym:rust:a.rs#B"),
Some(("app", "sym:rust:a.rs#B"))
);
assert_eq!(parse_qualified("app::file:x"), Some(("app", "file:x")));
assert_eq!(parse_qualified("sym:rust:a.rs#B"), None);
assert_eq!(parse_qualified("::x"), None);
assert_eq!(parse_qualified("app::"), None);
}
#[test]
fn resolve_qualified_finds_drift_and_bad_targets() {
use crate::model::{Node, NodeKind};
let mut s = store();
s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
"file:cfg.rs",
NodeKind::File,
"cfg.rs",
)))
.unwrap();
let ws = Workspace::single("app", s);
let hit = ws.resolve_qualified("app::file:cfg.rs").unwrap();
assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
assert!(ws.resolve_qualified("app::file:gone.rs").unwrap().is_none());
assert!(matches!(
ws.resolve_qualified("ghost::file:x").unwrap_err(),
WorkspaceError::UnknownProject { .. }
));
assert!(matches!(
ws.resolve_qualified("file:cfg.rs").unwrap_err(),
WorkspaceError::Unqualified { .. }
));
}
#[test]
fn follow_external_ref_walks_a_placeholder_to_its_target() {
use crate::links::external_ref_node;
use crate::model::{Node, NodeKind};
let mut s = store();
s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
"file:cfg.rs",
NodeKind::File,
"cfg.rs",
)))
.unwrap();
let ws = Workspace::single("app", s);
let placeholder = external_ref_node("app::file:cfg.rs");
let hit = ws.follow_external_ref(&placeholder).unwrap();
assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
let gone = external_ref_node("app::file:gone.rs");
assert!(ws.follow_external_ref(&gone).unwrap().is_none());
let plain = Node::new("file:cfg.rs", NodeKind::File, "cfg.rs");
assert!(ws.follow_external_ref(&plain).unwrap().is_none());
}
fn cfg_node(dotted: &str) -> crate::model::Node {
use crate::model::{Node, NodeKind};
let mut n = Node::new(
format!("cfgkey:config.toml#{dotted}"),
NodeKind::Other("config_key".to_owned()),
dotted,
);
n.meta = serde_json::json!({ "key": dotted, "value": "x" });
n
}
fn struct_node(name: &str, fields: &[&str]) -> crate::model::Node {
use crate::model::{Node, NodeKind};
let mut n = Node::new(format!("sym:rust:config.rs#{name}"), NodeKind::Struct, name);
n.meta = serde_json::json!({ "fields": fields });
n
}
fn bridge_hub() -> Workspace {
use crate::model::FactSet;
let mut s = store();
s.apply_factset(
&FactSet::new()
.with_node(struct_node("ServeConfig", &["addr", "tools", "tls_cert"]))
.with_node(struct_node("ModelsConfig", &["embedding", "generative"]))
.with_node(cfg_node("serve.addr"))
.with_node(cfg_node("serve.tls_cert"))
.with_node(cfg_node("serve.ghost")) .with_node(cfg_node("mystery.addr")) .with_node(cfg_node("port")), )
.unwrap();
Workspace::single("hub", s)
}
#[test]
fn follow_bridges_config_key_to_its_defining_struct_field() {
let ws = bridge_hub();
match ws
.follow_definition("hub::cfgkey:config.toml#serve.addr")
.unwrap()
{
Follow::StructField { node, field } => {
assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
assert_eq!(field, "addr");
}
other => panic!("expected a struct-field bridge, got {other:?}"),
}
match ws
.follow_definition("hub::cfgkey:config.toml#serve.tls_cert")
.unwrap()
{
Follow::StructField { node, field } => {
assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
assert_eq!(field, "tls_cert");
}
other => panic!("expected a struct-field bridge, got {other:?}"),
}
}
#[test]
fn follow_falls_back_to_config_key_when_not_confidently_bridgeable() {
let ws = bridge_hub();
let ghost = ws
.follow_definition("hub::cfgkey:config.toml#serve.ghost")
.unwrap();
assert!(
matches!(&ghost, Follow::Node { node } if node.name == "serve.ghost"),
"unmatched field falls back to the config_key node, got {ghost:?}"
);
let mystery = ws
.follow_definition("hub::cfgkey:config.toml#mystery.addr")
.unwrap();
assert!(matches!(&mystery, Follow::Node { node } if node.name == "mystery.addr"));
let port = ws
.follow_definition("hub::cfgkey:config.toml#port")
.unwrap();
assert!(matches!(&port, Follow::Node { node } if node.name == "port"));
}
#[test]
fn follow_does_not_bridge_on_ambiguity() {
use crate::model::FactSet;
let mut s = store();
s.apply_factset(
&FactSet::new()
.with_node(struct_node("ServeConfig", &["addr"]))
.with_node(struct_node("Serve", &["addr"])) .with_node(cfg_node("serve.addr")),
)
.unwrap();
let ws = Workspace::single("hub", s);
let out = ws
.follow_definition("hub::cfgkey:config.toml#serve.addr")
.unwrap();
assert!(
matches!(&out, Follow::Node { node } if node.name == "serve.addr"),
"ambiguous (two matching structs) falls back, got {out:?}"
);
}
#[test]
fn follow_narrow_lookup_ignores_unrelated_structs_with_the_same_field() {
use crate::model::FactSet;
let mut s = store();
s.apply_factset(
&FactSet::new()
.with_node(struct_node("ServeConfig", &["addr"]))
.with_node(struct_node("Unrelated", &["addr"]))
.with_node(struct_node("Widget", &["addr", "size"]))
.with_node(struct_node("ModelsConfig", &["embedding"]))
.with_node(cfg_node("serve.addr")),
)
.unwrap();
let ws = Workspace::single("hub", s);
match ws
.follow_definition("hub::cfgkey:config.toml#serve.addr")
.unwrap()
{
Follow::StructField { node, field } => {
assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
assert_eq!(field, "addr");
}
other => panic!("expected a struct-field bridge to ServeConfig, got {other:?}"),
}
}
#[test]
fn follow_reports_drift_and_passes_through_non_config_targets() {
use crate::model::{FactSet, Node, NodeKind};
let mut s = store();
s.apply_factset(&FactSet::new().with_node(Node::new(
"sym:rust:a.rs#Thing",
NodeKind::Struct,
"Thing",
)))
.unwrap();
let ws = Workspace::single("hub", s);
assert_eq!(
ws.follow_definition("hub::cfgkey:config.toml#gone")
.unwrap(),
Follow::Drift
);
match ws.follow_definition("hub::sym:rust:a.rs#Thing").unwrap() {
Follow::Node { node } => assert_eq!(node.key, "sym:rust:a.rs#Thing"),
other => panic!("expected pass-through, got {other:?}"),
}
}
#[test]
fn workspace_set_select_single_ambiguous_and_unknown() {
let one = WorkspaceSet::from_workspaces([(
"only".to_owned(),
Workspace::single("only", store()),
true,
)]);
assert_eq!(one.names(), vec!["only".to_owned()]);
assert_eq!(one.linked("only"), Some(true));
assert!(one.linked("nope").is_none());
assert!(one.select(None).is_ok());
assert!(one.select(Some("only")).is_ok());
assert!(matches!(
one.select(Some("ghost")),
Err(WorkspaceError::UnknownWorkspace { .. })
));
let many = WorkspaceSet::from_workspaces([
("api".to_owned(), Workspace::single("api", store()), true),
("web".to_owned(), Workspace::single("web", store()), false),
]);
assert_eq!(many.names(), vec!["api".to_owned(), "web".to_owned()]);
assert_eq!(many.linked("web"), Some(false));
let Err(err) = many.select(None) else {
panic!("a bare select over several workspaces must be ambiguous");
};
assert!(matches!(err, WorkspaceError::AmbiguousWorkspace { .. }));
assert!(err.to_string().contains("api"));
assert!(err.to_string().contains("web"));
assert!(many.select(Some("web")).is_ok());
assert!(matches!(
many.select(Some("ghost")),
Err(WorkspaceError::UnknownWorkspace { .. })
));
let none = WorkspaceSet::from_workspaces(std::iter::empty());
assert!(matches!(none.select(None), Err(WorkspaceError::Empty)));
}
#[test]
fn workspace_set_containing_finds_the_owning_workspace_by_db_path() {
let api_db = PathBuf::from("/ws/api/svc/.git/roteiro/graph.db");
let web_db = PathBuf::from("/ws/web/app/.git/roteiro/graph.db");
let set = WorkspaceSet::from_workspaces([
(
"api".to_owned(),
Workspace::from_named_dbs([("svc".to_owned(), api_db.clone())]),
true,
),
(
"web".to_owned(),
Workspace::from_named_dbs([("app".to_owned(), web_db.clone())]),
false,
),
]);
assert_eq!(set.containing(&api_db), Some("api"));
assert_eq!(set.containing(&web_db), Some("web"));
assert_eq!(
set.containing(Path::new("/elsewhere/.git/roteiro/graph.db")),
None
);
}
#[test]
fn a_shallow_scan_reports_the_directories_it_walked_past() {
let base = std::env::temp_dir().join(format!("rto-scan-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
for dir in ["direct/.git", "orgA/repo1/.git", "orgB/repo2/.git", "empty"] {
std::fs::create_dir_all(base.join(dir)).expect("mkdir");
}
let scan = scan_root(&base).expect("scan");
assert_eq!(scan.repos, vec![base.join("direct")]);
assert_eq!(discover_repos_under(&base).expect("discover"), scan.repos);
assert_eq!(
scan.skipped,
vec![base.join("empty"), base.join("orgA"), base.join("orgB")],
);
assert_eq!(
scan.nested_repo_parents(64),
vec![base.join("orgA").as_path(), base.join("orgB").as_path()],
);
assert_eq!(
scan.nested_repo_parents(2),
vec![base.join("orgA").as_path()],
"`limit` bounds the directories examined, not the ones reported",
);
assert!(scan.nested_repo_parents(0).is_empty());
std::fs::remove_dir_all(&base).ok();
}
}