rto_graph/workspace.rs
1//! A **workspace**: many per-repo graphs served by one process (ADR-0008).
2//!
3//! Each Roteiro graph is per-repo — a small `SQLite` store at
4//! `<repo>/.git/roteiro/graph.db`. The expensive resource a server holds is the
5//! *model*, not the graphs, so one process can hold the model once and answer
6//! questions about **any** registered repo by opening that repo's store on
7//! demand and caching it. A [`Workspace`] is that registry + on-demand,
8//! cached store resolver; the tool surfaces (MCP and the `/v1` model server)
9//! call [`Workspace::with_store`] with an optional `project` selector.
10//!
11//! Single-repo serving is just a workspace with one project (see
12//! [`Workspace::single`]), so the default `serve` path is unchanged.
13//!
14//! The registry can be **reloaded** in place ([`Workspace::reload_from`]) so a
15//! long-lived server can pick up added/removed repos without a restart (a SIGHUP
16//! trigger); already-open stores for still-present projects keep their warm
17//! connections, and dropped projects are evicted. An optional first-open hook
18//! ([`Workspace::with_on_open`], `serve --sync-on-access`) (re)builds a project's
19//! graph the first time it is queried.
20
21use std::collections::{BTreeMap, HashMap};
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex};
24
25use crate::git::{GitError, Repo};
26use crate::model::Node;
27use crate::store::{Store, StoreError};
28
29/// A failure resolving or opening a project's graph.
30#[derive(Debug, thiserror::Error)]
31pub enum WorkspaceError {
32 /// A call named a project the workspace does not know.
33 #[error("no project named `{name}` (known: {known})")]
34 UnknownProject {
35 /// The requested name.
36 name: String,
37 /// Comma-separated list of known project names.
38 known: String,
39 },
40 /// A call omitted `project` but the workspace has no single default (it holds
41 /// several projects), so the selection is ambiguous.
42 #[error("this server hosts several projects ({known}); name one with `project`")]
43 AmbiguousProject {
44 /// Comma-separated list of known project names.
45 known: String,
46 },
47 /// The workspace is registered but empty (no repos resolved).
48 #[error("no projects registered")]
49 Empty,
50 /// A selector named a workspace the [`WorkspaceSet`] does not know.
51 #[error("no workspace named `{name}` (known: {known})")]
52 UnknownWorkspace {
53 /// The requested workspace name.
54 name: String,
55 /// Comma-separated list of known workspace names.
56 known: String,
57 },
58 /// A selection omitted a name but the [`WorkspaceSet`] holds several
59 /// workspaces, so the choice is ambiguous.
60 #[error("several workspaces configured ({known}); select one with `--workspace-name`")]
61 AmbiguousWorkspace {
62 /// Comma-separated list of known workspace names.
63 known: String,
64 },
65 /// Reading a workspace root directory during repo discovery failed.
66 #[error("reading workspace root `{}`: {msg}", .root.display())]
67 Discover {
68 /// The root directory that could not be read.
69 root: PathBuf,
70 /// The underlying I/O error message.
71 msg: String,
72 },
73 /// A cross-repo target was not a project-qualified key (`<project>::<key>`).
74 #[error("`{key}` is not a project-qualified key (expected `<project>::<key>`)")]
75 Unqualified {
76 /// The malformed key.
77 key: String,
78 },
79 /// The project's graph store does not exist yet — its repo has not been
80 /// synced (`roteiro sync`).
81 #[error("project `{name}` has no graph yet — run `roteiro sync` in {}", .path.display())]
82 NoGraph {
83 /// The project name.
84 name: String,
85 /// The repo directory whose graph is missing.
86 path: PathBuf,
87 },
88 /// The on-open hook (`serve --sync-on-access`) failed to prepare a project's
89 /// graph before it was first served.
90 #[error("failed to prepare project `{name}` on first access: {msg}")]
91 Prepare {
92 /// The project name.
93 name: String,
94 /// The hook's error message.
95 msg: String,
96 },
97 /// A store lock was poisoned by a panic in another thread.
98 #[error("store lock poisoned")]
99 Poisoned,
100 /// Discovering the repo for a registered path failed.
101 #[error(transparent)]
102 Git(#[from] GitError),
103 /// Opening the project's store failed.
104 #[error(transparent)]
105 Store(#[from] StoreError),
106}
107
108/// Where a project's store comes from: a `graph.db` to open on demand, or an
109/// already-open store (the single-repo default and tests).
110#[derive(Clone)]
111enum Source {
112 /// Open this `graph.db` path on first use.
113 Path(PathBuf),
114 /// A pre-opened store, shared directly.
115 Open(Arc<Mutex<Store>>),
116}
117
118/// The registry plus the open-store cache, behind one lock. Held only briefly —
119/// to look up a source or (un)cache a handle — never across a graph query, which
120/// runs on the returned per-store `Mutex` after this lock is released.
121struct Inner {
122 /// Project name → its store source, in stable name order.
123 projects: BTreeMap<String, Source>,
124 /// The project used when a call omits `project` (the sole project, if there
125 /// is exactly one; otherwise `None` and a bare call is ambiguous).
126 default: Option<String>,
127 /// Opened stores, cached by project name, tagged with the [`Source`] they
128 /// were opened from. `Store` is `!Sync` (it holds a rusqlite connection), so
129 /// each is behind its own `Mutex`. The tag lets a reload keep a warm
130 /// connection only when the project still maps to the *same* source, and
131 /// never serve a handle for a repo the name no longer points at.
132 cache: HashMap<String, (Source, Arc<Mutex<Store>>)>,
133}
134
135/// Whether two sources denote the same store: the same `graph.db` path, or the
136/// very same pre-opened handle.
137fn source_eq(a: &Source, b: &Source) -> bool {
138 match (a, b) {
139 (Source::Path(x), Source::Path(y)) => x == y,
140 (Source::Open(x), Source::Open(y)) => Arc::ptr_eq(x, y),
141 _ => false,
142 }
143}
144
145/// A hook run against a project's `graph.db` path the first time it is opened —
146/// used by `serve --sync-on-access` to (re)build a stale or missing graph before
147/// it is served (ADR-0008). Returns a human-readable error on failure.
148pub type OnOpen = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;
149
150/// A named set of per-repo graphs, each opened on demand and cached. Cheap to
151/// hold: the stores are small `SQLite` files opened lazily; the caller (a server)
152/// holds the one expensive model. The registry is reloadable in place.
153pub struct Workspace {
154 inner: Mutex<Inner>,
155 /// Optional first-open hook (`serve --sync-on-access`): run against a
156 /// project's `graph.db` path before it is opened, to sync it on demand.
157 on_open: Option<OnOpen>,
158}
159
160impl Workspace {
161 /// A single-project workspace over an already-open `store`, named `name`.
162 /// This is the single-repo `serve` default and the test constructor; a bare
163 /// (no-`project`) call resolves to it. Not reloadable (no repo paths).
164 #[must_use]
165 pub fn single(name: impl Into<String>, store: Store) -> Self {
166 let name = name.into();
167 let mut projects = BTreeMap::new();
168 projects.insert(name.clone(), Source::Open(Arc::new(Mutex::new(store))));
169 Self {
170 inner: Mutex::new(Inner {
171 projects,
172 default: Some(name),
173 cache: HashMap::new(),
174 }),
175 on_open: None,
176 }
177 }
178
179 /// A workspace over several already-open stores, one per named project — the
180 /// in-memory counterpart of [`Workspace::from_repo_paths`] (which opens each
181 /// project's `graph.db` from disk lazily). Used for multi-repo serving of
182 /// pre-built stores and for tests. With exactly one project it becomes the
183 /// default (as [`Workspace::single`]); with several, a bare (no-`project`)
184 /// call is ambiguous. Not reloadable (no repo paths).
185 #[must_use]
186 pub fn from_stores<I, S>(stores: I) -> Self
187 where
188 I: IntoIterator<Item = (S, Store)>,
189 S: Into<String>,
190 {
191 let mut projects = BTreeMap::new();
192 for (name, store) in stores {
193 // Dedupe like `from_repo_paths` (`-2`, `-3`, …) so two stores sharing a
194 // base name both survive instead of the second silently overwriting the
195 // first (which would drop a project).
196 let name = dedupe_name(&projects, name.into());
197 projects.insert(name, Source::Open(Arc::new(Mutex::new(store))));
198 }
199 // Mirror `from_repo_paths`: a lone project is the default; several are
200 // ambiguous until a call names one.
201 let default = if projects.len() == 1 {
202 projects.keys().next().cloned()
203 } else {
204 None
205 };
206 Self {
207 inner: Mutex::new(Inner {
208 projects,
209 default,
210 cache: HashMap::new(),
211 }),
212 on_open: None,
213 }
214 }
215
216 /// Build a workspace from repo directories: each is `git`-discovered, named
217 /// after its working-tree directory (collisions get a `-2`, `-3`, … suffix),
218 /// and its `graph.db` opened lazily. With exactly one repo, that repo is the
219 /// default project.
220 ///
221 /// # Errors
222 /// [`WorkspaceError::Git`] if a path is not inside a git repository, or
223 /// [`WorkspaceError::Empty`] if `paths` resolves to no repos.
224 pub fn from_repo_paths<I, P>(paths: I) -> Result<Self, WorkspaceError>
225 where
226 I: IntoIterator<Item = P>,
227 P: AsRef<Path>,
228 {
229 let (projects, default) = build_registry(paths)?;
230 Ok(Self {
231 inner: Mutex::new(Inner {
232 projects,
233 default,
234 cache: HashMap::new(),
235 }),
236 on_open: None,
237 })
238 }
239
240 /// Build a workspace from explicit `(project name, graph.db path)` pairs,
241 /// **without** git discovery — used where the names and store locations are
242 /// already known ([`WorkspaceSet`] construction re-uses the CLI's discovery
243 /// upstream, and tests build synthetic registries). Names are taken verbatim
244 /// (deduplicate before calling if a collision is possible); with exactly one
245 /// pair, that project is the default.
246 #[must_use]
247 pub fn from_named_dbs<I>(dbs: I) -> Self
248 where
249 I: IntoIterator<Item = (String, PathBuf)>,
250 {
251 let projects: BTreeMap<String, Source> = dbs
252 .into_iter()
253 .map(|(n, db)| (n, Source::Path(db)))
254 .collect();
255 let default = (projects.len() == 1)
256 .then(|| projects.keys().next().cloned())
257 .flatten();
258 Self {
259 inner: Mutex::new(Inner {
260 projects,
261 default,
262 cache: HashMap::new(),
263 }),
264 on_open: None,
265 }
266 }
267
268 /// The `graph.db` paths of the workspace's lazily-opened (`Path`) projects, in
269 /// stable name order. Pre-opened (`single`) projects carry no path and are
270 /// omitted. Used by [`WorkspaceSet::containing`] to find which workspace holds
271 /// a given repo.
272 #[must_use]
273 pub fn member_dbs(&self) -> Vec<PathBuf> {
274 self.lock()
275 .map(|i| {
276 i.projects
277 .values()
278 .filter_map(|s| match s {
279 Source::Path(p) => Some(p.clone()),
280 Source::Open(_) => None,
281 })
282 .collect()
283 })
284 .unwrap_or_default()
285 }
286
287 /// Set a first-open hook (`serve --sync-on-access`): before a project's store
288 /// is opened for the first time, `hook` is run against its `graph.db` path to
289 /// (re)build it. Applies to lazily-opened `Path` projects; a pre-opened
290 /// `single` store is already loaded, so the hook does not fire for it.
291 #[must_use]
292 pub fn with_on_open(mut self, hook: OnOpen) -> Self {
293 self.on_open = Some(hook);
294 self
295 }
296
297 /// Rebuild the registry from a fresh set of repo `paths`: added repos become
298 /// available, removed ones are dropped (and their cached store evicted), and
299 /// still-present ones keep their warm connection. Returns the new project
300 /// names. Use this to reload a running server (e.g. on SIGHUP) without a
301 /// restart. A single-project pre-opened workspace ([`Workspace::single`]) has
302 /// no repo paths, so reloading it simply replaces it with the given repos.
303 ///
304 /// # Errors
305 /// As [`Workspace::from_repo_paths`].
306 pub fn reload_from<I, P>(&self, paths: I) -> Result<Vec<String>, WorkspaceError>
307 where
308 I: IntoIterator<Item = P>,
309 P: AsRef<Path>,
310 {
311 // Build the new registry outside the lock (discovery does git I/O).
312 let (projects, default) = build_registry(paths)?;
313 let names: Vec<String> = projects.keys().cloned().collect();
314 let mut inner = self.lock()?;
315 // Keep a warm connection only where the project still maps to the *same*
316 // source; drop it if the name is gone or now points at a different
317 // `graph.db` (or was a pre-opened `single` store), so a query never hits
318 // the wrong repo.
319 inner
320 .cache
321 .retain(|name, (src, _)| projects.get(name).is_some_and(|new| source_eq(new, src)));
322 inner.projects = projects;
323 inner.default = default;
324 Ok(names)
325 }
326
327 /// The registered project names, in stable order.
328 #[must_use]
329 pub fn names(&self) -> Vec<String> {
330 self.lock()
331 .map(|i| i.projects.keys().cloned().collect())
332 .unwrap_or_default()
333 }
334
335 /// Whether the workspace holds more than one project (so `project` selection
336 /// is meaningful to expose to callers/tools).
337 #[must_use]
338 pub fn is_multi(&self) -> bool {
339 self.lock().is_ok_and(|i| i.projects.len() > 1)
340 }
341
342 /// Resolve `project` (or the default) to a concrete project name.
343 ///
344 /// # Errors
345 /// [`WorkspaceError::UnknownProject`] if named but absent,
346 /// [`WorkspaceError::AmbiguousProject`] if omitted with several projects, or
347 /// [`WorkspaceError::Empty`] if there are none.
348 pub fn resolve(&self, project: Option<&str>) -> Result<String, WorkspaceError> {
349 let inner = self.lock()?;
350 match project {
351 Some(name) if inner.projects.contains_key(name) => Ok(name.to_owned()),
352 Some(name) => Err(WorkspaceError::UnknownProject {
353 name: name.to_owned(),
354 known: keys(&inner.projects),
355 }),
356 None => inner.default.clone().ok_or_else(|| {
357 if inner.projects.is_empty() {
358 WorkspaceError::Empty
359 } else {
360 WorkspaceError::AmbiguousProject {
361 known: keys(&inner.projects),
362 }
363 }
364 }),
365 }
366 }
367
368 /// Run `f` with the resolved project's store (opened and cached on first
369 /// use). The store lock is held only for `f`, never across an `.await`.
370 ///
371 /// # Errors
372 /// As [`Workspace::resolve`], plus [`WorkspaceError::NoGraph`] if the store
373 /// file is absent, [`WorkspaceError::Store`] on open failure, or
374 /// [`WorkspaceError::Poisoned`] if a lock was poisoned.
375 pub fn with_store<R>(
376 &self,
377 project: Option<&str>,
378 f: impl FnOnce(&Store) -> R,
379 ) -> Result<R, WorkspaceError> {
380 let name = self.resolve(project)?;
381 let handle = self.handle(&name)?;
382 let store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
383 Ok(f(&store))
384 }
385
386 /// Resolve a **project-qualified** key `"<project>::<key>"` to its node across
387 /// the workspace, opening the target project on demand (ADR-0009). `Ok(None)`
388 /// means the key is well-formed and the project exists but the node does not —
389 /// i.e. **cross-repo drift** (a removed or renamed target). Errors distinguish
390 /// the other failure modes so a caller can report them precisely:
391 /// [`WorkspaceError::Unqualified`] (not in `<project>::<key>` form),
392 /// [`WorkspaceError::UnknownProject`] (target repo not in the workspace),
393 /// [`WorkspaceError::NoGraph`] (target repo unsynced).
394 ///
395 /// # Errors
396 /// As above, plus [`WorkspaceError::Store`] / [`WorkspaceError::Poisoned`].
397 pub fn resolve_qualified(&self, qualified: &str) -> Result<Option<Node>, WorkspaceError> {
398 let (project, key) =
399 parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
400 key: qualified.to_owned(),
401 })?;
402 let key = key.to_owned();
403 self.with_store(Some(project), move |s| s.get_node(&key))?
404 .map_err(WorkspaceError::from)
405 }
406
407 /// Follow an **external-ref** placeholder node to the real node it stands for,
408 /// resolving its project-qualified target across the workspace (ADR-0009). An
409 /// external-ref lives in a spoke's store as a local stand-in for a node in the
410 /// hub's store (see [`crate::external_ref_node`]); this walks it through to the
411 /// hub. `Ok(None)` means either `node` is not an external-ref, or its target no
412 /// longer resolves — cross-repo drift (a removed or renamed hub key). Errors
413 /// distinguish the other failure modes, as [`Workspace::resolve_qualified`].
414 ///
415 /// # Errors
416 /// As [`Workspace::resolve_qualified`].
417 pub fn follow_external_ref(&self, node: &Node) -> Result<Option<Node>, WorkspaceError> {
418 match crate::external_ref_target(node) {
419 Some(qualified) => self.resolve_qualified(&qualified),
420 None => Ok(None),
421 }
422 }
423
424 /// Follow a **project-qualified** cross-repo target to the most specific
425 /// *definition* it names — the follow-the-link hop that turns a click on a
426 /// spoke's app-key target into a jump to the hub node that defines it.
427 ///
428 /// [`Workspace::resolve_qualified`] lands on the raw hub node a spoke points
429 /// at, which for a config override is the hub's `config_key` node (e.g.
430 /// `cfgkey:config.toml#serve.addr`), *not* the Rust struct that declares the
431 /// setting. This method adds the net-new **`config_key` → struct bridge**: when
432 /// the resolved node is a config key whose dotted path maps — with confidence —
433 /// to exactly one hub struct and one of its named fields, it returns that
434 /// struct as the jump target ([`Follow::StructField`], carrying the matched
435 /// field name). Otherwise it returns the resolved node unchanged
436 /// ([`Follow::Node`]) — a config key we could not bridge, or any non-config
437 /// target (e.g. an authored `[[links]]` that already points at a symbol). A
438 /// well-formed target whose node is gone is [`Follow::Drift`].
439 ///
440 /// The bridge is deliberately conservative (see [`bridge_config_key`]): it
441 /// fires only on a *unique* match of both an independent section→struct-name
442 /// signal and a field-presence signal, so it never jumps to a **wrong** node —
443 /// an ambiguous or unmatched key falls back to the config-key node.
444 ///
445 /// # Errors
446 /// As [`Workspace::resolve_qualified`] (a well-formed but unhosted / unsynced
447 /// target project still errors; a resolved-but-missing node is `Drift`).
448 pub fn follow_definition(&self, qualified: &str) -> Result<Follow, WorkspaceError> {
449 let (project, key) =
450 parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
451 key: qualified.to_owned(),
452 })?;
453 let key = key.to_owned();
454 self.with_store(Some(project), move |store| -> Result<Follow, StoreError> {
455 let Some(node) = store.get_node(&key)? else {
456 return Ok(Follow::Drift);
457 };
458 // Only a config-key node needs bridging; anything else the spoke points
459 // at is already a definition-level target. Compare against the stable
460 // token via `as_str()` — no allocation to build a throwaway `NodeKind`.
461 if node.kind.as_str() == crate::config_keys::KIND {
462 match bridge_config_key(store, &node)? {
463 Some((target, field)) => Ok(Follow::StructField {
464 node: target,
465 field,
466 }),
467 None => Ok(Follow::Node { node }),
468 }
469 } else {
470 Ok(Follow::Node { node })
471 }
472 })?
473 .map_err(WorkspaceError::from)
474 }
475
476 /// Lock the inner state, mapping a poisoned lock to [`WorkspaceError::Poisoned`].
477 fn lock(&self) -> Result<std::sync::MutexGuard<'_, Inner>, WorkspaceError> {
478 self.inner.lock().map_err(|_| WorkspaceError::Poisoned)
479 }
480
481 /// Get (opening + caching on first use) the shared store handle for `name`.
482 /// Opens `graph.db` **outside** the registry lock so a first-touch open never
483 /// blocks other projects' queries.
484 fn handle(&self, name: &str) -> Result<Arc<Mutex<Store>>, WorkspaceError> {
485 // Fast path and pre-opened sources resolve under a single short lock.
486 let db = {
487 let mut inner = self.lock()?;
488 if let Some((_, handle)) = inner.cache.get(name) {
489 return Ok(handle.clone());
490 }
491 match inner.projects.get(name) {
492 Some(Source::Open(handle)) => {
493 let handle = handle.clone();
494 inner.cache.insert(
495 name.to_owned(),
496 (Source::Open(handle.clone()), handle.clone()),
497 );
498 return Ok(handle);
499 }
500 Some(Source::Path(db)) => db.clone(),
501 None => {
502 return Err(WorkspaceError::UnknownProject {
503 name: name.to_owned(),
504 known: keys(&inner.projects),
505 });
506 }
507 }
508 };
509 // `serve --sync-on-access`: (re)build this project's graph before opening
510 // it, so a stale or never-synced repo is prepared on first touch. Runs
511 // outside the registry lock (it does extraction I/O).
512 if let Some(on_open) = &self.on_open {
513 on_open(&db).map_err(|msg| WorkspaceError::Prepare {
514 name: name.to_owned(),
515 msg,
516 })?;
517 }
518 if !db.exists() {
519 return Err(WorkspaceError::NoGraph {
520 name: name.to_owned(),
521 // The repo dir is the store's grandparent (`…/.git/roteiro`).
522 path: db
523 .parent()
524 .and_then(Path::parent)
525 .and_then(Path::parent)
526 .unwrap_or(&db)
527 .to_path_buf(),
528 });
529 }
530 let handle = Arc::new(Mutex::new(Store::open(&db)?));
531 let opened = Source::Path(db.clone());
532 let mut inner = self.lock()?;
533 // Another thread may have opened it while we were; prefer the existing.
534 if let Some((_, existing)) = inner.cache.get(name) {
535 return Ok(existing.clone());
536 }
537 // Only cache if the registry still maps this name to the DB we opened —
538 // a concurrent `reload_from` may have remapped or removed it. If so,
539 // return the freshly-opened handle for this call (the caller resolved
540 // before the reload) but do not cache a now-stale mapping.
541 if inner
542 .projects
543 .get(name)
544 .is_some_and(|current| source_eq(current, &opened))
545 {
546 inner
547 .cache
548 .insert(name.to_owned(), (opened, handle.clone()));
549 }
550 Ok(handle)
551 }
552}
553
554/// Comma-separated project names (for error messages).
555fn keys(projects: &BTreeMap<String, Source>) -> String {
556 projects.keys().cloned().collect::<Vec<_>>().join(", ")
557}
558
559/// Split a **project-qualified** key `"<project>::<key>"` into `(project, key)`,
560/// or `None` if it carries no `::` separator (a bare, within-repo key). A project
561/// name never contains `::`; a bare key may itself contain single colons (e.g.
562/// `sym:rust:…`), so only the **first** double-colon separates the project
563/// (ADR-0009).
564#[must_use]
565pub fn parse_qualified(key: &str) -> Option<(&str, &str)> {
566 key.split_once("::")
567 .filter(|(project, bare)| !project.is_empty() && !bare.is_empty())
568}
569
570/// The outcome of [`Workspace::follow_definition`]: where a cross-repo follow-hop
571/// lands.
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub enum Follow {
574 /// Bridged past a `config_key` node to the hub **struct** that declares the
575 /// setting, carrying the specific named field that matched (e.g. the
576 /// `ServeConfig` struct for `serve.addr`, `field = "addr"`). The `node` is the
577 /// real struct node, so a caller can center it in the hub graph.
578 StructField {
579 /// The defining struct node (`sym:rust:<file>#<Struct>`).
580 node: Node,
581 /// The struct field the dotted key resolved to (its declared identifier).
582 field: String,
583 },
584 /// The resolved target node itself, unbridged — a `config_key` we could not map
585 /// to a struct with confidence (the safe fallback), or any non-config target a
586 /// spoke points straight at.
587 Node {
588 /// The resolved hub node.
589 node: Node,
590 },
591 /// The target is well-formed but its node is gone — cross-repo drift.
592 Drift,
593}
594
595/// Bridge a hub **`config_key`** node to the Rust **struct** that declares it, plus
596/// the specific field matched — the net-new step behind [`Workspace::follow_definition`].
597///
598/// The mapping from a dotted config key (`serve.addr`) to a defining Rust field is
599/// not recorded anywhere in the graph (the extractor models structs as nodes but
600/// not their fields as nodes, and a field's *type* is not captured), so this is a
601/// **resolve-time join** over two independent, deterministic signals — and it only
602/// bridges when they agree on exactly one struct:
603///
604/// 1. **section → struct name.** The dotted key's head segment (`serve`) must name
605/// the struct: its lower-cased name, with a trailing `Config` stripped, equals
606/// the section (`ServeConfig` → `serve`; a bare `Serve` also matches). See
607/// [`struct_matches_section`].
608/// 2. **field presence.** The struct must actually declare a field whose
609/// normalised name equals the key's leaf (`addr`, or `tls_cert` for
610/// `serve.tls_cert`) — read from the struct's `meta.fields`. See
611/// [`struct_field_matching`].
612///
613/// Requiring a **unique** `(struct, field)` hit is the correctness rule: a key that
614/// matches zero structs (no such section, or the field isn't declared) or more than
615/// one (genuinely ambiguous) returns `None`, and the caller falls back to the
616/// config-key node rather than risk jumping to a wrong definition.
617///
618/// Known limits (documented, deliberate): a single-segment key (no section, e.g.
619/// `port`) is never bridged; a key nested past one level (`serve.tls.cert` where
620/// `tls` is a sub-struct) won't match a flat field and falls back; and a struct
621/// whose name doesn't follow the `<Section>Config` convention won't be found. All
622/// three degrade to the existing config-key target — never to a wrong one.
623fn bridge_config_key(store: &Store, cfg_node: &Node) -> Result<Option<(Node, String)>, StoreError> {
624 // The dotted key: authoritative from `meta.key`, falling back to the node name
625 // (both are the dotted path in practice — see config-key extraction).
626 let dotted = cfg_node
627 .meta
628 .get("key")
629 .and_then(serde_json::Value::as_str)
630 .unwrap_or(cfg_node.name.as_str());
631 let Some((section, leaf)) = split_section_field(dotted) else {
632 return Ok(None);
633 };
634 let leaf_norm = crate::config_keys::normalize(leaf);
635 if leaf_norm.is_empty() {
636 return Ok(None);
637 }
638
639 // Fetch only the CANDIDATE struct(s) for this section by name, rather than
640 // loading and JSON-decoding every `struct` node in the graph on each hop
641 // (a latency spike on a large hub). `section_struct_names` yields the exact
642 // lower-cased names `struct_matches_section` would accept, so this narrows the
643 // scan without changing the bridging semantics; `struct_matches_section` is
644 // still applied below as the authoritative check.
645 let mut candidates: Vec<Node> = Vec::new();
646 for name in section_struct_names(section) {
647 candidates.extend(store.nodes_by_kind_named(&crate::NodeKind::Struct, &name)?);
648 }
649
650 let mut hits = candidates
651 .into_iter()
652 .filter(|s| struct_matches_section(&s.name, section))
653 .filter_map(|s| struct_field_matching(&s, &leaf_norm).map(|field| (s, field)));
654
655 match (hits.next(), hits.next()) {
656 // Exactly one confident match → bridge to it.
657 (Some(one), None) => Ok(Some(one)),
658 // Zero or ambiguous (>1) → fall back to the config-key node.
659 _ => Ok(None),
660 }
661}
662
663/// Split a dotted config key into `(section, leaf)` on its **first** separator:
664/// `serve.addr` → `("serve", "addr")`, `serve.tls_cert` → `("serve", "tls_cert")`.
665/// A single-segment key (`port`) has no section to identify a struct by, so it is
666/// `None` (never bridged).
667fn split_section_field(dotted: &str) -> Option<(&str, &str)> {
668 dotted
669 .split_once('.')
670 .filter(|(section, leaf)| !section.is_empty() && !leaf.is_empty())
671}
672
673/// The section's canonical form for name-matching: normalised, separators removed
674/// (`serve` → `serve`, `serve_mode` → `servemode`). Empty when the section carries
675/// no alphanumerics.
676fn section_key(section: &str) -> String {
677 crate::config_keys::normalize(section).replace('.', "")
678}
679
680/// The lower-cased struct names a config `section` can map to — exactly the names
681/// [`struct_matches_section`] accepts: `serve` → `["serve", "serveconfig"]`. Used
682/// to fetch just the candidate struct(s) by name instead of scanning them all
683/// (kept in lock-step with [`struct_matches_section`], which remains the check).
684fn section_struct_names(section: &str) -> Vec<String> {
685 let want = section_key(section);
686 if want.is_empty() {
687 return Vec::new();
688 }
689 let with_config = format!("{want}config");
690 vec![want, with_config]
691}
692
693/// Whether a struct `name` is the one a config `section` maps to: its lower-cased
694/// name with a trailing `config` stripped equals the section (case- and
695/// separator-insensitive). `ServeConfig`/`Serve` both match section `serve`;
696/// `ServeSettings` does not (so an unrelated struct is never bridged to).
697fn struct_matches_section(name: &str, section: &str) -> bool {
698 let lname = name.to_ascii_lowercase();
699 let base = lname.strip_suffix("config").unwrap_or(&lname);
700 let want = section_key(section);
701 !want.is_empty() && base == want
702}
703
704/// The struct field whose normalised identifier equals `leaf_norm`, read from the
705/// struct node's `meta.fields` (see extraction). Returns the field's original
706/// declared name (for display), or `None` when the struct declares no such field.
707fn struct_field_matching(struct_node: &Node, leaf_norm: &str) -> Option<String> {
708 struct_node
709 .meta
710 .get("fields")?
711 .as_array()?
712 .iter()
713 .filter_map(serde_json::Value::as_str)
714 .find(|field| crate::config_keys::normalize(field) == leaf_norm)
715 .map(ToOwned::to_owned)
716}
717
718/// Discover repos at `paths` into a `(name → Source, default)` registry: each
719/// path is git-discovered, named after its working-tree directory (deduped), and
720/// mapped to a lazily-opened `graph.db`. Exactly one repo ⇒ it is the default.
721type Registry = (BTreeMap<String, Source>, Option<String>);
722fn build_registry<I, P>(paths: I) -> Result<Registry, WorkspaceError>
723where
724 I: IntoIterator<Item = P>,
725 P: AsRef<Path>,
726{
727 let mut projects: BTreeMap<String, Source> = BTreeMap::new();
728 let mut seen_dbs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
729 for path in paths {
730 let repo = Repo::discover(path.as_ref())?;
731 let db = repo.git_dir().join("roteiro").join("graph.db");
732 // De-duplicate the same repo reached via different paths (O(1) lookup, so
733 // discovery stays linear even on a big workspace and every reload).
734 if !seen_dbs.insert(db.clone()) {
735 continue;
736 }
737 let base = repo
738 .workdir()
739 .and_then(Path::file_name)
740 .map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
741 let name = dedupe_name(&projects, base);
742 projects.insert(name, Source::Path(db));
743 }
744 if projects.is_empty() {
745 return Err(WorkspaceError::Empty);
746 }
747 let default = if projects.len() == 1 {
748 projects.keys().next().cloned()
749 } else {
750 None
751 };
752 Ok((projects, default))
753}
754
755/// Make `base` unique against the names already in `projects`, appending
756/// `-2`, `-3`, … on collision.
757fn dedupe_name(projects: &BTreeMap<String, Source>, base: String) -> String {
758 if !projects.contains_key(&base) {
759 return base;
760 }
761 let mut n = 2u32;
762 loop {
763 let candidate = format!("{base}-{n}");
764 if !projects.contains_key(&candidate) {
765 return candidate;
766 }
767 n += 1;
768 }
769}
770
771/// Shallow git-repo discovery under `root`: the root itself if it is a repo, plus
772/// each immediate subdirectory that is one, in sorted order. Shallow by design — a
773/// code directory holding sibling checkouts is the common case, and a deep scan
774/// would be slow and surprising. Shared by the CLI's workspace collection and
775/// [`WorkspaceSet`] / config resolution, so the membership rule lives in one place.
776///
777/// A repo is any directory containing a `.git` entry (a directory in a normal
778/// clone, a file in worktrees and submodules), so existence — not `is_dir` — is
779/// tested.
780///
781/// # Errors
782/// [`WorkspaceError::Discover`] if `root` cannot be read.
783pub fn discover_repos_under(root: &Path) -> Result<Vec<PathBuf>, WorkspaceError> {
784 let is_repo = |dir: &Path| dir.join(".git").exists();
785 let mut repos = Vec::new();
786 if is_repo(root) {
787 repos.push(root.to_path_buf());
788 }
789 let entries = std::fs::read_dir(root).map_err(|e| WorkspaceError::Discover {
790 root: root.to_path_buf(),
791 msg: e.to_string(),
792 })?;
793 let mut children: Vec<PathBuf> = entries
794 .filter_map(Result::ok)
795 .map(|e| e.path())
796 .filter(|p| p.is_dir() && is_repo(p))
797 .collect();
798 children.sort();
799 repos.extend(children);
800 Ok(repos)
801}
802
803/// A workspace group after config normalisation ([`crate::WorkspaceSet`] input): a
804/// name, its member `roots`/`repos` (unexpanded — discovered when the set is
805/// built), and whether its repos are cross-**linked** (served as one multi-repo
806/// graph) or **standalone** (each its own single-repo graph, no cross-repo links).
807///
808/// A `linked = false` (standalone) group denotes **exactly one** single-repo graph:
809/// the config normaliser emits one such group per discovered repo, and
810/// [`WorkspaceSet::from_resolved`] upholds the invariant by materialising a
811/// standalone group as a one-repo [`Workspace`] per member — a standalone group can
812/// never collapse several repos into one unlinked multi-repo graph.
813#[derive(Debug, Clone, PartialEq, Eq)]
814pub struct ResolvedWorkspace {
815 /// The workspace name (the `--workspace-name` selector).
816 pub name: String,
817 /// Directories to scan for member repos (as `[workspace] roots`).
818 pub roots: Vec<String>,
819 /// Explicit member repo paths, in addition to anything under `roots`.
820 pub repos: Vec<String>,
821 /// `true` ⇒ the repos form one linked graph; `false` ⇒ **standalone**: each
822 /// member repo is its own single-repo graph (no cross-repo links).
823 pub linked: bool,
824}
825
826/// One entry in a [`WorkspaceSet`]: a built [`Workspace`] plus whether its member
827/// repos are cross-linked. The workspace is held behind an `Arc` so an
828/// already-shared workspace (e.g. the one a `serve` process holds for its model
829/// tools and MCP router) can be wrapped into a set without re-opening its stores
830/// ([`WorkspaceSet::from_single`]).
831struct WorkspaceEntry {
832 /// The per-group workspace (one repo for a standalone singleton, several for a
833 /// linked group).
834 workspace: Arc<Workspace>,
835 /// Whether the group's repos are cross-linked.
836 linked: bool,
837}
838
839/// An install's **many** named workspaces: linked groups (multi-repo graphs) and
840/// standalone singletons (one-repo graphs), keyed by name in stable order (ADR-0008
841/// multi-workspace). The outer layer over [`Workspace`]: it selects *which*
842/// workspace a command operates on, then hands back that `Workspace` to resolve
843/// projects within it. Built from normalised config ([`WorkspaceSet::from_resolved`])
844/// so the `serve`/`links` selection logic is shared.
845pub struct WorkspaceSet {
846 /// Workspace name → its entry, in stable (`BTreeMap`) name order.
847 entries: BTreeMap<String, WorkspaceEntry>,
848 /// The workspace used when a selection omits a name (the sole workspace, if
849 /// there is exactly one; otherwise `None` and a bare selection is ambiguous).
850 default: Option<String>,
851}
852
853impl WorkspaceSet {
854 /// Assemble a set from pre-built named workspaces — the shared core of
855 /// [`WorkspaceSet::from_resolved`] and the test constructor. With exactly one
856 /// entry, that workspace is the default (a bare selection resolves to it).
857 #[must_use]
858 pub fn from_workspaces<I>(entries: I) -> Self
859 where
860 I: IntoIterator<Item = (String, Workspace, bool)>,
861 {
862 let entries: BTreeMap<String, WorkspaceEntry> = entries
863 .into_iter()
864 .map(|(name, workspace, linked)| {
865 (
866 name,
867 WorkspaceEntry {
868 workspace: Arc::new(workspace),
869 linked,
870 },
871 )
872 })
873 .collect();
874 let default = (entries.len() == 1)
875 .then(|| entries.keys().next().cloned())
876 .flatten();
877 Self { entries, default }
878 }
879
880 /// Wrap an already-built [`Workspace`] (shared via `Arc`) as a one-entry set
881 /// under `name`, with `linked` recording whether that workspace is a
882 /// cross-linked multi-repo group. Used where a single `Workspace` is served as
883 /// the whole set — e.g. `roteiro serve` merges the read-only graph API over the
884 /// one workspace it already holds for its model tools and MCP router, so the
885 /// API's flat routes resolve to it as the sole (default) workspace. The store
886 /// handles are shared, never re-opened.
887 #[must_use]
888 pub fn from_single(name: impl Into<String>, workspace: Arc<Workspace>, linked: bool) -> Self {
889 let name = name.into();
890 let mut entries = BTreeMap::new();
891 entries.insert(name.clone(), WorkspaceEntry { workspace, linked });
892 Self {
893 entries,
894 default: Some(name),
895 }
896 }
897
898 /// Build a set from normalised config groups: each group's `roots`/`repos` are
899 /// discovered into member repo paths and opened as [`Workspace`]s. A **linked**
900 /// group becomes one multi-repo graph. A **standalone** (`linked = false`) group
901 /// becomes one single-repo graph **per member repo** — the invariant that a
902 /// standalone workspace is exactly one repo is upheld *here*, by splitting, so a
903 /// hand-built group can never collapse several repos into one unlinked multi-repo
904 /// graph (the config normaliser already emits standalone as per-repo singletons,
905 /// so in practice each such group has exactly one repo and the split is a no-op).
906 /// On a split, the extra members take a `-2`/`-3` suffix off the group name. A
907 /// group that resolves to **no** repos is skipped, so a stale root never aborts
908 /// the whole set.
909 ///
910 /// # Errors
911 /// [`WorkspaceError::Discover`] if a group's root cannot be read, or
912 /// [`WorkspaceError::Git`] if an explicit repo path is not inside a git repo.
913 pub fn from_resolved(resolved: Vec<ResolvedWorkspace>) -> Result<Self, WorkspaceError> {
914 let mut entries: BTreeMap<String, WorkspaceEntry> = BTreeMap::new();
915 for rw in resolved {
916 let mut paths: Vec<PathBuf> = Vec::new();
917 for root in &rw.roots {
918 paths.extend(discover_repos_under(Path::new(root))?);
919 }
920 for repo in &rw.repos {
921 paths.push(PathBuf::from(repo));
922 }
923 if paths.is_empty() {
924 // A group naming nothing (e.g. a `roots` dir with no repos) is
925 // simply absent rather than an error.
926 continue;
927 }
928 if rw.linked {
929 let workspace = Workspace::from_repo_paths(&paths)?;
930 entries.insert(
931 rw.name.clone(),
932 WorkspaceEntry {
933 workspace: Arc::new(workspace),
934 linked: true,
935 },
936 );
937 } else {
938 // Standalone: one single-repo graph per member, enforcing the
939 // `linked = false` ⇒ exactly-one-repo invariant structurally (the
940 // config normaliser already emits one repo per group, so this is a
941 // no-op split there; it only matters if a group is hand-built).
942 for (i, path) in paths.iter().enumerate() {
943 let workspace = Workspace::from_repo_paths([path])?;
944 let name = if i == 0 {
945 rw.name.clone()
946 } else {
947 format!("{}-{}", rw.name, i + 1)
948 };
949 entries.insert(
950 name,
951 WorkspaceEntry {
952 workspace: Arc::new(workspace),
953 linked: false,
954 },
955 );
956 }
957 }
958 }
959 let default = (entries.len() == 1)
960 .then(|| entries.keys().next().cloned())
961 .flatten();
962 Ok(Self { entries, default })
963 }
964
965 /// The configured workspace names, in stable order.
966 #[must_use]
967 pub fn names(&self) -> Vec<String> {
968 self.entries.keys().cloned().collect()
969 }
970
971 /// Whether workspace `name` is linked (`Some(true)`), standalone
972 /// (`Some(false)`), or unknown (`None`).
973 #[must_use]
974 pub fn linked(&self, name: &str) -> Option<bool> {
975 self.entries.get(name).map(|e| e.linked)
976 }
977
978 /// Select a workspace by `name`, or the default when `name` is `None`.
979 ///
980 /// # Errors
981 /// [`WorkspaceError::UnknownWorkspace`] if named but absent,
982 /// [`WorkspaceError::AmbiguousWorkspace`] if omitted with several configured,
983 /// or [`WorkspaceError::Empty`] if none are configured.
984 pub fn select(&self, name: Option<&str>) -> Result<&Workspace, WorkspaceError> {
985 if let Some(n) = name {
986 return self
987 .entries
988 .get(n)
989 .map(|e| e.workspace.as_ref())
990 .ok_or_else(|| WorkspaceError::UnknownWorkspace {
991 name: n.to_owned(),
992 known: self.known(),
993 });
994 }
995 // No name given: the sole workspace, else ambiguous / empty.
996 let name = self.default.as_ref().ok_or_else(|| {
997 if self.entries.is_empty() {
998 WorkspaceError::Empty
999 } else {
1000 WorkspaceError::AmbiguousWorkspace {
1001 known: self.known(),
1002 }
1003 }
1004 })?;
1005 Ok(self.entries[name].workspace.as_ref())
1006 }
1007
1008 /// The **name** of the workspace [`WorkspaceSet::select`] resolves for `name`:
1009 /// the given name when present (and valid), else the sole/default workspace's
1010 /// name. Same resolution and errors as `select`, but returns the concrete name
1011 /// — so a caller (e.g. the `/follow` endpoint) can report which workspace it
1012 /// actually resolved in, even on a flat route where the default was implicit.
1013 ///
1014 /// # Errors
1015 /// As [`WorkspaceSet::select`].
1016 pub fn select_name(&self, name: Option<&str>) -> Result<&str, WorkspaceError> {
1017 if let Some(n) = name {
1018 return self
1019 .entries
1020 .get_key_value(n)
1021 .map(|(k, _)| k.as_str())
1022 .ok_or_else(|| WorkspaceError::UnknownWorkspace {
1023 name: n.to_owned(),
1024 known: self.known(),
1025 });
1026 }
1027 self.default.as_deref().ok_or_else(|| {
1028 if self.entries.is_empty() {
1029 WorkspaceError::Empty
1030 } else {
1031 WorkspaceError::AmbiguousWorkspace {
1032 known: self.known(),
1033 }
1034 }
1035 })
1036 }
1037
1038 /// The name of the workspace whose member repos include the repo whose graph is
1039 /// `cwd_repo_db` (`<repo>/.git/roteiro/graph.db`), or `None` if no workspace
1040 /// contains it. Used to default `--workspace-name` to the workspace the current
1041 /// directory belongs to.
1042 #[must_use]
1043 pub fn containing(&self, cwd_repo_db: &Path) -> Option<&str> {
1044 self.entries.iter().find_map(|(name, e)| {
1045 e.workspace
1046 .member_dbs()
1047 .iter()
1048 .any(|db| db == cwd_repo_db)
1049 .then_some(name.as_str())
1050 })
1051 }
1052
1053 /// Comma-separated workspace names (for error messages).
1054 fn known(&self) -> String {
1055 self.entries.keys().cloned().collect::<Vec<_>>().join(", ")
1056 }
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062 use crate::store::Store;
1063
1064 fn store() -> Store {
1065 Store::open_in_memory().expect("in-memory store")
1066 }
1067
1068 #[test]
1069 fn single_project_is_the_default_and_resolves_bare() {
1070 let ws = Workspace::single("myrepo", store());
1071 assert_eq!(ws.names(), vec!["myrepo".to_owned()]);
1072 assert!(!ws.is_multi());
1073 // A bare call resolves to the sole project.
1074 assert_eq!(ws.resolve(None).unwrap(), "myrepo");
1075 // Naming it explicitly works too.
1076 assert_eq!(ws.resolve(Some("myrepo")).unwrap(), "myrepo");
1077 // with_store hands over the store.
1078 let n = ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
1079 assert_eq!(n, 0);
1080 }
1081
1082 #[test]
1083 fn from_stores_dedupes_colliding_names() {
1084 // Two stores sharing the base name `repo` must both survive: the second
1085 // is suffixed `repo-2` (like `from_repo_paths`), never dropped.
1086 let ws = Workspace::from_stores([("repo", store()), ("repo", store())]);
1087 let mut names = ws.names();
1088 names.sort();
1089 assert_eq!(names, vec!["repo".to_owned(), "repo-2".to_owned()]);
1090 assert!(ws.is_multi());
1091 }
1092
1093 #[test]
1094 fn unknown_project_is_an_error_naming_the_known_ones() {
1095 let ws = Workspace::single("a", store());
1096 let err = ws.resolve(Some("b")).unwrap_err();
1097 assert!(matches!(err, WorkspaceError::UnknownProject { .. }));
1098 assert!(err.to_string().contains("known: a"));
1099 }
1100
1101 #[test]
1102 fn cached_store_handle_is_reused() {
1103 let ws = Workspace::single("a", store());
1104 // Two accesses return the same underlying handle (cache hit).
1105 ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
1106 let again = ws.handle("a").unwrap();
1107 // The handle is held by both the cache and this local, so ≥ 2.
1108 assert!(Arc::strong_count(&again) >= 2);
1109 }
1110
1111 #[test]
1112 fn parse_qualified_splits_on_the_first_double_colon_only() {
1113 // Bare keys carry single colons; only `::` separates the project.
1114 assert_eq!(
1115 parse_qualified("app::sym:rust:a.rs#B"),
1116 Some(("app", "sym:rust:a.rs#B"))
1117 );
1118 assert_eq!(parse_qualified("app::file:x"), Some(("app", "file:x")));
1119 // Not qualified / malformed.
1120 assert_eq!(parse_qualified("sym:rust:a.rs#B"), None);
1121 assert_eq!(parse_qualified("::x"), None);
1122 assert_eq!(parse_qualified("app::"), None);
1123 }
1124
1125 #[test]
1126 fn resolve_qualified_finds_drift_and_bad_targets() {
1127 use crate::model::{Node, NodeKind};
1128 let mut s = store();
1129 s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
1130 "file:cfg.rs",
1131 NodeKind::File,
1132 "cfg.rs",
1133 )))
1134 .unwrap();
1135 let ws = Workspace::single("app", s);
1136
1137 // Resolves an existing node in the named project.
1138 let hit = ws.resolve_qualified("app::file:cfg.rs").unwrap();
1139 assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
1140 // Well-formed but absent → drift (Ok(None)).
1141 assert!(ws.resolve_qualified("app::file:gone.rs").unwrap().is_none());
1142 // Unknown target project → an error the caller reports as drift.
1143 assert!(matches!(
1144 ws.resolve_qualified("ghost::file:x").unwrap_err(),
1145 WorkspaceError::UnknownProject { .. }
1146 ));
1147 // Not project-qualified at all.
1148 assert!(matches!(
1149 ws.resolve_qualified("file:cfg.rs").unwrap_err(),
1150 WorkspaceError::Unqualified { .. }
1151 ));
1152 }
1153
1154 #[test]
1155 fn follow_external_ref_walks_a_placeholder_to_its_target() {
1156 use crate::links::external_ref_node;
1157 use crate::model::{Node, NodeKind};
1158 let mut s = store();
1159 // A real target node, plus a placeholder standing in for it (as it would
1160 // live in a spoke store pointing back at this project).
1161 s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
1162 "file:cfg.rs",
1163 NodeKind::File,
1164 "cfg.rs",
1165 )))
1166 .unwrap();
1167 let ws = Workspace::single("app", s);
1168
1169 // Following the placeholder resolves the qualified target to the real node.
1170 let placeholder = external_ref_node("app::file:cfg.rs");
1171 let hit = ws.follow_external_ref(&placeholder).unwrap();
1172 assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
1173
1174 // A placeholder for a removed target is drift (Ok(None)), not an error.
1175 let gone = external_ref_node("app::file:gone.rs");
1176 assert!(ws.follow_external_ref(&gone).unwrap().is_none());
1177
1178 // A plain (non-external-ref) node is simply not followed.
1179 let plain = Node::new("file:cfg.rs", NodeKind::File, "cfg.rs");
1180 assert!(ws.follow_external_ref(&plain).unwrap().is_none());
1181 }
1182
1183 // -- follow-the-link hop: config_key → struct bridge ------------------
1184
1185 /// A config-key node as extraction emits it: key `cfgkey:<file>#<dotted>`,
1186 /// name the dotted key, `meta { key, value }`.
1187 fn cfg_node(dotted: &str) -> crate::model::Node {
1188 use crate::model::{Node, NodeKind};
1189 let mut n = Node::new(
1190 format!("cfgkey:config.toml#{dotted}"),
1191 NodeKind::Other("config_key".to_owned()),
1192 dotted,
1193 );
1194 n.meta = serde_json::json!({ "key": dotted, "value": "x" });
1195 n
1196 }
1197
1198 /// A struct node as extraction emits it, carrying its declared field names in
1199 /// `meta.fields` (the bridge's join signal).
1200 fn struct_node(name: &str, fields: &[&str]) -> crate::model::Node {
1201 use crate::model::{Node, NodeKind};
1202 let mut n = Node::new(format!("sym:rust:config.rs#{name}"), NodeKind::Struct, name);
1203 n.meta = serde_json::json!({ "fields": fields });
1204 n
1205 }
1206
1207 /// Build a hub with a `ServeConfig`/`addr` struct field AND its `serve.addr`
1208 /// config key — plus decoys — so the bridge's confidence rules are exercised.
1209 fn bridge_hub() -> Workspace {
1210 use crate::model::FactSet;
1211 let mut s = store();
1212 s.apply_factset(
1213 &FactSet::new()
1214 .with_node(struct_node("ServeConfig", &["addr", "tools", "tls_cert"]))
1215 .with_node(struct_node("ModelsConfig", &["embedding", "generative"]))
1216 .with_node(cfg_node("serve.addr"))
1217 .with_node(cfg_node("serve.tls_cert"))
1218 .with_node(cfg_node("serve.ghost")) // resolves, but no such field
1219 .with_node(cfg_node("mystery.addr")) // no struct for section `mystery`
1220 .with_node(cfg_node("port")), // single-segment: no section
1221 )
1222 .unwrap();
1223 Workspace::single("hub", s)
1224 }
1225
1226 #[test]
1227 fn follow_bridges_config_key_to_its_defining_struct_field() {
1228 let ws = bridge_hub();
1229 // `serve.addr` bridges to the `ServeConfig` struct, field `addr`.
1230 match ws
1231 .follow_definition("hub::cfgkey:config.toml#serve.addr")
1232 .unwrap()
1233 {
1234 Follow::StructField { node, field } => {
1235 assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1236 assert_eq!(field, "addr");
1237 }
1238 other => panic!("expected a struct-field bridge, got {other:?}"),
1239 }
1240 // Separator-insensitive on the leaf: `serve.tls_cert` → field `tls_cert`.
1241 match ws
1242 .follow_definition("hub::cfgkey:config.toml#serve.tls_cert")
1243 .unwrap()
1244 {
1245 Follow::StructField { node, field } => {
1246 assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1247 assert_eq!(field, "tls_cert");
1248 }
1249 other => panic!("expected a struct-field bridge, got {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1254 fn follow_falls_back_to_config_key_when_not_confidently_bridgeable() {
1255 let ws = bridge_hub();
1256 // Section matches a struct, but the struct has no such field → fall back.
1257 let ghost = ws
1258 .follow_definition("hub::cfgkey:config.toml#serve.ghost")
1259 .unwrap();
1260 assert!(
1261 matches!(&ghost, Follow::Node { node } if node.name == "serve.ghost"),
1262 "unmatched field falls back to the config_key node, got {ghost:?}"
1263 );
1264 // No struct maps to section `mystery` → fall back.
1265 let mystery = ws
1266 .follow_definition("hub::cfgkey:config.toml#mystery.addr")
1267 .unwrap();
1268 assert!(matches!(&mystery, Follow::Node { node } if node.name == "mystery.addr"));
1269 // A single-segment key names no section → never bridged.
1270 let port = ws
1271 .follow_definition("hub::cfgkey:config.toml#port")
1272 .unwrap();
1273 assert!(matches!(&port, Follow::Node { node } if node.name == "port"));
1274 }
1275
1276 #[test]
1277 fn follow_does_not_bridge_on_ambiguity() {
1278 use crate::model::FactSet;
1279 // TWO structs both map to section `serve` and both declare `addr` — a
1280 // genuinely ambiguous mapping must fall back, never guess a wrong node.
1281 let mut s = store();
1282 s.apply_factset(
1283 &FactSet::new()
1284 .with_node(struct_node("ServeConfig", &["addr"]))
1285 .with_node(struct_node("Serve", &["addr"])) // also matches `serve`
1286 .with_node(cfg_node("serve.addr")),
1287 )
1288 .unwrap();
1289 let ws = Workspace::single("hub", s);
1290 let out = ws
1291 .follow_definition("hub::cfgkey:config.toml#serve.addr")
1292 .unwrap();
1293 assert!(
1294 matches!(&out, Follow::Node { node } if node.name == "serve.addr"),
1295 "ambiguous (two matching structs) falls back, got {out:?}"
1296 );
1297 }
1298
1299 #[test]
1300 fn follow_narrow_lookup_ignores_unrelated_structs_with_the_same_field() {
1301 use crate::model::FactSet;
1302 // The name-narrowed struct lookup must return exactly what a full scan
1303 // would: an unrelated struct that happens to declare `addr` is NOT the
1304 // `serve` section's struct, so `serve.addr` still bridges only to
1305 // `ServeConfig` — proving the narrowing preserves bridging semantics.
1306 let mut s = store();
1307 s.apply_factset(
1308 &FactSet::new()
1309 .with_node(struct_node("ServeConfig", &["addr"]))
1310 .with_node(struct_node("Unrelated", &["addr"]))
1311 .with_node(struct_node("Widget", &["addr", "size"]))
1312 .with_node(struct_node("ModelsConfig", &["embedding"]))
1313 .with_node(cfg_node("serve.addr")),
1314 )
1315 .unwrap();
1316 let ws = Workspace::single("hub", s);
1317 match ws
1318 .follow_definition("hub::cfgkey:config.toml#serve.addr")
1319 .unwrap()
1320 {
1321 Follow::StructField { node, field } => {
1322 assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1323 assert_eq!(field, "addr");
1324 }
1325 other => panic!("expected a struct-field bridge to ServeConfig, got {other:?}"),
1326 }
1327 }
1328
1329 #[test]
1330 fn follow_reports_drift_and_passes_through_non_config_targets() {
1331 use crate::model::{FactSet, Node, NodeKind};
1332 let mut s = store();
1333 s.apply_factset(&FactSet::new().with_node(Node::new(
1334 "sym:rust:a.rs#Thing",
1335 NodeKind::Struct,
1336 "Thing",
1337 )))
1338 .unwrap();
1339 let ws = Workspace::single("hub", s);
1340 // A well-formed target whose node is gone → drift.
1341 assert_eq!(
1342 ws.follow_definition("hub::cfgkey:config.toml#gone")
1343 .unwrap(),
1344 Follow::Drift
1345 );
1346 // A spoke pointing straight at a symbol (an authored link, not a config
1347 // key) passes the node through unbridged.
1348 match ws.follow_definition("hub::sym:rust:a.rs#Thing").unwrap() {
1349 Follow::Node { node } => assert_eq!(node.key, "sym:rust:a.rs#Thing"),
1350 other => panic!("expected pass-through, got {other:?}"),
1351 }
1352 }
1353
1354 #[test]
1355 fn workspace_set_select_single_ambiguous_and_unknown() {
1356 // One workspace ⇒ the default; a bare or named select both resolve to it.
1357 let one = WorkspaceSet::from_workspaces([(
1358 "only".to_owned(),
1359 Workspace::single("only", store()),
1360 true,
1361 )]);
1362 assert_eq!(one.names(), vec!["only".to_owned()]);
1363 assert_eq!(one.linked("only"), Some(true));
1364 assert!(one.linked("nope").is_none());
1365 assert!(one.select(None).is_ok());
1366 assert!(one.select(Some("only")).is_ok());
1367 assert!(matches!(
1368 one.select(Some("ghost")),
1369 Err(WorkspaceError::UnknownWorkspace { .. })
1370 ));
1371
1372 // Several workspaces ⇒ a bare select is ambiguous (listing the names), a
1373 // named select works, and an unknown name errors.
1374 let many = WorkspaceSet::from_workspaces([
1375 ("api".to_owned(), Workspace::single("api", store()), true),
1376 ("web".to_owned(), Workspace::single("web", store()), false),
1377 ]);
1378 assert_eq!(many.names(), vec!["api".to_owned(), "web".to_owned()]);
1379 assert_eq!(many.linked("web"), Some(false));
1380 // (`select` yields `&Workspace`, which isn't `Debug`, so match the error
1381 // out rather than `unwrap_err`.)
1382 let Err(err) = many.select(None) else {
1383 panic!("a bare select over several workspaces must be ambiguous");
1384 };
1385 assert!(matches!(err, WorkspaceError::AmbiguousWorkspace { .. }));
1386 assert!(err.to_string().contains("api"));
1387 assert!(err.to_string().contains("web"));
1388 assert!(many.select(Some("web")).is_ok());
1389 assert!(matches!(
1390 many.select(Some("ghost")),
1391 Err(WorkspaceError::UnknownWorkspace { .. })
1392 ));
1393
1394 // No workspaces ⇒ a bare select reports the empty set.
1395 let none = WorkspaceSet::from_workspaces(std::iter::empty());
1396 assert!(matches!(none.select(None), Err(WorkspaceError::Empty)));
1397 }
1398
1399 #[test]
1400 fn workspace_set_containing_finds_the_owning_workspace_by_db_path() {
1401 // Build two workspaces from explicit (name, graph.db) pairs — no git needed
1402 // — so `containing` can match a repo's db against each workspace's members.
1403 let api_db = PathBuf::from("/ws/api/svc/.git/roteiro/graph.db");
1404 let web_db = PathBuf::from("/ws/web/app/.git/roteiro/graph.db");
1405 let set = WorkspaceSet::from_workspaces([
1406 (
1407 "api".to_owned(),
1408 Workspace::from_named_dbs([("svc".to_owned(), api_db.clone())]),
1409 true,
1410 ),
1411 (
1412 "web".to_owned(),
1413 Workspace::from_named_dbs([("app".to_owned(), web_db.clone())]),
1414 false,
1415 ),
1416 ]);
1417 assert_eq!(set.containing(&api_db), Some("api"));
1418 assert_eq!(set.containing(&web_db), Some("web"));
1419 // A db in no workspace matches nothing.
1420 assert_eq!(
1421 set.containing(Path::new("/elsewhere/.git/roteiro/graph.db")),
1422 None
1423 );
1424 }
1425}