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