Skip to main content

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    /// Like [`Workspace::with_store`], but hands `f` a **mutable** store so it can
387    /// persist into the graph (e.g. [`Store::apply_import_layer`]). The store lock
388    /// is held only for `f`, never across an `.await`. Backs the explorer's
389    /// `links/write` endpoint, which materialises the inferred cross-repo links into
390    /// a spoke's graph as a durable import layer.
391    ///
392    /// # Errors
393    /// As [`Workspace::with_store`].
394    pub fn with_store_mut<R>(
395        &self,
396        project: Option<&str>,
397        f: impl FnOnce(&mut Store) -> R,
398    ) -> Result<R, WorkspaceError> {
399        let name = self.resolve(project)?;
400        let handle = self.handle(&name)?;
401        let mut store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
402        Ok(f(&mut store))
403    }
404
405    /// Resolve a **project-qualified** key `"<project>::<key>"` to its node across
406    /// the workspace, opening the target project on demand (ADR-0009). `Ok(None)`
407    /// means the key is well-formed and the project exists but the node does not —
408    /// i.e. **cross-repo drift** (a removed or renamed target). Errors distinguish
409    /// the other failure modes so a caller can report them precisely:
410    /// [`WorkspaceError::Unqualified`] (not in `<project>::<key>` form),
411    /// [`WorkspaceError::UnknownProject`] (target repo not in the workspace),
412    /// [`WorkspaceError::NoGraph`] (target repo unsynced).
413    ///
414    /// # Errors
415    /// As above, plus [`WorkspaceError::Store`] / [`WorkspaceError::Poisoned`].
416    pub fn resolve_qualified(&self, qualified: &str) -> Result<Option<Node>, WorkspaceError> {
417        let (project, key) =
418            parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
419                key: qualified.to_owned(),
420            })?;
421        let key = key.to_owned();
422        self.with_store(Some(project), move |s| s.get_node(&key))?
423            .map_err(WorkspaceError::from)
424    }
425
426    /// Follow an **external-ref** placeholder node to the real node it stands for,
427    /// resolving its project-qualified target across the workspace (ADR-0009). An
428    /// external-ref lives in a spoke's store as a local stand-in for a node in the
429    /// hub's store (see [`crate::external_ref_node`]); this walks it through to the
430    /// hub. `Ok(None)` means either `node` is not an external-ref, or its target no
431    /// longer resolves — cross-repo drift (a removed or renamed hub key). Errors
432    /// distinguish the other failure modes, as [`Workspace::resolve_qualified`].
433    ///
434    /// # Errors
435    /// As [`Workspace::resolve_qualified`].
436    pub fn follow_external_ref(&self, node: &Node) -> Result<Option<Node>, WorkspaceError> {
437        match crate::external_ref_target(node) {
438            Some(qualified) => self.resolve_qualified(&qualified),
439            None => Ok(None),
440        }
441    }
442
443    /// Follow a **project-qualified** cross-repo target to the most specific
444    /// *definition* it names — the follow-the-link hop that turns a click on a
445    /// spoke's app-key target into a jump to the hub node that defines it.
446    ///
447    /// [`Workspace::resolve_qualified`] lands on the raw hub node a spoke points
448    /// at, which for a config override is the hub's `config_key` node (e.g.
449    /// `cfgkey:config.toml#serve.addr`), *not* the Rust struct that declares the
450    /// setting. This method adds the net-new **`config_key` → struct bridge**: when
451    /// the resolved node is a config key whose dotted path maps — with confidence —
452    /// to exactly one hub struct and one of its named fields, it returns that
453    /// struct as the jump target ([`Follow::StructField`], carrying the matched
454    /// field name). Otherwise it returns the resolved node unchanged
455    /// ([`Follow::Node`]) — a config key we could not bridge, or any non-config
456    /// target (e.g. an authored `[[links]]` that already points at a symbol). A
457    /// well-formed target whose node is gone is [`Follow::Drift`].
458    ///
459    /// The bridge is deliberately conservative (see [`bridge_config_key`]): it
460    /// fires only on a *unique* match of both an independent section→struct-name
461    /// signal and a field-presence signal, so it never jumps to a **wrong** node —
462    /// an ambiguous or unmatched key falls back to the config-key node.
463    ///
464    /// # Errors
465    /// As [`Workspace::resolve_qualified`] (a well-formed but unhosted / unsynced
466    /// target project still errors; a resolved-but-missing node is `Drift`).
467    pub fn follow_definition(&self, qualified: &str) -> Result<Follow, WorkspaceError> {
468        let (project, key) =
469            parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
470                key: qualified.to_owned(),
471            })?;
472        let key = key.to_owned();
473        self.with_store(Some(project), move |store| -> Result<Follow, StoreError> {
474            let Some(node) = store.get_node(&key)? else {
475                return Ok(Follow::Drift);
476            };
477            // Only a config-key node needs bridging; anything else the spoke points
478            // at is already a definition-level target. Compare against the stable
479            // token via `as_str()` — no allocation to build a throwaway `NodeKind`.
480            if node.kind.as_str() == crate::config_keys::KIND {
481                match bridge_config_key(store, &node)? {
482                    Some((target, field)) => Ok(Follow::StructField {
483                        node: target,
484                        field,
485                    }),
486                    None => Ok(Follow::Node { node }),
487                }
488            } else {
489                Ok(Follow::Node { node })
490            }
491        })?
492        .map_err(WorkspaceError::from)
493    }
494
495    /// Lock the inner state, mapping a poisoned lock to [`WorkspaceError::Poisoned`].
496    fn lock(&self) -> Result<std::sync::MutexGuard<'_, Inner>, WorkspaceError> {
497        self.inner.lock().map_err(|_| WorkspaceError::Poisoned)
498    }
499
500    /// Get (opening + caching on first use) the shared store handle for `name`.
501    /// Opens `graph.db` **outside** the registry lock so a first-touch open never
502    /// blocks other projects' queries.
503    fn handle(&self, name: &str) -> Result<Arc<Mutex<Store>>, WorkspaceError> {
504        // Fast path and pre-opened sources resolve under a single short lock.
505        let db = {
506            let mut inner = self.lock()?;
507            if let Some((_, handle)) = inner.cache.get(name) {
508                return Ok(handle.clone());
509            }
510            match inner.projects.get(name) {
511                Some(Source::Open(handle)) => {
512                    let handle = handle.clone();
513                    inner.cache.insert(
514                        name.to_owned(),
515                        (Source::Open(handle.clone()), handle.clone()),
516                    );
517                    return Ok(handle);
518                }
519                Some(Source::Path(db)) => db.clone(),
520                None => {
521                    return Err(WorkspaceError::UnknownProject {
522                        name: name.to_owned(),
523                        known: keys(&inner.projects),
524                    });
525                }
526            }
527        };
528        // `serve --sync-on-access`: (re)build this project's graph before opening
529        // it, so a stale or never-synced repo is prepared on first touch. Runs
530        // outside the registry lock (it does extraction I/O).
531        if let Some(on_open) = &self.on_open {
532            on_open(&db).map_err(|msg| WorkspaceError::Prepare {
533                name: name.to_owned(),
534                msg,
535            })?;
536        }
537        if !db.exists() {
538            return Err(WorkspaceError::NoGraph {
539                name: name.to_owned(),
540                // The repo dir is the store's grandparent (`…/.git/roteiro`).
541                path: db
542                    .parent()
543                    .and_then(Path::parent)
544                    .and_then(Path::parent)
545                    .unwrap_or(&db)
546                    .to_path_buf(),
547            });
548        }
549        let handle = Arc::new(Mutex::new(Store::open(&db)?));
550        let opened = Source::Path(db.clone());
551        let mut inner = self.lock()?;
552        // Another thread may have opened it while we were; prefer the existing.
553        if let Some((_, existing)) = inner.cache.get(name) {
554            return Ok(existing.clone());
555        }
556        // Only cache if the registry still maps this name to the DB we opened —
557        // a concurrent `reload_from` may have remapped or removed it. If so,
558        // return the freshly-opened handle for this call (the caller resolved
559        // before the reload) but do not cache a now-stale mapping.
560        if inner
561            .projects
562            .get(name)
563            .is_some_and(|current| source_eq(current, &opened))
564        {
565            inner
566                .cache
567                .insert(name.to_owned(), (opened, handle.clone()));
568        }
569        Ok(handle)
570    }
571}
572
573/// Comma-separated project names (for error messages).
574fn keys(projects: &BTreeMap<String, Source>) -> String {
575    projects.keys().cloned().collect::<Vec<_>>().join(", ")
576}
577
578/// Split a **project-qualified** key `"<project>::<key>"` into `(project, key)`,
579/// or `None` if it carries no `::` separator (a bare, within-repo key). A project
580/// name never contains `::`; a bare key may itself contain single colons (e.g.
581/// `sym:rust:…`), so only the **first** double-colon separates the project
582/// (ADR-0009).
583#[must_use]
584pub fn parse_qualified(key: &str) -> Option<(&str, &str)> {
585    key.split_once("::")
586        .filter(|(project, bare)| !project.is_empty() && !bare.is_empty())
587}
588
589/// The outcome of [`Workspace::follow_definition`]: where a cross-repo follow-hop
590/// lands.
591#[derive(Debug, Clone, PartialEq, Eq)]
592pub enum Follow {
593    /// Bridged past a `config_key` node to the hub **struct** that declares the
594    /// setting, carrying the specific named field that matched (e.g. the
595    /// `ServeConfig` struct for `serve.addr`, `field = "addr"`). The `node` is the
596    /// real struct node, so a caller can center it in the hub graph.
597    StructField {
598        /// The defining struct node (`sym:rust:<file>#<Struct>`).
599        node: Node,
600        /// The struct field the dotted key resolved to (its declared identifier).
601        field: String,
602    },
603    /// The resolved target node itself, unbridged — a `config_key` we could not map
604    /// to a struct with confidence (the safe fallback), or any non-config target a
605    /// spoke points straight at.
606    Node {
607        /// The resolved hub node.
608        node: Node,
609    },
610    /// The target is well-formed but its node is gone — cross-repo drift.
611    Drift,
612}
613
614/// Bridge a hub **`config_key`** node to the Rust **struct** that declares it, plus
615/// the specific field matched — the net-new step behind [`Workspace::follow_definition`].
616///
617/// The mapping from a dotted config key (`serve.addr`) to a defining Rust field is
618/// not recorded anywhere in the graph (the extractor models structs as nodes but
619/// not their fields as nodes, and a field's *type* is not captured), so this is a
620/// **resolve-time join** over two independent, deterministic signals — and it only
621/// bridges when they agree on exactly one struct:
622///
623/// 1. **section → struct name.** The dotted key's head segment (`serve`) must name
624///    the struct: its lower-cased name, with a trailing `Config` stripped, equals
625///    the section (`ServeConfig` → `serve`; a bare `Serve` also matches). See
626///    [`struct_matches_section`].
627/// 2. **field presence.** The struct must actually declare a field whose
628///    normalised name equals the key's leaf (`addr`, or `tls_cert` for
629///    `serve.tls_cert`) — read from the struct's `meta.fields`. See
630///    [`struct_field_matching`].
631///
632/// Requiring a **unique** `(struct, field)` hit is the correctness rule: a key that
633/// matches zero structs (no such section, or the field isn't declared) or more than
634/// one (genuinely ambiguous) returns `None`, and the caller falls back to the
635/// config-key node rather than risk jumping to a wrong definition.
636///
637/// Known limits (documented, deliberate): a single-segment key (no section, e.g.
638/// `port`) is never bridged; a key nested past one level (`serve.tls.cert` where
639/// `tls` is a sub-struct) won't match a flat field and falls back; and a struct
640/// whose name doesn't follow the `<Section>Config` convention won't be found. All
641/// three degrade to the existing config-key target — never to a wrong one.
642fn bridge_config_key(store: &Store, cfg_node: &Node) -> Result<Option<(Node, String)>, StoreError> {
643    // The dotted key: authoritative from `meta.key`, falling back to the node name
644    // (both are the dotted path in practice — see config-key extraction).
645    let dotted = cfg_node
646        .meta
647        .get("key")
648        .and_then(serde_json::Value::as_str)
649        .unwrap_or(cfg_node.name.as_str());
650    let Some((section, leaf)) = split_section_field(dotted) else {
651        return Ok(None);
652    };
653    let leaf_norm = crate::config_keys::normalize(leaf);
654    if leaf_norm.is_empty() {
655        return Ok(None);
656    }
657
658    // Fetch only the CANDIDATE struct(s) for this section by name, rather than
659    // loading and JSON-decoding every `struct` node in the graph on each hop
660    // (a latency spike on a large hub). `section_struct_names` yields the exact
661    // lower-cased names `struct_matches_section` would accept, so this narrows the
662    // scan without changing the bridging semantics; `struct_matches_section` is
663    // still applied below as the authoritative check.
664    let mut candidates: Vec<Node> = Vec::new();
665    for name in section_struct_names(section) {
666        candidates.extend(store.nodes_by_kind_named(&crate::NodeKind::Struct, &name)?);
667    }
668
669    let mut hits = candidates
670        .into_iter()
671        .filter(|s| struct_matches_section(&s.name, section))
672        .filter_map(|s| struct_field_matching(&s, &leaf_norm).map(|field| (s, field)));
673
674    match (hits.next(), hits.next()) {
675        // Exactly one confident match → bridge to it.
676        (Some(one), None) => Ok(Some(one)),
677        // Zero or ambiguous (>1) → fall back to the config-key node.
678        _ => Ok(None),
679    }
680}
681
682/// Split a dotted config key into `(section, leaf)` on its **first** separator:
683/// `serve.addr` → `("serve", "addr")`, `serve.tls_cert` → `("serve", "tls_cert")`.
684/// A single-segment key (`port`) has no section to identify a struct by, so it is
685/// `None` (never bridged).
686fn split_section_field(dotted: &str) -> Option<(&str, &str)> {
687    dotted
688        .split_once('.')
689        .filter(|(section, leaf)| !section.is_empty() && !leaf.is_empty())
690}
691
692/// The section's canonical form for name-matching: normalised, separators removed
693/// (`serve` → `serve`, `serve_mode` → `servemode`). Empty when the section carries
694/// no alphanumerics.
695fn section_key(section: &str) -> String {
696    crate::config_keys::normalize(section).replace('.', "")
697}
698
699/// The lower-cased struct names a config `section` can map to — exactly the names
700/// [`struct_matches_section`] accepts: `serve` → `["serve", "serveconfig"]`. Used
701/// to fetch just the candidate struct(s) by name instead of scanning them all
702/// (kept in lock-step with [`struct_matches_section`], which remains the check).
703fn section_struct_names(section: &str) -> Vec<String> {
704    let want = section_key(section);
705    if want.is_empty() {
706        return Vec::new();
707    }
708    let with_config = format!("{want}config");
709    vec![want, with_config]
710}
711
712/// Whether a struct `name` is the one a config `section` maps to: its lower-cased
713/// name with a trailing `config` stripped equals the section (case- and
714/// separator-insensitive). `ServeConfig`/`Serve` both match section `serve`;
715/// `ServeSettings` does not (so an unrelated struct is never bridged to).
716fn struct_matches_section(name: &str, section: &str) -> bool {
717    let lname = name.to_ascii_lowercase();
718    let base = lname.strip_suffix("config").unwrap_or(&lname);
719    let want = section_key(section);
720    !want.is_empty() && base == want
721}
722
723/// The struct field whose normalised identifier equals `leaf_norm`, read from the
724/// struct node's `meta.fields` (see extraction). Returns the field's original
725/// declared name (for display), or `None` when the struct declares no such field.
726fn struct_field_matching(struct_node: &Node, leaf_norm: &str) -> Option<String> {
727    struct_node
728        .meta
729        .get("fields")?
730        .as_array()?
731        .iter()
732        .filter_map(serde_json::Value::as_str)
733        .find(|field| crate::config_keys::normalize(field) == leaf_norm)
734        .map(ToOwned::to_owned)
735}
736
737/// Discover repos at `paths` into a `(name → Source, default)` registry: each
738/// path is git-discovered, named after its working-tree directory (deduped), and
739/// mapped to a lazily-opened `graph.db`. Exactly one repo ⇒ it is the default.
740type Registry = (BTreeMap<String, Source>, Option<String>);
741fn build_registry<I, P>(paths: I) -> Result<Registry, WorkspaceError>
742where
743    I: IntoIterator<Item = P>,
744    P: AsRef<Path>,
745{
746    let mut projects: BTreeMap<String, Source> = BTreeMap::new();
747    let mut seen_dbs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
748    for path in paths {
749        let repo = Repo::discover(path.as_ref())?;
750        let db = repo.git_dir().join("roteiro").join("graph.db");
751        // De-duplicate the same repo reached via different paths (O(1) lookup, so
752        // discovery stays linear even on a big workspace and every reload).
753        if !seen_dbs.insert(db.clone()) {
754            continue;
755        }
756        let base = repo
757            .workdir()
758            .and_then(Path::file_name)
759            .map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
760        let name = dedupe_name(&projects, base);
761        projects.insert(name, Source::Path(db));
762    }
763    if projects.is_empty() {
764        return Err(WorkspaceError::Empty);
765    }
766    let default = if projects.len() == 1 {
767        projects.keys().next().cloned()
768    } else {
769        None
770    };
771    Ok((projects, default))
772}
773
774/// Make `base` unique against the names already in `projects`, appending
775/// `-2`, `-3`, … on collision.
776fn dedupe_name(projects: &BTreeMap<String, Source>, base: String) -> String {
777    if !projects.contains_key(&base) {
778        return base;
779    }
780    let mut n = 2u32;
781    loop {
782        let candidate = format!("{base}-{n}");
783        if !projects.contains_key(&candidate) {
784            return candidate;
785        }
786        n += 1;
787    }
788}
789
790/// Shallow git-repo discovery under `root`: the root itself if it is a repo, plus
791/// each immediate subdirectory that is one, in sorted order. Shallow by design — a
792/// code directory holding sibling checkouts is the common case, and a deep scan
793/// would be slow and surprising. Shared by the CLI's workspace collection and
794/// [`WorkspaceSet`] / config resolution, so the membership rule lives in one place.
795///
796/// A repo is any directory containing a `.git` entry (a directory in a normal
797/// clone, a file in worktrees and submodules), so existence — not `is_dir` — is
798/// tested.
799///
800/// # Errors
801/// [`WorkspaceError::Discover`] if `root` cannot be read.
802pub fn discover_repos_under(root: &Path) -> Result<Vec<PathBuf>, WorkspaceError> {
803    let is_repo = |dir: &Path| dir.join(".git").exists();
804    let mut repos = Vec::new();
805    if is_repo(root) {
806        repos.push(root.to_path_buf());
807    }
808    let entries = std::fs::read_dir(root).map_err(|e| WorkspaceError::Discover {
809        root: root.to_path_buf(),
810        msg: e.to_string(),
811    })?;
812    let mut children: Vec<PathBuf> = entries
813        .filter_map(Result::ok)
814        .map(|e| e.path())
815        .filter(|p| p.is_dir() && is_repo(p))
816        .collect();
817    children.sort();
818    repos.extend(children);
819    Ok(repos)
820}
821
822/// A workspace group after config normalisation ([`crate::WorkspaceSet`] input): a
823/// name, its member `roots`/`repos` (unexpanded — discovered when the set is
824/// built), and whether its repos are cross-**linked** (served as one multi-repo
825/// graph) or **standalone** (each its own single-repo graph, no cross-repo links).
826///
827/// A `linked = false` (standalone) group denotes **exactly one** single-repo graph:
828/// the config normaliser emits one such group per discovered repo, and
829/// [`WorkspaceSet::from_resolved`] upholds the invariant by materialising a
830/// standalone group as a one-repo [`Workspace`] per member — a standalone group can
831/// never collapse several repos into one unlinked multi-repo graph.
832#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct ResolvedWorkspace {
834    /// The workspace name (the `--workspace-name` selector).
835    pub name: String,
836    /// Directories to scan for member repos (as `[workspace] roots`).
837    pub roots: Vec<String>,
838    /// Explicit member repo paths, in addition to anything under `roots`.
839    pub repos: Vec<String>,
840    /// `true` ⇒ the repos form one linked graph; `false` ⇒ **standalone**: each
841    /// member repo is its own single-repo graph (no cross-repo links).
842    pub linked: bool,
843}
844
845/// One entry in a [`WorkspaceSet`]: a built [`Workspace`] plus whether its member
846/// repos are cross-linked. The workspace is held behind an `Arc` so an
847/// already-shared workspace (e.g. the one a `serve` process holds for its model
848/// tools and MCP router) can be wrapped into a set without re-opening its stores
849/// ([`WorkspaceSet::from_single`]).
850struct WorkspaceEntry {
851    /// The per-group workspace (one repo for a standalone singleton, several for a
852    /// linked group).
853    workspace: Arc<Workspace>,
854    /// Whether the group's repos are cross-linked.
855    linked: bool,
856}
857
858/// An install's **many** named workspaces: linked groups (multi-repo graphs) and
859/// standalone singletons (one-repo graphs), keyed by name in stable order (ADR-0008
860/// multi-workspace). The outer layer over [`Workspace`]: it selects *which*
861/// workspace a command operates on, then hands back that `Workspace` to resolve
862/// projects within it. Built from normalised config ([`WorkspaceSet::from_resolved`])
863/// so the `serve`/`links` selection logic is shared.
864pub struct WorkspaceSet {
865    /// Workspace name → its entry, in stable (`BTreeMap`) name order.
866    entries: BTreeMap<String, WorkspaceEntry>,
867    /// The workspace used when a selection omits a name (the sole workspace, if
868    /// there is exactly one; otherwise `None` and a bare selection is ambiguous).
869    default: Option<String>,
870}
871
872impl WorkspaceSet {
873    /// Assemble a set from pre-built named workspaces — the shared core of
874    /// [`WorkspaceSet::from_resolved`] and the test constructor. With exactly one
875    /// entry, that workspace is the default (a bare selection resolves to it).
876    #[must_use]
877    pub fn from_workspaces<I>(entries: I) -> Self
878    where
879        I: IntoIterator<Item = (String, Workspace, bool)>,
880    {
881        let entries: BTreeMap<String, WorkspaceEntry> = entries
882            .into_iter()
883            .map(|(name, workspace, linked)| {
884                (
885                    name,
886                    WorkspaceEntry {
887                        workspace: Arc::new(workspace),
888                        linked,
889                    },
890                )
891            })
892            .collect();
893        let default = (entries.len() == 1)
894            .then(|| entries.keys().next().cloned())
895            .flatten();
896        Self { entries, default }
897    }
898
899    /// Wrap an already-built [`Workspace`] (shared via `Arc`) as a one-entry set
900    /// under `name`, with `linked` recording whether that workspace is a
901    /// cross-linked multi-repo group. Used where a single `Workspace` is served as
902    /// the whole set — e.g. `roteiro serve` merges the read-only graph API over the
903    /// one workspace it already holds for its model tools and MCP router, so the
904    /// API's flat routes resolve to it as the sole (default) workspace. The store
905    /// handles are shared, never re-opened.
906    #[must_use]
907    pub fn from_single(name: impl Into<String>, workspace: Arc<Workspace>, linked: bool) -> Self {
908        let name = name.into();
909        let mut entries = BTreeMap::new();
910        entries.insert(name.clone(), WorkspaceEntry { workspace, linked });
911        Self {
912            entries,
913            default: Some(name),
914        }
915    }
916
917    /// Build a set from normalised config groups: each group's `roots`/`repos` are
918    /// discovered into member repo paths and opened as [`Workspace`]s. A **linked**
919    /// group becomes one multi-repo graph. A **standalone** (`linked = false`) group
920    /// becomes one single-repo graph **per member repo** — the invariant that a
921    /// standalone workspace is exactly one repo is upheld *here*, by splitting, so a
922    /// hand-built group can never collapse several repos into one unlinked multi-repo
923    /// graph (the config normaliser already emits standalone as per-repo singletons,
924    /// so in practice each such group has exactly one repo and the split is a no-op).
925    /// On a split, the extra members take a `-2`/`-3` suffix off the group name. A
926    /// group that resolves to **no** repos is skipped, so a stale root never aborts
927    /// the whole set.
928    ///
929    /// # Errors
930    /// [`WorkspaceError::Discover`] if a group's root cannot be read, or
931    /// [`WorkspaceError::Git`] if an explicit repo path is not inside a git repo.
932    pub fn from_resolved(resolved: Vec<ResolvedWorkspace>) -> Result<Self, WorkspaceError> {
933        let mut entries: BTreeMap<String, WorkspaceEntry> = BTreeMap::new();
934        for rw in resolved {
935            let mut paths: Vec<PathBuf> = Vec::new();
936            for root in &rw.roots {
937                paths.extend(discover_repos_under(Path::new(root))?);
938            }
939            for repo in &rw.repos {
940                paths.push(PathBuf::from(repo));
941            }
942            if paths.is_empty() {
943                // A group naming nothing (e.g. a `roots` dir with no repos) is
944                // simply absent rather than an error.
945                continue;
946            }
947            if rw.linked {
948                let workspace = Workspace::from_repo_paths(&paths)?;
949                entries.insert(
950                    rw.name.clone(),
951                    WorkspaceEntry {
952                        workspace: Arc::new(workspace),
953                        linked: true,
954                    },
955                );
956            } else {
957                // Standalone: one single-repo graph per member, enforcing the
958                // `linked = false` ⇒ exactly-one-repo invariant structurally (the
959                // config normaliser already emits one repo per group, so this is a
960                // no-op split there; it only matters if a group is hand-built).
961                for (i, path) in paths.iter().enumerate() {
962                    let workspace = Workspace::from_repo_paths([path])?;
963                    let name = if i == 0 {
964                        rw.name.clone()
965                    } else {
966                        format!("{}-{}", rw.name, i + 1)
967                    };
968                    entries.insert(
969                        name,
970                        WorkspaceEntry {
971                            workspace: Arc::new(workspace),
972                            linked: false,
973                        },
974                    );
975                }
976            }
977        }
978        let default = (entries.len() == 1)
979            .then(|| entries.keys().next().cloned())
980            .flatten();
981        Ok(Self { entries, default })
982    }
983
984    /// The configured workspace names, in stable order.
985    #[must_use]
986    pub fn names(&self) -> Vec<String> {
987        self.entries.keys().cloned().collect()
988    }
989
990    /// Whether workspace `name` is linked (`Some(true)`), standalone
991    /// (`Some(false)`), or unknown (`None`).
992    #[must_use]
993    pub fn linked(&self, name: &str) -> Option<bool> {
994        self.entries.get(name).map(|e| e.linked)
995    }
996
997    /// Select a workspace by `name`, or the default when `name` is `None`.
998    ///
999    /// # Errors
1000    /// [`WorkspaceError::UnknownWorkspace`] if named but absent,
1001    /// [`WorkspaceError::AmbiguousWorkspace`] if omitted with several configured,
1002    /// or [`WorkspaceError::Empty`] if none are configured.
1003    pub fn select(&self, name: Option<&str>) -> Result<&Workspace, WorkspaceError> {
1004        if let Some(n) = name {
1005            return self
1006                .entries
1007                .get(n)
1008                .map(|e| e.workspace.as_ref())
1009                .ok_or_else(|| WorkspaceError::UnknownWorkspace {
1010                    name: n.to_owned(),
1011                    known: self.known(),
1012                });
1013        }
1014        // No name given: the sole workspace, else ambiguous / empty.
1015        let name = self.default.as_ref().ok_or_else(|| {
1016            if self.entries.is_empty() {
1017                WorkspaceError::Empty
1018            } else {
1019                WorkspaceError::AmbiguousWorkspace {
1020                    known: self.known(),
1021                }
1022            }
1023        })?;
1024        Ok(self.entries[name].workspace.as_ref())
1025    }
1026
1027    /// The **name** of the workspace [`WorkspaceSet::select`] resolves for `name`:
1028    /// the given name when present (and valid), else the sole/default workspace's
1029    /// name. Same resolution and errors as `select`, but returns the concrete name
1030    /// — so a caller (e.g. the `/follow` endpoint) can report which workspace it
1031    /// actually resolved in, even on a flat route where the default was implicit.
1032    ///
1033    /// # Errors
1034    /// As [`WorkspaceSet::select`].
1035    pub fn select_name(&self, name: Option<&str>) -> Result<&str, WorkspaceError> {
1036        if let Some(n) = name {
1037            return self
1038                .entries
1039                .get_key_value(n)
1040                .map(|(k, _)| k.as_str())
1041                .ok_or_else(|| WorkspaceError::UnknownWorkspace {
1042                    name: n.to_owned(),
1043                    known: self.known(),
1044                });
1045        }
1046        self.default.as_deref().ok_or_else(|| {
1047            if self.entries.is_empty() {
1048                WorkspaceError::Empty
1049            } else {
1050                WorkspaceError::AmbiguousWorkspace {
1051                    known: self.known(),
1052                }
1053            }
1054        })
1055    }
1056
1057    /// The name of the workspace whose member repos include the repo whose graph is
1058    /// `cwd_repo_db` (`<repo>/.git/roteiro/graph.db`), or `None` if no workspace
1059    /// contains it. Used to default `--workspace-name` to the workspace the current
1060    /// directory belongs to.
1061    #[must_use]
1062    pub fn containing(&self, cwd_repo_db: &Path) -> Option<&str> {
1063        self.entries.iter().find_map(|(name, e)| {
1064            e.workspace
1065                .member_dbs()
1066                .iter()
1067                .any(|db| db == cwd_repo_db)
1068                .then_some(name.as_str())
1069        })
1070    }
1071
1072    /// Comma-separated workspace names (for error messages).
1073    fn known(&self) -> String {
1074        self.entries.keys().cloned().collect::<Vec<_>>().join(", ")
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::store::Store;
1082
1083    fn store() -> Store {
1084        Store::open_in_memory().expect("in-memory store")
1085    }
1086
1087    #[test]
1088    fn single_project_is_the_default_and_resolves_bare() {
1089        let ws = Workspace::single("myrepo", store());
1090        assert_eq!(ws.names(), vec!["myrepo".to_owned()]);
1091        assert!(!ws.is_multi());
1092        // A bare call resolves to the sole project.
1093        assert_eq!(ws.resolve(None).unwrap(), "myrepo");
1094        // Naming it explicitly works too.
1095        assert_eq!(ws.resolve(Some("myrepo")).unwrap(), "myrepo");
1096        // with_store hands over the store.
1097        let n = ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
1098        assert_eq!(n, 0);
1099    }
1100
1101    #[test]
1102    fn from_stores_dedupes_colliding_names() {
1103        // Two stores sharing the base name `repo` must both survive: the second
1104        // is suffixed `repo-2` (like `from_repo_paths`), never dropped.
1105        let ws = Workspace::from_stores([("repo", store()), ("repo", store())]);
1106        let mut names = ws.names();
1107        names.sort();
1108        assert_eq!(names, vec!["repo".to_owned(), "repo-2".to_owned()]);
1109        assert!(ws.is_multi());
1110    }
1111
1112    #[test]
1113    fn unknown_project_is_an_error_naming_the_known_ones() {
1114        let ws = Workspace::single("a", store());
1115        let err = ws.resolve(Some("b")).unwrap_err();
1116        assert!(matches!(err, WorkspaceError::UnknownProject { .. }));
1117        assert!(err.to_string().contains("known: a"));
1118    }
1119
1120    #[test]
1121    fn cached_store_handle_is_reused() {
1122        let ws = Workspace::single("a", store());
1123        // Two accesses return the same underlying handle (cache hit).
1124        ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
1125        let again = ws.handle("a").unwrap();
1126        // The handle is held by both the cache and this local, so ≥ 2.
1127        assert!(Arc::strong_count(&again) >= 2);
1128    }
1129
1130    #[test]
1131    fn parse_qualified_splits_on_the_first_double_colon_only() {
1132        // Bare keys carry single colons; only `::` separates the project.
1133        assert_eq!(
1134            parse_qualified("app::sym:rust:a.rs#B"),
1135            Some(("app", "sym:rust:a.rs#B"))
1136        );
1137        assert_eq!(parse_qualified("app::file:x"), Some(("app", "file:x")));
1138        // Not qualified / malformed.
1139        assert_eq!(parse_qualified("sym:rust:a.rs#B"), None);
1140        assert_eq!(parse_qualified("::x"), None);
1141        assert_eq!(parse_qualified("app::"), None);
1142    }
1143
1144    #[test]
1145    fn resolve_qualified_finds_drift_and_bad_targets() {
1146        use crate::model::{Node, NodeKind};
1147        let mut s = store();
1148        s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
1149            "file:cfg.rs",
1150            NodeKind::File,
1151            "cfg.rs",
1152        )))
1153        .unwrap();
1154        let ws = Workspace::single("app", s);
1155
1156        // Resolves an existing node in the named project.
1157        let hit = ws.resolve_qualified("app::file:cfg.rs").unwrap();
1158        assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
1159        // Well-formed but absent → drift (Ok(None)).
1160        assert!(ws.resolve_qualified("app::file:gone.rs").unwrap().is_none());
1161        // Unknown target project → an error the caller reports as drift.
1162        assert!(matches!(
1163            ws.resolve_qualified("ghost::file:x").unwrap_err(),
1164            WorkspaceError::UnknownProject { .. }
1165        ));
1166        // Not project-qualified at all.
1167        assert!(matches!(
1168            ws.resolve_qualified("file:cfg.rs").unwrap_err(),
1169            WorkspaceError::Unqualified { .. }
1170        ));
1171    }
1172
1173    #[test]
1174    fn follow_external_ref_walks_a_placeholder_to_its_target() {
1175        use crate::links::external_ref_node;
1176        use crate::model::{Node, NodeKind};
1177        let mut s = store();
1178        // A real target node, plus a placeholder standing in for it (as it would
1179        // live in a spoke store pointing back at this project).
1180        s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
1181            "file:cfg.rs",
1182            NodeKind::File,
1183            "cfg.rs",
1184        )))
1185        .unwrap();
1186        let ws = Workspace::single("app", s);
1187
1188        // Following the placeholder resolves the qualified target to the real node.
1189        let placeholder = external_ref_node("app::file:cfg.rs");
1190        let hit = ws.follow_external_ref(&placeholder).unwrap();
1191        assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
1192
1193        // A placeholder for a removed target is drift (Ok(None)), not an error.
1194        let gone = external_ref_node("app::file:gone.rs");
1195        assert!(ws.follow_external_ref(&gone).unwrap().is_none());
1196
1197        // A plain (non-external-ref) node is simply not followed.
1198        let plain = Node::new("file:cfg.rs", NodeKind::File, "cfg.rs");
1199        assert!(ws.follow_external_ref(&plain).unwrap().is_none());
1200    }
1201
1202    // -- follow-the-link hop: config_key → struct bridge ------------------
1203
1204    /// A config-key node as extraction emits it: key `cfgkey:<file>#<dotted>`,
1205    /// name the dotted key, `meta { key, value }`.
1206    fn cfg_node(dotted: &str) -> crate::model::Node {
1207        use crate::model::{Node, NodeKind};
1208        let mut n = Node::new(
1209            format!("cfgkey:config.toml#{dotted}"),
1210            NodeKind::Other("config_key".to_owned()),
1211            dotted,
1212        );
1213        n.meta = serde_json::json!({ "key": dotted, "value": "x" });
1214        n
1215    }
1216
1217    /// A struct node as extraction emits it, carrying its declared field names in
1218    /// `meta.fields` (the bridge's join signal).
1219    fn struct_node(name: &str, fields: &[&str]) -> crate::model::Node {
1220        use crate::model::{Node, NodeKind};
1221        let mut n = Node::new(format!("sym:rust:config.rs#{name}"), NodeKind::Struct, name);
1222        n.meta = serde_json::json!({ "fields": fields });
1223        n
1224    }
1225
1226    /// Build a hub with a `ServeConfig`/`addr` struct field AND its `serve.addr`
1227    /// config key — plus decoys — so the bridge's confidence rules are exercised.
1228    fn bridge_hub() -> Workspace {
1229        use crate::model::FactSet;
1230        let mut s = store();
1231        s.apply_factset(
1232            &FactSet::new()
1233                .with_node(struct_node("ServeConfig", &["addr", "tools", "tls_cert"]))
1234                .with_node(struct_node("ModelsConfig", &["embedding", "generative"]))
1235                .with_node(cfg_node("serve.addr"))
1236                .with_node(cfg_node("serve.tls_cert"))
1237                .with_node(cfg_node("serve.ghost")) // resolves, but no such field
1238                .with_node(cfg_node("mystery.addr")) // no struct for section `mystery`
1239                .with_node(cfg_node("port")), // single-segment: no section
1240        )
1241        .unwrap();
1242        Workspace::single("hub", s)
1243    }
1244
1245    #[test]
1246    fn follow_bridges_config_key_to_its_defining_struct_field() {
1247        let ws = bridge_hub();
1248        // `serve.addr` bridges to the `ServeConfig` struct, field `addr`.
1249        match ws
1250            .follow_definition("hub::cfgkey:config.toml#serve.addr")
1251            .unwrap()
1252        {
1253            Follow::StructField { node, field } => {
1254                assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1255                assert_eq!(field, "addr");
1256            }
1257            other => panic!("expected a struct-field bridge, got {other:?}"),
1258        }
1259        // Separator-insensitive on the leaf: `serve.tls_cert` → field `tls_cert`.
1260        match ws
1261            .follow_definition("hub::cfgkey:config.toml#serve.tls_cert")
1262            .unwrap()
1263        {
1264            Follow::StructField { node, field } => {
1265                assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1266                assert_eq!(field, "tls_cert");
1267            }
1268            other => panic!("expected a struct-field bridge, got {other:?}"),
1269        }
1270    }
1271
1272    #[test]
1273    fn follow_falls_back_to_config_key_when_not_confidently_bridgeable() {
1274        let ws = bridge_hub();
1275        // Section matches a struct, but the struct has no such field → fall back.
1276        let ghost = ws
1277            .follow_definition("hub::cfgkey:config.toml#serve.ghost")
1278            .unwrap();
1279        assert!(
1280            matches!(&ghost, Follow::Node { node } if node.name == "serve.ghost"),
1281            "unmatched field falls back to the config_key node, got {ghost:?}"
1282        );
1283        // No struct maps to section `mystery` → fall back.
1284        let mystery = ws
1285            .follow_definition("hub::cfgkey:config.toml#mystery.addr")
1286            .unwrap();
1287        assert!(matches!(&mystery, Follow::Node { node } if node.name == "mystery.addr"));
1288        // A single-segment key names no section → never bridged.
1289        let port = ws
1290            .follow_definition("hub::cfgkey:config.toml#port")
1291            .unwrap();
1292        assert!(matches!(&port, Follow::Node { node } if node.name == "port"));
1293    }
1294
1295    #[test]
1296    fn follow_does_not_bridge_on_ambiguity() {
1297        use crate::model::FactSet;
1298        // TWO structs both map to section `serve` and both declare `addr` — a
1299        // genuinely ambiguous mapping must fall back, never guess a wrong node.
1300        let mut s = store();
1301        s.apply_factset(
1302            &FactSet::new()
1303                .with_node(struct_node("ServeConfig", &["addr"]))
1304                .with_node(struct_node("Serve", &["addr"])) // also matches `serve`
1305                .with_node(cfg_node("serve.addr")),
1306        )
1307        .unwrap();
1308        let ws = Workspace::single("hub", s);
1309        let out = ws
1310            .follow_definition("hub::cfgkey:config.toml#serve.addr")
1311            .unwrap();
1312        assert!(
1313            matches!(&out, Follow::Node { node } if node.name == "serve.addr"),
1314            "ambiguous (two matching structs) falls back, got {out:?}"
1315        );
1316    }
1317
1318    #[test]
1319    fn follow_narrow_lookup_ignores_unrelated_structs_with_the_same_field() {
1320        use crate::model::FactSet;
1321        // The name-narrowed struct lookup must return exactly what a full scan
1322        // would: an unrelated struct that happens to declare `addr` is NOT the
1323        // `serve` section's struct, so `serve.addr` still bridges only to
1324        // `ServeConfig` — proving the narrowing preserves bridging semantics.
1325        let mut s = store();
1326        s.apply_factset(
1327            &FactSet::new()
1328                .with_node(struct_node("ServeConfig", &["addr"]))
1329                .with_node(struct_node("Unrelated", &["addr"]))
1330                .with_node(struct_node("Widget", &["addr", "size"]))
1331                .with_node(struct_node("ModelsConfig", &["embedding"]))
1332                .with_node(cfg_node("serve.addr")),
1333        )
1334        .unwrap();
1335        let ws = Workspace::single("hub", s);
1336        match ws
1337            .follow_definition("hub::cfgkey:config.toml#serve.addr")
1338            .unwrap()
1339        {
1340            Follow::StructField { node, field } => {
1341                assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1342                assert_eq!(field, "addr");
1343            }
1344            other => panic!("expected a struct-field bridge to ServeConfig, got {other:?}"),
1345        }
1346    }
1347
1348    #[test]
1349    fn follow_reports_drift_and_passes_through_non_config_targets() {
1350        use crate::model::{FactSet, Node, NodeKind};
1351        let mut s = store();
1352        s.apply_factset(&FactSet::new().with_node(Node::new(
1353            "sym:rust:a.rs#Thing",
1354            NodeKind::Struct,
1355            "Thing",
1356        )))
1357        .unwrap();
1358        let ws = Workspace::single("hub", s);
1359        // A well-formed target whose node is gone → drift.
1360        assert_eq!(
1361            ws.follow_definition("hub::cfgkey:config.toml#gone")
1362                .unwrap(),
1363            Follow::Drift
1364        );
1365        // A spoke pointing straight at a symbol (an authored link, not a config
1366        // key) passes the node through unbridged.
1367        match ws.follow_definition("hub::sym:rust:a.rs#Thing").unwrap() {
1368            Follow::Node { node } => assert_eq!(node.key, "sym:rust:a.rs#Thing"),
1369            other => panic!("expected pass-through, got {other:?}"),
1370        }
1371    }
1372
1373    #[test]
1374    fn workspace_set_select_single_ambiguous_and_unknown() {
1375        // One workspace ⇒ the default; a bare or named select both resolve to it.
1376        let one = WorkspaceSet::from_workspaces([(
1377            "only".to_owned(),
1378            Workspace::single("only", store()),
1379            true,
1380        )]);
1381        assert_eq!(one.names(), vec!["only".to_owned()]);
1382        assert_eq!(one.linked("only"), Some(true));
1383        assert!(one.linked("nope").is_none());
1384        assert!(one.select(None).is_ok());
1385        assert!(one.select(Some("only")).is_ok());
1386        assert!(matches!(
1387            one.select(Some("ghost")),
1388            Err(WorkspaceError::UnknownWorkspace { .. })
1389        ));
1390
1391        // Several workspaces ⇒ a bare select is ambiguous (listing the names), a
1392        // named select works, and an unknown name errors.
1393        let many = WorkspaceSet::from_workspaces([
1394            ("api".to_owned(), Workspace::single("api", store()), true),
1395            ("web".to_owned(), Workspace::single("web", store()), false),
1396        ]);
1397        assert_eq!(many.names(), vec!["api".to_owned(), "web".to_owned()]);
1398        assert_eq!(many.linked("web"), Some(false));
1399        // (`select` yields `&Workspace`, which isn't `Debug`, so match the error
1400        // out rather than `unwrap_err`.)
1401        let Err(err) = many.select(None) else {
1402            panic!("a bare select over several workspaces must be ambiguous");
1403        };
1404        assert!(matches!(err, WorkspaceError::AmbiguousWorkspace { .. }));
1405        assert!(err.to_string().contains("api"));
1406        assert!(err.to_string().contains("web"));
1407        assert!(many.select(Some("web")).is_ok());
1408        assert!(matches!(
1409            many.select(Some("ghost")),
1410            Err(WorkspaceError::UnknownWorkspace { .. })
1411        ));
1412
1413        // No workspaces ⇒ a bare select reports the empty set.
1414        let none = WorkspaceSet::from_workspaces(std::iter::empty());
1415        assert!(matches!(none.select(None), Err(WorkspaceError::Empty)));
1416    }
1417
1418    #[test]
1419    fn workspace_set_containing_finds_the_owning_workspace_by_db_path() {
1420        // Build two workspaces from explicit (name, graph.db) pairs — no git needed
1421        // — so `containing` can match a repo's db against each workspace's members.
1422        let api_db = PathBuf::from("/ws/api/svc/.git/roteiro/graph.db");
1423        let web_db = PathBuf::from("/ws/web/app/.git/roteiro/graph.db");
1424        let set = WorkspaceSet::from_workspaces([
1425            (
1426                "api".to_owned(),
1427                Workspace::from_named_dbs([("svc".to_owned(), api_db.clone())]),
1428                true,
1429            ),
1430            (
1431                "web".to_owned(),
1432                Workspace::from_named_dbs([("app".to_owned(), web_db.clone())]),
1433                false,
1434            ),
1435        ]);
1436        assert_eq!(set.containing(&api_db), Some("api"));
1437        assert_eq!(set.containing(&web_db), Some("web"));
1438        // A db in no workspace matches nothing.
1439        assert_eq!(
1440            set.containing(Path::new("/elsewhere/.git/roteiro/graph.db")),
1441            None
1442        );
1443    }
1444}