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. The outer
18//! [`WorkspaceSet`] reloads the same way ([`WorkspaceSet::reload_from_resolved`])
19//! — it must, because a `serve` process holds *both*, and reloading only the
20//! inner one left the read-only graph API and the served UI reporting a stale
21//! repo list beside a log line announcing a fresh one. Each reload splits into a
22//! `plan_reload` that does all the git discovery and an `apply_reload` that only
23//! takes a lock, so a caller holding both registries can swap them back to back
24//! rather than interleaved with a filesystem walk. An optional first-open hook
25//! ([`Workspace::with_on_open`], `serve --sync-on-access`) (re)builds a project's
26//! graph the first time it is queried.
27
28use std::collections::{BTreeMap, HashMap};
29use std::path::{Path, PathBuf};
30use std::sync::{Arc, Mutex};
31
32use crate::git::{GitError, Repo};
33use crate::model::Node;
34use crate::store::{Store, StoreError};
35
36/// A failure resolving or opening a project's graph.
37#[derive(Debug, thiserror::Error)]
38pub enum WorkspaceError {
39 /// A call named a project the workspace does not know.
40 #[error("no project named `{name}` (known: {known})")]
41 UnknownProject {
42 /// The requested name.
43 name: String,
44 /// Comma-separated list of known project names.
45 known: String,
46 },
47 /// A call omitted `project` but the workspace has no single default (it holds
48 /// several projects), so the selection is ambiguous.
49 #[error("this server hosts several projects ({known}); name one with `project`")]
50 AmbiguousProject {
51 /// Comma-separated list of known project names.
52 known: String,
53 },
54 /// The workspace is registered but empty (no repos resolved).
55 #[error("no projects registered")]
56 Empty,
57 /// A selector named a workspace the [`WorkspaceSet`] does not know.
58 #[error("no workspace named `{name}` (known: {known})")]
59 UnknownWorkspace {
60 /// The requested workspace name.
61 name: String,
62 /// Comma-separated list of known workspace names.
63 known: String,
64 },
65 /// A selection omitted a name but the [`WorkspaceSet`] holds several
66 /// workspaces, so the choice is ambiguous.
67 #[error("several workspaces configured ({known}); select one with `--workspace-name`")]
68 AmbiguousWorkspace {
69 /// Comma-separated list of known workspace names.
70 known: String,
71 },
72 /// Reading a workspace root directory during repo discovery failed.
73 #[error("reading workspace root `{}`: {msg}", .root.display())]
74 Discover {
75 /// The root directory that could not be read.
76 root: PathBuf,
77 /// The underlying I/O error message.
78 msg: String,
79 },
80 /// A cross-repo target was not a project-qualified key (`<project>::<key>`).
81 #[error("`{key}` is not a project-qualified key (expected `<project>::<key>`)")]
82 Unqualified {
83 /// The malformed key.
84 key: String,
85 },
86 /// The project's graph store does not exist yet — its repo has not been
87 /// synced (`roteiro sync`).
88 #[error("project `{name}` has no graph yet — run `roteiro sync` in {}", .path.display())]
89 NoGraph {
90 /// The project name.
91 name: String,
92 /// The repo directory whose graph is missing.
93 path: PathBuf,
94 },
95 /// The on-open hook (`serve --sync-on-access`) failed to prepare a project's
96 /// graph before it was first served.
97 #[error("failed to prepare project `{name}` on first access: {msg}")]
98 Prepare {
99 /// The project name.
100 name: String,
101 /// The hook's error message.
102 msg: String,
103 },
104 /// A store lock was poisoned by a panic in another thread.
105 #[error("store lock poisoned")]
106 Poisoned,
107 /// Discovering the repo for a registered path failed.
108 #[error(transparent)]
109 Git(#[from] GitError),
110 /// Opening the project's store failed.
111 #[error(transparent)]
112 Store(#[from] StoreError),
113}
114
115/// Where a project's store comes from: a `graph.db` to open on demand, or an
116/// already-open store (the single-repo default and tests).
117#[derive(Clone)]
118enum Source {
119 /// Open this `graph.db` path on first use, for the repository whose working
120 /// tree is rooted at `root`.
121 ///
122 /// `root` is *carried* rather than derived from `db`, because a
123 /// repository's own configuration governs how it is scanned, whoever is
124 /// asking ([`Workspace::project_root`]) — and the "repo dir is the store's
125 /// grandparent" shortcut is wrong for a **linked worktree**, whose git dir
126 /// is `<main>/.git/worktrees/<name>`, not `<repo>/.git`. `build_registry`
127 /// already holds the true working-tree root, so it is recorded here instead
128 /// of guessed later. `None` where the caller supplied only a `graph.db`
129 /// path ([`Workspace::from_named_dbs`]).
130 Path {
131 /// The `graph.db` to open.
132 db: PathBuf,
133 /// The repository's working-tree root, when known.
134 root: Option<PathBuf>,
135 },
136 /// A pre-opened store, shared directly.
137 Open(Arc<Mutex<Store>>),
138}
139
140/// The registry plus the open-store cache, behind one lock. Held only briefly —
141/// to look up a source or (un)cache a handle — never across a graph query, which
142/// runs on the returned per-store `Mutex` after this lock is released.
143struct Inner {
144 /// Project name → its store source, in stable name order.
145 projects: BTreeMap<String, Source>,
146 /// The project used when a call omits `project` (the sole project, if there
147 /// is exactly one; otherwise `None` and a bare call is ambiguous).
148 default: Option<String>,
149 /// Opened stores, cached by project name, tagged with the [`Source`] they
150 /// were opened from. `Store` is `!Sync` (it holds a rusqlite connection), so
151 /// each is behind its own `Mutex`. The tag lets a reload keep a warm
152 /// connection only when the project still maps to the *same* source, and
153 /// never serve a handle for a repo the name no longer points at.
154 cache: HashMap<String, (Source, Arc<Mutex<Store>>)>,
155}
156
157/// Whether two sources denote the same store: the same `graph.db` path, or the
158/// very same pre-opened handle. The `graph.db` path *is* the store's identity,
159/// so the recorded working-tree root does not enter the comparison.
160fn source_eq(a: &Source, b: &Source) -> bool {
161 match (a, b) {
162 (Source::Path { db: x, .. }, Source::Path { db: y, .. }) => x == y,
163 (Source::Open(x), Source::Open(y)) => Arc::ptr_eq(x, y),
164 _ => false,
165 }
166}
167
168/// A hook run against a project's `graph.db` path the first time it is opened —
169/// used by `serve --sync-on-access` to (re)build a stale or missing graph before
170/// it is served (ADR-0008). Returns a human-readable error on failure.
171pub type OnOpen = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;
172
173/// A fully-discovered registry, ready to be swapped into a live [`Workspace`].
174///
175/// Opaque on purpose: it exists so that the **I/O half** of a reload (git
176/// discovery, [`Workspace::plan_reload`]) can be separated from the **swap half**
177/// ([`Workspace::apply_reload`]), which takes one lock and does no I/O. A server
178/// that must reload several registries coherently plans them all first and then
179/// applies them back to back, so the window in which two surfaces could report
180/// different repo sets is a pair of adjacent lock acquisitions rather than a
181/// filesystem walk.
182pub struct ReloadPlan {
183 /// Project name → its store source, in stable name order.
184 projects: BTreeMap<String, Source>,
185 /// The project a bare (no-`project`) call resolves to, if unambiguous.
186 default: Option<String>,
187}
188
189/// A named set of per-repo graphs, each opened on demand and cached. Cheap to
190/// hold: the stores are small `SQLite` files opened lazily; the caller (a server)
191/// holds the one expensive model. The registry is reloadable in place.
192pub struct Workspace {
193 inner: Mutex<Inner>,
194 /// Optional first-open hook (`serve --sync-on-access`): run against a
195 /// project's `graph.db` path before it is opened, to sync it on demand.
196 on_open: Option<OnOpen>,
197}
198
199impl Workspace {
200 /// A single-project workspace over an already-open `store`, named `name`.
201 /// This is the single-repo `serve` default and the test constructor; a bare
202 /// (no-`project`) call resolves to it. Not reloadable (no repo paths).
203 #[must_use]
204 pub fn single(name: impl Into<String>, store: Store) -> Self {
205 let name = name.into();
206 let mut projects = BTreeMap::new();
207 projects.insert(name.clone(), Source::Open(Arc::new(Mutex::new(store))));
208 Self {
209 inner: Mutex::new(Inner {
210 projects,
211 default: Some(name),
212 cache: HashMap::new(),
213 }),
214 on_open: None,
215 }
216 }
217
218 /// A workspace over several already-open stores, one per named project — the
219 /// in-memory counterpart of [`Workspace::from_repo_paths`] (which opens each
220 /// project's `graph.db` from disk lazily). Used for multi-repo serving of
221 /// pre-built stores and for tests. With exactly one project it becomes the
222 /// default (as [`Workspace::single`]); with several, a bare (no-`project`)
223 /// call is ambiguous. Not reloadable (no repo paths).
224 #[must_use]
225 pub fn from_stores<I, S>(stores: I) -> Self
226 where
227 I: IntoIterator<Item = (S, Store)>,
228 S: Into<String>,
229 {
230 let mut projects = BTreeMap::new();
231 for (name, store) in stores {
232 // Dedupe like `from_repo_paths` (`-2`, `-3`, …) so two stores sharing a
233 // base name both survive instead of the second silently overwriting the
234 // first (which would drop a project).
235 let name = dedupe_name(&projects, name.into());
236 projects.insert(name, Source::Open(Arc::new(Mutex::new(store))));
237 }
238 // Mirror `from_repo_paths`: a lone project is the default; several are
239 // ambiguous until a call names one.
240 let default = if projects.len() == 1 {
241 projects.keys().next().cloned()
242 } else {
243 None
244 };
245 Self {
246 inner: Mutex::new(Inner {
247 projects,
248 default,
249 cache: HashMap::new(),
250 }),
251 on_open: None,
252 }
253 }
254
255 /// Build a workspace from repo directories: each is `git`-discovered, named
256 /// after its working-tree directory (collisions get a `-2`, `-3`, … suffix),
257 /// and its `graph.db` opened lazily. With exactly one repo, that repo is the
258 /// default project.
259 ///
260 /// # Errors
261 /// [`WorkspaceError::Git`] if a path is not inside a git repository, or
262 /// [`WorkspaceError::Empty`] if `paths` resolves to no repos.
263 pub fn from_repo_paths<I, P>(paths: I) -> Result<Self, WorkspaceError>
264 where
265 I: IntoIterator<Item = P>,
266 P: AsRef<Path>,
267 {
268 let (projects, default) = build_registry(paths)?;
269 Ok(Self {
270 inner: Mutex::new(Inner {
271 projects,
272 default,
273 cache: HashMap::new(),
274 }),
275 on_open: None,
276 })
277 }
278
279 /// Build a workspace from explicit `(project name, graph.db path)` pairs,
280 /// **without** git discovery — used where the names and store locations are
281 /// already known ([`WorkspaceSet`] construction re-uses the CLI's discovery
282 /// upstream, and tests build synthetic registries). Names are taken verbatim
283 /// (deduplicate before calling if a collision is possible); with exactly one
284 /// pair, that project is the default.
285 #[must_use]
286 pub fn from_named_dbs<I>(dbs: I) -> Self
287 where
288 I: IntoIterator<Item = (String, PathBuf)>,
289 {
290 let projects: BTreeMap<String, Source> = dbs
291 .into_iter()
292 .map(|(n, db)| (n, Source::Path { db, root: None }))
293 .collect();
294 let default = (projects.len() == 1)
295 .then(|| projects.keys().next().cloned())
296 .flatten();
297 Self {
298 inner: Mutex::new(Inner {
299 projects,
300 default,
301 cache: HashMap::new(),
302 }),
303 on_open: None,
304 }
305 }
306
307 /// The `graph.db` paths of the workspace's lazily-opened (`Path`) projects, in
308 /// stable name order. Pre-opened (`single`) projects carry no path and are
309 /// omitted. Used by [`WorkspaceSet::containing`] to find which workspace holds
310 /// a given repo.
311 #[must_use]
312 pub fn member_dbs(&self) -> Vec<PathBuf> {
313 self.lock()
314 .map(|i| {
315 i.projects
316 .values()
317 .filter_map(|s| match s {
318 Source::Path { db, .. } => Some(db.clone()),
319 Source::Open(_) => None,
320 })
321 .collect()
322 })
323 .unwrap_or_default()
324 }
325
326 /// The **working-tree root** of `project`'s repository, resolving `project`
327 /// the same way [`Workspace::with_store`] does (so `None` means the default
328 /// project).
329 ///
330 /// This exists so a caller can read *that repository's own* configuration
331 /// rather than the invoking process's. The rule, following ADR-0009's
332 /// per-repo `[[links]]` resolution: **a repository's own config governs how
333 /// it is scanned, whoever is asking.** Without it, a server started in repo
334 /// A answers questions about repo B using A's settings — and B's own
335 /// `[debt] ignore` never applies, so the API and B's CLI disagree about B.
336 ///
337 /// Returns `Ok(None)` when the project's store was handed over pre-opened
338 /// ([`Workspace::single`] / [`Workspace::from_stores`]) or registered by
339 /// `graph.db` path alone ([`Workspace::from_named_dbs`]): there is no
340 /// repository on disk to consult, and the caller falls back to its own
341 /// configuration.
342 ///
343 /// # Errors
344 /// [`WorkspaceError::UnknownProject`] / [`WorkspaceError::AmbiguousProject`]
345 /// as [`Workspace::resolve`], or [`WorkspaceError::Poisoned`].
346 pub fn project_root(&self, project: Option<&str>) -> Result<Option<PathBuf>, WorkspaceError> {
347 let name = self.resolve(project)?;
348 let inner = self.lock()?;
349 Ok(match inner.projects.get(&name) {
350 Some(Source::Path { root, .. }) => root.clone(),
351 _ => None,
352 })
353 }
354
355 /// Set a first-open hook (`serve --sync-on-access`): before a project's store
356 /// is opened for the first time, `hook` is run against its `graph.db` path to
357 /// (re)build it. Applies to lazily-opened `Path` projects; a pre-opened
358 /// `single` store is already loaded, so the hook does not fire for it.
359 #[must_use]
360 pub fn with_on_open(mut self, hook: OnOpen) -> Self {
361 self.on_open = Some(hook);
362 self
363 }
364
365 /// Rebuild the registry from a fresh set of repo `paths`: added repos become
366 /// available, removed ones are dropped (and their cached store evicted), and
367 /// still-present ones keep their warm connection. Returns the new project
368 /// names. Use this to reload a running server (e.g. on SIGHUP) without a
369 /// restart. A single-project pre-opened workspace ([`Workspace::single`]) has
370 /// no repo paths, so reloading it simply replaces it with the given repos.
371 ///
372 /// This is [`Workspace::plan_reload`] followed immediately by
373 /// [`Workspace::apply_reload`]; use the two halves separately when several
374 /// registries must be swapped together (see [`WorkspaceSet::plan_reload`]).
375 ///
376 /// # Errors
377 /// As [`Workspace::from_repo_paths`].
378 pub fn reload_from<I, P>(&self, paths: I) -> Result<Vec<String>, WorkspaceError>
379 where
380 I: IntoIterator<Item = P>,
381 P: AsRef<Path>,
382 {
383 self.apply_reload(Self::plan_reload(paths)?)
384 }
385
386 /// Discover `paths` into the registry a reload would install, **without
387 /// touching the live workspace**. All of a reload's I/O (git discovery)
388 /// happens here, so [`Workspace::apply_reload`] is a lock-and-swap with no
389 /// I/O in it — which is what lets a caller holding several registries swap
390 /// them all back to back rather than interleaved with discovery.
391 ///
392 /// # Errors
393 /// As [`Workspace::from_repo_paths`].
394 pub fn plan_reload<I, P>(paths: I) -> Result<ReloadPlan, WorkspaceError>
395 where
396 I: IntoIterator<Item = P>,
397 P: AsRef<Path>,
398 {
399 let (projects, default) = build_registry(paths)?;
400 Ok(ReloadPlan { projects, default })
401 }
402
403 /// Install a [`ReloadPlan`] built by [`Workspace::plan_reload`], returning the
404 /// new project names. Takes the registry lock once and does no I/O under it.
405 ///
406 /// # Errors
407 /// [`WorkspaceError::Poisoned`] if the registry lock was poisoned.
408 pub fn apply_reload(&self, plan: ReloadPlan) -> Result<Vec<String>, WorkspaceError> {
409 let ReloadPlan { projects, default } = plan;
410 let names: Vec<String> = projects.keys().cloned().collect();
411 let mut inner = self.lock()?;
412 // Keep a warm connection only where the project still maps to the *same*
413 // source; drop it if the name is gone or now points at a different
414 // `graph.db` (or was a pre-opened `single` store), so a query never hits
415 // the wrong repo.
416 inner
417 .cache
418 .retain(|name, (src, _)| projects.get(name).is_some_and(|new| source_eq(new, src)));
419 inner.projects = projects;
420 inner.default = default;
421 Ok(names)
422 }
423
424 /// The registered project names, in stable order.
425 #[must_use]
426 pub fn names(&self) -> Vec<String> {
427 self.lock()
428 .map(|i| i.projects.keys().cloned().collect())
429 .unwrap_or_default()
430 }
431
432 /// Whether the workspace holds more than one project (so `project` selection
433 /// is meaningful to expose to callers/tools).
434 #[must_use]
435 pub fn is_multi(&self) -> bool {
436 self.lock().is_ok_and(|i| i.projects.len() > 1)
437 }
438
439 /// Resolve `project` (or the default) to a concrete project name.
440 ///
441 /// # Errors
442 /// [`WorkspaceError::UnknownProject`] if named but absent,
443 /// [`WorkspaceError::AmbiguousProject`] if omitted with several projects, or
444 /// [`WorkspaceError::Empty`] if there are none.
445 pub fn resolve(&self, project: Option<&str>) -> Result<String, WorkspaceError> {
446 let inner = self.lock()?;
447 match project {
448 Some(name) if inner.projects.contains_key(name) => Ok(name.to_owned()),
449 Some(name) => Err(WorkspaceError::UnknownProject {
450 name: name.to_owned(),
451 known: keys(&inner.projects),
452 }),
453 None => inner.default.clone().ok_or_else(|| {
454 if inner.projects.is_empty() {
455 WorkspaceError::Empty
456 } else {
457 WorkspaceError::AmbiguousProject {
458 known: keys(&inner.projects),
459 }
460 }
461 }),
462 }
463 }
464
465 /// Run `f` with the resolved project's store (opened and cached on first
466 /// use). The store lock is held only for `f`, never across an `.await`.
467 ///
468 /// # Errors
469 /// As [`Workspace::resolve`], plus [`WorkspaceError::NoGraph`] if the store
470 /// file is absent, [`WorkspaceError::Store`] on open failure, or
471 /// [`WorkspaceError::Poisoned`] if a lock was poisoned.
472 pub fn with_store<R>(
473 &self,
474 project: Option<&str>,
475 f: impl FnOnce(&Store) -> R,
476 ) -> Result<R, WorkspaceError> {
477 let name = self.resolve(project)?;
478 let handle = self.handle(&name)?;
479 let store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
480 Ok(f(&store))
481 }
482
483 /// Like [`Workspace::with_store`], but hands `f` a **mutable** store so it can
484 /// persist into the graph (e.g. [`Store::apply_import_layer`]). The store lock
485 /// is held only for `f`, never across an `.await`. Backs the explorer's
486 /// `links/write` endpoint, which materialises the inferred cross-repo links into
487 /// a spoke's graph as a durable import layer.
488 ///
489 /// # Errors
490 /// As [`Workspace::with_store`].
491 pub fn with_store_mut<R>(
492 &self,
493 project: Option<&str>,
494 f: impl FnOnce(&mut Store) -> R,
495 ) -> Result<R, WorkspaceError> {
496 let name = self.resolve(project)?;
497 let handle = self.handle(&name)?;
498 let mut store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
499 Ok(f(&mut store))
500 }
501
502 /// Resolve a **project-qualified** key `"<project>::<key>"` to its node across
503 /// the workspace, opening the target project on demand (ADR-0009). `Ok(None)`
504 /// means the key is well-formed and the project exists but the node does not —
505 /// i.e. **cross-repo drift** (a removed or renamed target). Errors distinguish
506 /// the other failure modes so a caller can report them precisely:
507 /// [`WorkspaceError::Unqualified`] (not in `<project>::<key>` form),
508 /// [`WorkspaceError::UnknownProject`] (target repo not in the workspace),
509 /// [`WorkspaceError::NoGraph`] (target repo unsynced).
510 ///
511 /// # Errors
512 /// As above, plus [`WorkspaceError::Store`] / [`WorkspaceError::Poisoned`].
513 pub fn resolve_qualified(&self, qualified: &str) -> Result<Option<Node>, WorkspaceError> {
514 let (project, key) =
515 parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
516 key: qualified.to_owned(),
517 })?;
518 let key = key.to_owned();
519 self.with_store(Some(project), move |s| s.get_node(&key))?
520 .map_err(WorkspaceError::from)
521 }
522
523 /// Follow an **external-ref** placeholder node to the real node it stands for,
524 /// resolving its project-qualified target across the workspace (ADR-0009). An
525 /// external-ref lives in a spoke's store as a local stand-in for a node in the
526 /// hub's store (see [`crate::external_ref_node`]); this walks it through to the
527 /// hub. `Ok(None)` means either `node` is not an external-ref, or its target no
528 /// longer resolves — cross-repo drift (a removed or renamed hub key). Errors
529 /// distinguish the other failure modes, as [`Workspace::resolve_qualified`].
530 ///
531 /// # Errors
532 /// As [`Workspace::resolve_qualified`].
533 pub fn follow_external_ref(&self, node: &Node) -> Result<Option<Node>, WorkspaceError> {
534 match crate::external_ref_target(node) {
535 Some(qualified) => self.resolve_qualified(&qualified),
536 None => Ok(None),
537 }
538 }
539
540 /// Follow a **project-qualified** cross-repo target to the most specific
541 /// *definition* it names — the follow-the-link hop that turns a click on a
542 /// spoke's app-key target into a jump to the hub node that defines it.
543 ///
544 /// [`Workspace::resolve_qualified`] lands on the raw hub node a spoke points
545 /// at, which for a config override is the hub's `config_key` node (e.g.
546 /// `cfgkey:config.toml#serve.addr`), *not* the Rust struct that declares the
547 /// setting. This method adds the net-new **`config_key` → struct bridge**: when
548 /// the resolved node is a config key whose dotted path maps — with confidence —
549 /// to exactly one hub struct and one of its named fields, it returns that
550 /// struct as the jump target ([`Follow::StructField`], carrying the matched
551 /// field name). Otherwise it returns the resolved node unchanged
552 /// ([`Follow::Node`]) — a config key we could not bridge, or any non-config
553 /// target (e.g. an authored `[[links]]` that already points at a symbol). A
554 /// well-formed target whose node is gone is [`Follow::Drift`].
555 ///
556 /// The bridge is deliberately conservative (see `bridge_config_key`): it
557 /// fires only on a *unique* match of both an independent section→struct-name
558 /// signal and a field-presence signal, so it never jumps to a **wrong** node —
559 /// an ambiguous or unmatched key falls back to the config-key node.
560 ///
561 /// # Errors
562 /// As [`Workspace::resolve_qualified`] (a well-formed but unhosted / unsynced
563 /// target project still errors; a resolved-but-missing node is `Drift`).
564 pub fn follow_definition(&self, qualified: &str) -> Result<Follow, WorkspaceError> {
565 let (project, key) =
566 parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
567 key: qualified.to_owned(),
568 })?;
569 let key = key.to_owned();
570 self.with_store(Some(project), move |store| -> Result<Follow, StoreError> {
571 let Some(node) = store.get_node(&key)? else {
572 return Ok(Follow::Drift);
573 };
574 // Only a config-key node needs bridging; anything else the spoke points
575 // at is already a definition-level target. Compare against the stable
576 // token via `as_str()` — no allocation to build a throwaway `NodeKind`.
577 if node.kind.as_str() == crate::config_keys::KIND {
578 match bridge_config_key(store, &node)? {
579 Some((target, field)) => Ok(Follow::StructField {
580 node: target,
581 field,
582 }),
583 None => Ok(Follow::Node { node }),
584 }
585 } else {
586 Ok(Follow::Node { node })
587 }
588 })?
589 .map_err(WorkspaceError::from)
590 }
591
592 /// Lock the inner state, mapping a poisoned lock to [`WorkspaceError::Poisoned`].
593 fn lock(&self) -> Result<std::sync::MutexGuard<'_, Inner>, WorkspaceError> {
594 self.inner.lock().map_err(|_| WorkspaceError::Poisoned)
595 }
596
597 /// Get (opening + caching on first use) the shared store handle for `name`.
598 /// Opens `graph.db` **outside** the registry lock so a first-touch open never
599 /// blocks other projects' queries.
600 fn handle(&self, name: &str) -> Result<Arc<Mutex<Store>>, WorkspaceError> {
601 // Fast path and pre-opened sources resolve under a single short lock.
602 let (db, root) = {
603 let mut inner = self.lock()?;
604 if let Some((_, handle)) = inner.cache.get(name) {
605 return Ok(handle.clone());
606 }
607 match inner.projects.get(name) {
608 Some(Source::Open(handle)) => {
609 let handle = handle.clone();
610 inner.cache.insert(
611 name.to_owned(),
612 (Source::Open(handle.clone()), handle.clone()),
613 );
614 return Ok(handle);
615 }
616 Some(Source::Path { db, root }) => (db.clone(), root.clone()),
617 None => {
618 return Err(WorkspaceError::UnknownProject {
619 name: name.to_owned(),
620 known: keys(&inner.projects),
621 });
622 }
623 }
624 };
625 // `serve --sync-on-access`: (re)build this project's graph before opening
626 // it, so a stale or never-synced repo is prepared on first touch. Runs
627 // outside the registry lock (it does extraction I/O).
628 if let Some(on_open) = &self.on_open {
629 on_open(&db).map_err(|msg| WorkspaceError::Prepare {
630 name: name.to_owned(),
631 msg,
632 })?;
633 }
634 if !db.exists() {
635 return Err(WorkspaceError::NoGraph {
636 name: name.to_owned(),
637 // The repo dir is the store's grandparent (`…/.git/roteiro`).
638 path: db
639 .parent()
640 .and_then(Path::parent)
641 .and_then(Path::parent)
642 .unwrap_or(&db)
643 .to_path_buf(),
644 });
645 }
646 let handle = Arc::new(Mutex::new(Store::open(&db)?));
647 let opened = Source::Path {
648 db: db.clone(),
649 root,
650 };
651 let mut inner = self.lock()?;
652 // Another thread may have opened it while we were; prefer the existing.
653 if let Some((_, existing)) = inner.cache.get(name) {
654 return Ok(existing.clone());
655 }
656 // Only cache if the registry still maps this name to the DB we opened —
657 // a concurrent `reload_from` may have remapped or removed it. If so,
658 // return the freshly-opened handle for this call (the caller resolved
659 // before the reload) but do not cache a now-stale mapping.
660 if inner
661 .projects
662 .get(name)
663 .is_some_and(|current| source_eq(current, &opened))
664 {
665 inner
666 .cache
667 .insert(name.to_owned(), (opened, handle.clone()));
668 }
669 Ok(handle)
670 }
671}
672
673/// Comma-separated project names (for error messages).
674fn keys<V>(entries: &BTreeMap<String, V>) -> String {
675 entries.keys().cloned().collect::<Vec<_>>().join(", ")
676}
677
678/// Split a **project-qualified** key `"<project>::<key>"` into `(project, key)`,
679/// or `None` if it carries no `::` separator (a bare, within-repo key). A project
680/// name never contains `::`; a bare key may itself contain single colons (e.g.
681/// `sym:rust:…`), so only the **first** double-colon separates the project
682/// (ADR-0009).
683#[must_use]
684pub fn parse_qualified(key: &str) -> Option<(&str, &str)> {
685 key.split_once("::")
686 .filter(|(project, bare)| !project.is_empty() && !bare.is_empty())
687}
688
689/// The outcome of [`Workspace::follow_definition`]: where a cross-repo follow-hop
690/// lands.
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub enum Follow {
693 /// Bridged past a `config_key` node to the hub **struct** that declares the
694 /// setting, carrying the specific named field that matched (e.g. the
695 /// `ServeConfig` struct for `serve.addr`, `field = "addr"`). The `node` is the
696 /// real struct node, so a caller can center it in the hub graph.
697 StructField {
698 /// The defining struct node (`sym:rust:<file>#<Struct>`).
699 node: Node,
700 /// The struct field the dotted key resolved to (its declared identifier).
701 field: String,
702 },
703 /// The resolved target node itself, unbridged — a `config_key` we could not map
704 /// to a struct with confidence (the safe fallback), or any non-config target a
705 /// spoke points straight at.
706 Node {
707 /// The resolved hub node.
708 node: Node,
709 },
710 /// The target is well-formed but its node is gone — cross-repo drift.
711 Drift,
712}
713
714/// Bridge a hub **`config_key`** node to the Rust **struct** that declares it, plus
715/// the specific field matched — the net-new step behind [`Workspace::follow_definition`].
716///
717/// The mapping from a dotted config key (`serve.addr`) to a defining Rust field is
718/// not recorded anywhere in the graph (the extractor models structs as nodes but
719/// not their fields as nodes, and a field's *type* is not captured), so this is a
720/// **resolve-time join** over two independent, deterministic signals — and it only
721/// bridges when they agree on exactly one struct:
722///
723/// 1. **section → struct name.** The dotted key's head segment (`serve`) must name
724/// the struct: its lower-cased name, with a trailing `Config` stripped, equals
725/// the section (`ServeConfig` → `serve`; a bare `Serve` also matches). See
726/// [`struct_matches_section`].
727/// 2. **field presence.** The struct must actually declare a field whose
728/// normalised name equals the key's leaf (`addr`, or `tls_cert` for
729/// `serve.tls_cert`) — read from the struct's `meta.fields`. See
730/// [`struct_field_matching`].
731///
732/// Requiring a **unique** `(struct, field)` hit is the correctness rule: a key that
733/// matches zero structs (no such section, or the field isn't declared) or more than
734/// one (genuinely ambiguous) returns `None`, and the caller falls back to the
735/// config-key node rather than risk jumping to a wrong definition.
736///
737/// Known limits (documented, deliberate): a single-segment key (no section, e.g.
738/// `port`) is never bridged; a key nested past one level (`serve.tls.cert` where
739/// `tls` is a sub-struct) won't match a flat field and falls back; and a struct
740/// whose name doesn't follow the `<Section>Config` convention won't be found. All
741/// three degrade to the existing config-key target — never to a wrong one.
742fn bridge_config_key(store: &Store, cfg_node: &Node) -> Result<Option<(Node, String)>, StoreError> {
743 // The dotted key: authoritative from `meta.key`, falling back to the node name
744 // (both are the dotted path in practice — see config-key extraction).
745 let dotted = cfg_node
746 .meta
747 .get("key")
748 .and_then(serde_json::Value::as_str)
749 .unwrap_or(cfg_node.name.as_str());
750 let Some((section, leaf)) = split_section_field(dotted) else {
751 return Ok(None);
752 };
753 let leaf_norm = crate::config_keys::normalize(leaf);
754 if leaf_norm.is_empty() {
755 return Ok(None);
756 }
757
758 // Fetch only the CANDIDATE struct(s) for this section by name, rather than
759 // loading and JSON-decoding every `struct` node in the graph on each hop
760 // (a latency spike on a large hub). `section_struct_names` yields the exact
761 // lower-cased names `struct_matches_section` would accept, so this narrows the
762 // scan without changing the bridging semantics; `struct_matches_section` is
763 // still applied below as the authoritative check.
764 let mut candidates: Vec<Node> = Vec::new();
765 for name in section_struct_names(section) {
766 candidates.extend(store.nodes_by_kind_named(&crate::NodeKind::Struct, &name)?);
767 }
768
769 let mut hits = candidates
770 .into_iter()
771 .filter(|s| struct_matches_section(&s.name, section))
772 .filter_map(|s| struct_field_matching(&s, &leaf_norm).map(|field| (s, field)));
773
774 match (hits.next(), hits.next()) {
775 // Exactly one confident match → bridge to it.
776 (Some(one), None) => Ok(Some(one)),
777 // Zero or ambiguous (>1) → fall back to the config-key node.
778 _ => Ok(None),
779 }
780}
781
782/// Split a dotted config key into `(section, leaf)` on its **first** separator:
783/// `serve.addr` → `("serve", "addr")`, `serve.tls_cert` → `("serve", "tls_cert")`.
784/// A single-segment key (`port`) has no section to identify a struct by, so it is
785/// `None` (never bridged).
786fn split_section_field(dotted: &str) -> Option<(&str, &str)> {
787 dotted
788 .split_once('.')
789 .filter(|(section, leaf)| !section.is_empty() && !leaf.is_empty())
790}
791
792/// The section's canonical form for name-matching: normalised, separators removed
793/// (`serve` → `serve`, `serve_mode` → `servemode`). Empty when the section carries
794/// no alphanumerics.
795fn section_key(section: &str) -> String {
796 crate::config_keys::normalize(section).replace('.', "")
797}
798
799/// The lower-cased struct names a config `section` can map to — exactly the names
800/// [`struct_matches_section`] accepts: `serve` → `["serve", "serveconfig"]`. Used
801/// to fetch just the candidate struct(s) by name instead of scanning them all
802/// (kept in lock-step with [`struct_matches_section`], which remains the check).
803fn section_struct_names(section: &str) -> Vec<String> {
804 let want = section_key(section);
805 if want.is_empty() {
806 return Vec::new();
807 }
808 let with_config = format!("{want}config");
809 vec![want, with_config]
810}
811
812/// Whether a struct `name` is the one a config `section` maps to: its lower-cased
813/// name with a trailing `config` stripped equals the section (case- and
814/// separator-insensitive). `ServeConfig`/`Serve` both match section `serve`;
815/// `ServeSettings` does not (so an unrelated struct is never bridged to).
816fn struct_matches_section(name: &str, section: &str) -> bool {
817 let lname = name.to_ascii_lowercase();
818 let base = lname.strip_suffix("config").unwrap_or(&lname);
819 let want = section_key(section);
820 !want.is_empty() && base == want
821}
822
823/// The struct field whose normalised identifier equals `leaf_norm`, read from the
824/// struct node's `meta.fields` (see extraction). Returns the field's original
825/// declared name (for display), or `None` when the struct declares no such field.
826fn struct_field_matching(struct_node: &Node, leaf_norm: &str) -> Option<String> {
827 struct_node
828 .meta
829 .get("fields")?
830 .as_array()?
831 .iter()
832 .filter_map(serde_json::Value::as_str)
833 .find(|field| crate::config_keys::normalize(field) == leaf_norm)
834 .map(ToOwned::to_owned)
835}
836
837/// Discover repos at `paths` into a `(name → Source, default)` registry: each
838/// path is git-discovered, named after its working-tree directory (deduped), and
839/// mapped to a lazily-opened `graph.db`. Exactly one repo ⇒ it is the default.
840type Registry = (BTreeMap<String, Source>, Option<String>);
841fn build_registry<I, P>(paths: I) -> Result<Registry, WorkspaceError>
842where
843 I: IntoIterator<Item = P>,
844 P: AsRef<Path>,
845{
846 let mut projects: BTreeMap<String, Source> = BTreeMap::new();
847 let mut seen_dbs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
848 for path in paths {
849 let repo = Repo::discover(path.as_ref())?;
850 let db = repo.git_dir().join("roteiro").join("graph.db");
851 // De-duplicate the same repo reached via different paths (O(1) lookup, so
852 // discovery stays linear even on a big workspace and every reload).
853 if !seen_dbs.insert(db.clone()) {
854 continue;
855 }
856 let base = repo
857 .workdir()
858 .and_then(Path::file_name)
859 .map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
860 let name = dedupe_name(&projects, base);
861 projects.insert(
862 name,
863 Source::Path {
864 db,
865 // The repository's own root, so its own config can be read later.
866 root: repo.workdir().map(Path::to_path_buf),
867 },
868 );
869 }
870 if projects.is_empty() {
871 return Err(WorkspaceError::Empty);
872 }
873 let default = if projects.len() == 1 {
874 projects.keys().next().cloned()
875 } else {
876 None
877 };
878 Ok((projects, default))
879}
880
881/// Make `base` unique against the names already in `projects`, appending
882/// `-2`, `-3`, … on collision.
883fn dedupe_name(projects: &BTreeMap<String, Source>, base: String) -> String {
884 if !projects.contains_key(&base) {
885 return base;
886 }
887 let mut n = 2u32;
888 loop {
889 let candidate = format!("{base}-{n}");
890 if !projects.contains_key(&candidate) {
891 return candidate;
892 }
893 n += 1;
894 }
895}
896
897/// Shallow git-repo discovery under `root`: the root itself if it is a repo, plus
898/// each immediate subdirectory that is one, in sorted order. Shallow by design — a
899/// code directory holding sibling checkouts is the common case, and a deep scan
900/// would be slow and surprising. Shared by the CLI's workspace collection and
901/// [`WorkspaceSet`] / config resolution, so the membership rule lives in one place.
902///
903/// A repo is any directory containing a `.git` entry (a directory in a normal
904/// clone, a file in worktrees and submodules), so existence — not `is_dir` — is
905/// tested.
906///
907/// The rule is invisible to whoever passes the root, which is a separate defect
908/// from the rule being wrong: see [`RootScan`], and the `--workspace` help text
909/// that now says "immediate subdirectories" rather than "under" (issue #580).
910///
911/// # Errors
912/// [`WorkspaceError::Discover`] if `root` cannot be read.
913pub fn discover_repos_under(root: &Path) -> Result<Vec<PathBuf>, WorkspaceError> {
914 Ok(scan_root(root)?.repos)
915}
916
917/// Whether `dir` is a git repository: it holds a `.git` **entry**. A directory in
918/// a normal clone, a file in worktrees and submodules — so existence is the test,
919/// not `is_dir`.
920fn is_repo(dir: &Path) -> bool {
921 dir.join(".git").exists()
922}
923
924/// Where `render okf` writes when `--out` is omitted, and therefore where a
925/// workspace member's published bundle is looked for.
926///
927/// A convention rather than a discovery: nothing in OKF says where a bundle
928/// lives in a repository, so the only directory we can name without guessing is
929/// the one **this** tool writes to. A peer who publishes elsewhere is still
930/// importable by hand with `roteiro import --from okf <path>`, which is the
931/// reason that command survives automatic discovery (issue #706, decision 3).
932pub const OKF_BUNDLE_DIR: &str = "okf";
933
934/// The OKF bundle a repository at `repo_root` publishes, if it publishes one.
935///
936/// # The test is `okf_version`, not the directory's existence
937///
938/// A directory called `okf` proves nothing — it could be source, notes, or a
939/// half-written experiment. OKF §10 says a bundle root's `index.md` declares
940/// `okf_version`, and that declaration is the only thing in the format that says
941/// "this is a bundle, and it is one of these". Requiring it is what stops
942/// discovery from offering to import an arbitrary directory of markdown, and it
943/// is deliberately the *stricter* of the two available tests: a false positive
944/// here becomes a consent prompt about something that is not a bundle, which
945/// trains the reader to dismiss the prompt.
946///
947/// # Why this parses a little YAML rather than calling the reader
948///
949/// `rto-render` depends on this crate, so the OKF reader cannot be called from
950/// here without inverting the dependency. The probe is deliberately tiny — a
951/// bounded read of the leading frontmatter block, looking for one key — rather
952/// than a second parser: it decides only *whether to offer* the bundle, and the
953/// reader still decides what the bundle contains.
954#[must_use]
955pub fn okf_bundle_in(repo_root: &Path) -> Option<PathBuf> {
956 let dir = repo_root.join(OKF_BUNDLE_DIR);
957 let index = dir.join("index.md");
958 // Bounded: a bundle index's frontmatter is a few hundred bytes, and a file
959 // that is not one should not be read into memory to find that out.
960 let mut buf = Vec::new();
961 {
962 use std::io::Read as _;
963 let file = std::fs::File::open(&index).ok()?;
964 file.take(4096).read_to_end(&mut buf).ok()?;
965 }
966 let head = String::from_utf8_lossy(&buf);
967 let rest = head
968 .strip_prefix("---\n")
969 .or_else(|| head.strip_prefix("---\r\n"))?;
970 // The **closing** fence is required, not optional. `split(…).next()` returns
971 // the whole remainder when there is no `\n---`, which would make any
972 // `index.md` opening with `---` and mentioning `okf_version:` anywhere in
973 // the first 4 KiB read as a bundle — including in ordinary prose under an
974 // unterminated block. This probe exists to be *stricter* than "a directory
975 // called okf", and a false positive here is a consent prompt about something
976 // that is not a bundle, which teaches the reader to dismiss the prompt.
977 //
978 // The cost is a false negative on an index whose frontmatter does not close
979 // within the bounded read. A bundle root's frontmatter is a handful of
980 // lines, so that is the safe direction to be wrong in.
981 let (block, _) = rest.split_once("\n---")?;
982 block
983 .lines()
984 .any(|line| {
985 line.split_once(':')
986 .is_some_and(|(k, v)| k.trim() == "okf_version" && !v.trim().is_empty())
987 })
988 .then_some(dir)
989}
990
991/// A workspace member's published OKF bundle.
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct OkfBundle {
994 /// The member repository's working-tree root.
995 pub repo: PathBuf,
996 /// The bundle directory inside it.
997 pub bundle: PathBuf,
998 /// The peer name: the member repository's directory name, which is also the
999 /// project name `build_registry` derives and the `--peer` default
1000 /// `roteiro import --from okf` uses. One name, so a bundle discovered
1001 /// automatically and the same bundle imported by hand land on **one** import
1002 /// layer rather than two.
1003 pub peer: String,
1004}
1005
1006/// Every OKF bundle published by a member in `repo_roots`, in path order.
1007///
1008/// Pure filesystem probing: one `open` and one bounded read per member. It opens
1009/// no store and makes no decision — [`crate::Store::okf_consent_holds`] is what
1010/// says whether a bundle may be read, and that is a separate question asked of a
1011/// separate crate.
1012#[must_use]
1013pub fn discover_okf_bundles(repo_roots: &[PathBuf]) -> Vec<OkfBundle> {
1014 let mut out: Vec<OkfBundle> = repo_roots
1015 .iter()
1016 .filter_map(|repo| {
1017 let bundle = okf_bundle_in(repo)?;
1018 let peer = repo.file_name()?.to_str()?.to_owned();
1019 Some(OkfBundle {
1020 repo: repo.clone(),
1021 bundle,
1022 peer,
1023 })
1024 })
1025 .collect();
1026 out.sort_by(|a, b| a.repo.cmp(&b.repo));
1027 out
1028}
1029
1030/// What a shallow scan of one root found, **including what it walked past**.
1031///
1032/// [`discover_repos_under`] answers the membership question and is what building
1033/// a workspace uses. This answers the diagnostic one, because the shallow rule is
1034/// invisible at exactly the moment it matters: a root whose repos all live one
1035/// level deeper (`~/GIT/<org>/<repo>`, a common layout) yields a near-empty
1036/// workspace and no error, so the failure presents later as "the graph tools
1037/// return nothing useful" rather than as a configuration mistake (issue #580).
1038///
1039/// The rule itself is deliberate and is not what this changes — see
1040/// [`discover_repos_under`].
1041#[derive(Debug, Clone, PartialEq, Eq)]
1042pub struct RootScan {
1043 /// The root scanned.
1044 pub root: PathBuf,
1045 /// Repos found: the root itself if it is one, plus each immediate
1046 /// subdirectory that is one, sorted.
1047 pub repos: Vec<PathBuf>,
1048 /// Immediate subdirectories that are **not** repos, sorted. A repo nested
1049 /// inside one of these is not hosted; counting them is free here because the
1050 /// scan already read the directory, which is why the successful-start note
1051 /// can report it without a second pass.
1052 pub skipped: Vec<PathBuf>,
1053}
1054
1055impl RootScan {
1056 /// Which skipped subdirectories hold a repo **directly** beneath them — the
1057 /// ones a user almost certainly meant to reach.
1058 ///
1059 /// Costs one `read_dir` per skipped directory, so it is **bounded** by `limit`
1060 /// and is for the path where the user is already stuck: a root that yielded
1061 /// nothing to serve. A successful start reports [`RootScan::skipped`] instead,
1062 /// which the scan already knows.
1063 #[must_use]
1064 pub fn nested_repo_parents(&self, limit: usize) -> Vec<&Path> {
1065 self.skipped
1066 .iter()
1067 .take(limit)
1068 .filter(|dir| {
1069 std::fs::read_dir(dir).is_ok_and(|entries| {
1070 entries
1071 .filter_map(Result::ok)
1072 .any(|e| e.path().is_dir() && is_repo(&e.path()))
1073 })
1074 })
1075 .map(PathBuf::as_path)
1076 .collect()
1077 }
1078}
1079
1080/// The shallow scan behind [`discover_repos_under`], keeping what it skipped.
1081///
1082/// # Errors
1083/// [`WorkspaceError::Discover`] if `root` cannot be read.
1084pub fn scan_root(root: &Path) -> Result<RootScan, WorkspaceError> {
1085 let mut repos = Vec::new();
1086 if is_repo(root) {
1087 repos.push(root.to_path_buf());
1088 }
1089 let entries = std::fs::read_dir(root).map_err(|e| WorkspaceError::Discover {
1090 root: root.to_path_buf(),
1091 msg: e.to_string(),
1092 })?;
1093 let (mut children, mut skipped): (Vec<PathBuf>, Vec<PathBuf>) = entries
1094 .filter_map(Result::ok)
1095 .map(|e| e.path())
1096 .filter(|p| p.is_dir())
1097 .partition(|p| is_repo(p));
1098 children.sort();
1099 skipped.sort();
1100 repos.extend(children);
1101 Ok(RootScan {
1102 root: root.to_path_buf(),
1103 repos,
1104 skipped,
1105 })
1106}
1107
1108/// A workspace group after config normalisation ([`crate::WorkspaceSet`] input): a
1109/// name, its member `roots`/`repos` (unexpanded — discovered when the set is
1110/// built), and whether its repos are cross-**linked** (served as one multi-repo
1111/// graph) or **standalone** (each its own single-repo graph, no cross-repo links).
1112///
1113/// A `linked = false` (standalone) group denotes **exactly one** single-repo graph:
1114/// the config normaliser emits one such group per discovered repo, and
1115/// [`WorkspaceSet::from_resolved`] upholds the invariant by materialising a
1116/// standalone group as a one-repo [`Workspace`] per member — a standalone group can
1117/// never collapse several repos into one unlinked multi-repo graph.
1118#[derive(Debug, Clone, PartialEq, Eq)]
1119pub struct ResolvedWorkspace {
1120 /// The workspace name (the `--workspace-name` selector).
1121 pub name: String,
1122 /// Directories to scan for member repos (as `[workspace] roots`).
1123 pub roots: Vec<String>,
1124 /// Explicit member repo paths, in addition to anything under `roots`.
1125 pub repos: Vec<String>,
1126 /// `true` ⇒ the repos form one linked graph; `false` ⇒ **standalone**: each
1127 /// member repo is its own single-repo graph (no cross-repo links).
1128 pub linked: bool,
1129}
1130
1131/// Discover each resolved group's member repo paths as
1132/// `(workspace name, repo paths, linked)`, in config order.
1133///
1134/// The **one** place a `[[workspaces]]`/`[standalone]` group becomes a concrete
1135/// set of repos, shared by [`WorkspaceSet::from_resolved`] and
1136/// [`WorkspaceSet::plan_reload`] so a reloaded set is exactly the set a restart
1137/// would have produced. A **standalone** (`linked = false`) group is split into
1138/// one single-repo entry per member, upholding the "a standalone workspace is
1139/// exactly one repo" invariant structurally; the extras take a `-2`/`-3` suffix.
1140/// A group that resolves to no repos is skipped, so a stale root never aborts the
1141/// whole set.
1142fn discover_groups(
1143 resolved: Vec<ResolvedWorkspace>,
1144) -> Result<Vec<(String, Vec<PathBuf>, bool)>, WorkspaceError> {
1145 let mut out: Vec<(String, Vec<PathBuf>, bool)> = Vec::new();
1146 for rw in resolved {
1147 let mut paths: Vec<PathBuf> = Vec::new();
1148 for root in &rw.roots {
1149 paths.extend(discover_repos_under(Path::new(root))?);
1150 }
1151 for repo in &rw.repos {
1152 paths.push(PathBuf::from(repo));
1153 }
1154 if paths.is_empty() {
1155 // A group naming nothing (e.g. a `roots` dir with no repos) is simply
1156 // absent rather than an error.
1157 continue;
1158 }
1159 if rw.linked {
1160 out.push((rw.name.clone(), paths, true));
1161 } else {
1162 for (i, path) in paths.into_iter().enumerate() {
1163 let name = if i == 0 {
1164 rw.name.clone()
1165 } else {
1166 format!("{}-{}", rw.name, i + 1)
1167 };
1168 out.push((name, vec![path], false));
1169 }
1170 }
1171 }
1172 Ok(out)
1173}
1174
1175/// One entry in a [`WorkspaceSet`]: a built [`Workspace`] plus whether its member
1176/// repos are cross-linked. The workspace is held behind an `Arc` so an
1177/// already-shared workspace (e.g. the one a `serve` process holds for its model
1178/// tools and MCP router) can be wrapped into a set without re-opening its stores
1179/// ([`WorkspaceSet::from_single`]).
1180struct WorkspaceEntry {
1181 /// The per-group workspace (one repo for a standalone singleton, several for a
1182 /// linked group).
1183 workspace: Arc<Workspace>,
1184 /// Whether the group's repos are cross-linked.
1185 linked: bool,
1186}
1187
1188/// An install's **many** named workspaces: linked groups (multi-repo graphs) and
1189/// standalone singletons (one-repo graphs), keyed by name in stable order (ADR-0008
1190/// multi-workspace). The outer layer over [`Workspace`]: it selects *which*
1191/// workspace a command operates on, then hands back that `Workspace` to resolve
1192/// projects within it. Built from normalised config ([`WorkspaceSet::from_resolved`])
1193/// so the `serve`/`links` selection logic is shared.
1194pub struct WorkspaceSet {
1195 /// The named workspaces plus the default selection, behind one lock so the
1196 /// set is **reloadable in place** ([`WorkspaceSet::apply_reload`]) exactly as
1197 /// a [`Workspace`]'s project registry is. Held only long enough to clone the
1198 /// `Arc` a selection resolves to, never across a graph query.
1199 inner: std::sync::RwLock<SetInner>,
1200}
1201
1202/// The mutable half of a [`WorkspaceSet`].
1203struct SetInner {
1204 /// Workspace name → its entry, in stable (`BTreeMap`) name order.
1205 entries: BTreeMap<String, WorkspaceEntry>,
1206 /// The workspace used when a selection omits a name (the sole workspace, if
1207 /// there is exactly one; otherwise `None` and a bare selection is ambiguous).
1208 default: Option<String>,
1209}
1210
1211/// A fully-built set of named workspaces, ready to be swapped into a live
1212/// [`WorkspaceSet`]. The [`ReloadPlan`] counterpart for the outer layer — see
1213/// [`WorkspaceSet::plan_reload`].
1214pub struct SetReloadPlan {
1215 /// The entries to install, and for a **retained** workspace the project
1216 /// registry to swap into it (planned, not yet applied).
1217 entries: Vec<(String, WorkspaceEntry, Option<ReloadPlan>)>,
1218 /// The default selection the new set will carry.
1219 default: Option<String>,
1220 /// Every member repo path this plan discovered, across all groups, in group
1221 /// order — see [`SetReloadPlan::repo_paths`].
1222 repo_paths: Vec<PathBuf>,
1223}
1224
1225impl SetReloadPlan {
1226 /// Every member repo path this plan discovered, across all groups, in group
1227 /// order.
1228 ///
1229 /// This exists so that a caller holding a **flattened** [`Workspace`] beside
1230 /// the set — `roteiro serve`/`mcp` does, one per surface — can plan its
1231 /// reload from *these very paths* rather than walking the same roots a second
1232 /// time. Two walks is two filesystem views: a repo created between them lands
1233 /// in one surface and not the other, which is a smaller version of the exact
1234 /// disagreement the whole reload-both change exists to remove. Not
1235 /// deduplicated here, because [`Workspace::from_repo_paths`] deduplicates by
1236 /// resolved `graph.db`, which is the stronger identity anyway.
1237 #[must_use]
1238 pub fn repo_paths(&self) -> &[PathBuf] {
1239 &self.repo_paths
1240 }
1241}
1242
1243impl WorkspaceSet {
1244 /// Take the read lock for a **decision** — a selection, or the snapshot a
1245 /// reload plans against — reporting a poisoned lock as an error.
1246 ///
1247 /// The only writer is [`WorkspaceSet::apply_reload`], which replaces
1248 /// `entries` and `default` as two separate moves. If it panicked between
1249 /// them the pair is genuinely inconsistent, and resolving a default against a
1250 /// half-swapped set would hand back the wrong workspace. So a decision fails
1251 /// loudly here; see [`WorkspaceSet::peek`] for the reporting counterpart.
1252 fn read(&self) -> Result<std::sync::RwLockReadGuard<'_, SetInner>, WorkspaceError> {
1253 self.inner.read().map_err(|_| WorkspaceError::Poisoned)
1254 }
1255
1256 /// Take the read lock for a **report** — a listing, never a resolution —
1257 /// reading *through* a poisoned lock.
1258 ///
1259 /// These accessors cannot return a `Result`, so the alternative is an empty
1260 /// list, and an empty list is a lie: it renders a poisoned set as "no
1261 /// workspaces configured", which is the confidently-wrong-message shape this
1262 /// whole change exists to remove — and it would empty the `known:` list in
1263 /// the very error a person is reading to find out what went wrong. The data
1264 /// behind the lock is a map of `Arc`s replaced by whole-value assignment, so
1265 /// reading it after a panicking writer yields the old or the new map, never
1266 /// a torn one.
1267 fn peek(&self) -> std::sync::RwLockReadGuard<'_, SetInner> {
1268 self.inner
1269 .read()
1270 .unwrap_or_else(std::sync::PoisonError::into_inner)
1271 }
1272
1273 /// Assemble a set from pre-built named workspaces — the shared core of
1274 /// [`WorkspaceSet::from_resolved`] and the test constructor. With exactly one
1275 /// entry, that workspace is the default (a bare selection resolves to it).
1276 #[must_use]
1277 pub fn from_workspaces<I>(entries: I) -> Self
1278 where
1279 I: IntoIterator<Item = (String, Workspace, bool)>,
1280 {
1281 let entries: BTreeMap<String, WorkspaceEntry> = entries
1282 .into_iter()
1283 .map(|(name, workspace, linked)| {
1284 (
1285 name,
1286 WorkspaceEntry {
1287 workspace: Arc::new(workspace),
1288 linked,
1289 },
1290 )
1291 })
1292 .collect();
1293 let default = (entries.len() == 1)
1294 .then(|| entries.keys().next().cloned())
1295 .flatten();
1296 Self {
1297 inner: std::sync::RwLock::new(SetInner { entries, default }),
1298 }
1299 }
1300
1301 /// Wrap an already-built [`Workspace`] (shared via `Arc`) as a one-entry set
1302 /// under `name`, with `linked` recording whether that workspace is a
1303 /// cross-linked multi-repo group. Used where a single `Workspace` is served as
1304 /// the whole set — e.g. `roteiro serve` merges the read-only graph API over the
1305 /// one workspace it already holds for its model tools and MCP router, so the
1306 /// API's flat routes resolve to it as the sole (default) workspace. The store
1307 /// handles are shared, never re-opened.
1308 #[must_use]
1309 pub fn from_single(name: impl Into<String>, workspace: Arc<Workspace>, linked: bool) -> Self {
1310 let name = name.into();
1311 let mut entries = BTreeMap::new();
1312 entries.insert(name.clone(), WorkspaceEntry { workspace, linked });
1313 Self {
1314 inner: std::sync::RwLock::new(SetInner {
1315 entries,
1316 default: Some(name),
1317 }),
1318 }
1319 }
1320
1321 /// Build a set from normalised config groups: each group's `roots`/`repos` are
1322 /// discovered into member repo paths and opened as [`Workspace`]s. A **linked**
1323 /// group becomes one multi-repo graph. A **standalone** (`linked = false`) group
1324 /// becomes one single-repo graph **per member repo** — the invariant that a
1325 /// standalone workspace is exactly one repo is upheld *here*, by splitting, so a
1326 /// hand-built group can never collapse several repos into one unlinked multi-repo
1327 /// graph (the config normaliser already emits standalone as per-repo singletons,
1328 /// so in practice each such group has exactly one repo and the split is a no-op).
1329 /// On a split, the extra members take a `-2`/`-3` suffix off the group name. A
1330 /// group that resolves to **no** repos is skipped, so a stale root never aborts
1331 /// the whole set.
1332 ///
1333 /// # Errors
1334 /// [`WorkspaceError::Discover`] if a group's root cannot be read, or
1335 /// [`WorkspaceError::Git`] if an explicit repo path is not inside a git repo.
1336 pub fn from_resolved(resolved: Vec<ResolvedWorkspace>) -> Result<Self, WorkspaceError> {
1337 let mut entries: BTreeMap<String, WorkspaceEntry> = BTreeMap::new();
1338 for (name, paths, linked) in discover_groups(resolved)? {
1339 entries.insert(
1340 name,
1341 WorkspaceEntry {
1342 workspace: Arc::new(Workspace::from_repo_paths(&paths)?),
1343 linked,
1344 },
1345 );
1346 }
1347 let default = (entries.len() == 1)
1348 .then(|| entries.keys().next().cloned())
1349 .flatten();
1350 Ok(Self {
1351 inner: std::sync::RwLock::new(SetInner { entries, default }),
1352 })
1353 }
1354
1355 /// Re-discover `resolved` into the set a reload would install, **without
1356 /// touching the live set**. All of the reload's I/O (root scans, git
1357 /// discovery) happens here; [`WorkspaceSet::apply_reload`] is then a swap.
1358 ///
1359 /// A workspace whose **name and linkage** survive the reload keeps its very
1360 /// `Arc<Workspace>` — so its open stores stay warm and any handle already
1361 /// shared out (`workspace_handles`, a scoped tool registry) keeps pointing at
1362 /// the live workspace — and receives a planned [`ReloadPlan`] for its own
1363 /// project registry, which retains warm connections per
1364 /// [`Workspace::apply_reload`]. A workspace that is new, gone, or has flipped
1365 /// between linked and standalone is rebuilt or dropped, because in those
1366 /// cases the name no longer denotes the same thing.
1367 ///
1368 /// Planning reads the current entries; concurrent reloads must be serialised
1369 /// by the caller (the SIGHUP handler holds one lock for the whole reload), or
1370 /// the later plan simply wins.
1371 ///
1372 /// # Errors
1373 /// As [`WorkspaceSet::from_resolved`].
1374 pub fn plan_reload(
1375 &self,
1376 resolved: Vec<ResolvedWorkspace>,
1377 ) -> Result<SetReloadPlan, WorkspaceError> {
1378 let groups = discover_groups(resolved)?;
1379 // Snapshot the current entries (cheap `Arc` clones) and release the lock
1380 // before any further discovery.
1381 let current: BTreeMap<String, WorkspaceEntry> = {
1382 let inner = self.read()?;
1383 inner
1384 .entries
1385 .iter()
1386 .map(|(n, e)| {
1387 (
1388 n.clone(),
1389 WorkspaceEntry {
1390 workspace: e.workspace.clone(),
1391 linked: e.linked,
1392 },
1393 )
1394 })
1395 .collect()
1396 };
1397 let mut entries: Vec<(String, WorkspaceEntry, Option<ReloadPlan>)> = Vec::new();
1398 // Every path this one walk found, kept so a flattened workspace beside
1399 // the set can be planned from the same discovery rather than a second.
1400 let mut repo_paths: Vec<PathBuf> = Vec::new();
1401 for (name, paths, linked) in groups {
1402 repo_paths.extend(paths.iter().cloned());
1403 match current.get(&name) {
1404 Some(existing) if existing.linked == linked => entries.push((
1405 name,
1406 WorkspaceEntry {
1407 workspace: existing.workspace.clone(),
1408 linked,
1409 },
1410 Some(Workspace::plan_reload(&paths)?),
1411 )),
1412 _ => entries.push((
1413 name,
1414 WorkspaceEntry {
1415 workspace: Arc::new(Workspace::from_repo_paths(&paths)?),
1416 linked,
1417 },
1418 None,
1419 )),
1420 }
1421 }
1422 // `from_resolved` collects into a `BTreeMap`, so a duplicated group name
1423 // keeps the last entry; count distinct names the same way here.
1424 let distinct: std::collections::BTreeSet<&String> =
1425 entries.iter().map(|(n, _, _)| n).collect();
1426 let default = (distinct.len() == 1)
1427 .then(|| distinct.into_iter().next().cloned())
1428 .flatten();
1429 Ok(SetReloadPlan {
1430 entries,
1431 default,
1432 repo_paths,
1433 })
1434 }
1435
1436 /// Install a [`SetReloadPlan`], returning the new workspace names in stable
1437 /// order. Does no I/O: each retained workspace's planned registry is swapped
1438 /// in, then the entry map is replaced under one write lock.
1439 ///
1440 /// # Errors
1441 /// [`WorkspaceError::Poisoned`] if a lock was poisoned.
1442 pub fn apply_reload(&self, plan: SetReloadPlan) -> Result<Vec<String>, WorkspaceError> {
1443 let SetReloadPlan {
1444 entries, default, ..
1445 } = plan;
1446 let mut next: BTreeMap<String, WorkspaceEntry> = BTreeMap::new();
1447 for (name, entry, registry) in entries {
1448 if let Some(registry) = registry {
1449 entry.workspace.apply_reload(registry)?;
1450 }
1451 next.insert(name, entry);
1452 }
1453 let names: Vec<String> = next.keys().cloned().collect();
1454 let mut inner = self.inner.write().map_err(|_| WorkspaceError::Poisoned)?;
1455 inner.entries = next;
1456 inner.default = default;
1457 Ok(names)
1458 }
1459
1460 /// Re-discover `resolved` and install it — [`WorkspaceSet::plan_reload`]
1461 /// followed by [`WorkspaceSet::apply_reload`]. Use the halves separately when
1462 /// another registry must be swapped in the same breath.
1463 ///
1464 /// # Errors
1465 /// As [`WorkspaceSet::plan_reload`].
1466 pub fn reload_from_resolved(
1467 &self,
1468 resolved: Vec<ResolvedWorkspace>,
1469 ) -> Result<Vec<String>, WorkspaceError> {
1470 self.apply_reload(self.plan_reload(resolved)?)
1471 }
1472
1473 /// The configured workspace names, in stable order.
1474 #[must_use]
1475 pub fn names(&self) -> Vec<String> {
1476 self.peek().entries.keys().cloned().collect()
1477 }
1478
1479 /// Each configured workspace as a `(name, shared handle)` pair, in stable name
1480 /// order. The `Arc<Workspace>` is the very handle the set holds, so a caller can
1481 /// build a **per-workspace** view — e.g. a tool registry confined to one
1482 /// workspace's projects — over the same lazily-opened stores, never re-opening
1483 /// them. Used by `serve` to scope the workspace-level Ask to the selected
1484 /// workspace (ADR-0008), mirroring how [`WorkspaceSet::select`] scopes the
1485 /// read-only `/v1/graph/workspaces/{ws}/…` routes.
1486 #[must_use]
1487 pub fn workspace_handles(&self) -> Vec<(String, Arc<Workspace>)> {
1488 self.peek()
1489 .entries
1490 .iter()
1491 .map(|(name, entry)| (name.clone(), entry.workspace.clone()))
1492 .collect()
1493 }
1494
1495 /// Whether workspace `name` is linked (`Some(true)`), standalone
1496 /// (`Some(false)`), or unknown (`None`).
1497 #[must_use]
1498 pub fn linked(&self, name: &str) -> Option<bool> {
1499 self.peek().entries.get(name).map(|e| e.linked)
1500 }
1501
1502 /// Select a workspace by `name`, or the default when `name` is `None`.
1503 ///
1504 /// Hands back the shared `Arc` rather than a borrow, because the set is
1505 /// reloadable: a caller that held a reference into the entry map would pin it
1506 /// against the swap. The handle stays valid across a reload — a retained
1507 /// workspace *is* reloaded in place, so a caller reading through it sees the
1508 /// new project set rather than a detached snapshot.
1509 ///
1510 /// # Errors
1511 /// [`WorkspaceError::UnknownWorkspace`] if named but absent,
1512 /// [`WorkspaceError::AmbiguousWorkspace`] if omitted with several configured,
1513 /// or [`WorkspaceError::Empty`] if none are configured.
1514 pub fn select(&self, name: Option<&str>) -> Result<Arc<Workspace>, WorkspaceError> {
1515 // One guard for the lookup *and* the error it may raise. Two reads would
1516 // let a reload land between them, so the `known:` list could name a set
1517 // the lookup never saw — a message that is confidently wrong about the
1518 // very thing the reader is consulting it for. (It also removes a nested
1519 // read-lock acquisition on one thread, which `RwLock` does not promise.)
1520 let inner = self.read()?;
1521 if let Some(n) = name {
1522 return inner
1523 .entries
1524 .get(n)
1525 .map(|e| e.workspace.clone())
1526 .ok_or_else(|| WorkspaceError::UnknownWorkspace {
1527 name: n.to_owned(),
1528 known: keys(&inner.entries),
1529 });
1530 }
1531 // No name given: the sole workspace, else ambiguous / empty.
1532 let name = inner.default.as_ref().ok_or_else(|| {
1533 if inner.entries.is_empty() {
1534 WorkspaceError::Empty
1535 } else {
1536 WorkspaceError::AmbiguousWorkspace {
1537 known: keys(&inner.entries),
1538 }
1539 }
1540 })?;
1541 Ok(inner.entries[name].workspace.clone())
1542 }
1543
1544 /// The **name** of the workspace [`WorkspaceSet::select`] resolves for `name`:
1545 /// the given name when present (and valid), else the sole/default workspace's
1546 /// name. Same resolution and errors as `select`, but returns the concrete name
1547 /// — so a caller (e.g. the `/follow` endpoint) can report which workspace it
1548 /// actually resolved in, even on a flat route where the default was implicit.
1549 ///
1550 /// # Errors
1551 /// As [`WorkspaceSet::select`].
1552 pub fn select_name(&self, name: Option<&str>) -> Result<String, WorkspaceError> {
1553 let inner = self.read()?;
1554 if let Some(n) = name {
1555 return inner
1556 .entries
1557 .get_key_value(n)
1558 .map(|(k, _)| k.clone())
1559 .ok_or_else(|| WorkspaceError::UnknownWorkspace {
1560 name: n.to_owned(),
1561 known: keys(&inner.entries),
1562 });
1563 }
1564 inner.default.clone().ok_or_else(|| {
1565 if inner.entries.is_empty() {
1566 WorkspaceError::Empty
1567 } else {
1568 WorkspaceError::AmbiguousWorkspace {
1569 known: keys(&inner.entries),
1570 }
1571 }
1572 })
1573 }
1574
1575 /// The name of the workspace whose member repos include the repo whose graph is
1576 /// `cwd_repo_db` (`<repo>/.git/roteiro/graph.db`), or `None` if no workspace
1577 /// contains it. Used to default `--workspace-name` to the workspace the current
1578 /// directory belongs to.
1579 #[must_use]
1580 pub fn containing(&self, cwd_repo_db: &Path) -> Option<String> {
1581 // Snapshot the handles first: `member_dbs` takes each workspace's own
1582 // lock, and holding the set's lock across that would nest two locks in an
1583 // order nothing else uses.
1584 self.workspace_handles().into_iter().find_map(|(name, ws)| {
1585 ws.member_dbs()
1586 .iter()
1587 .any(|db| db == cwd_repo_db)
1588 .then_some(name)
1589 })
1590 }
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595 use super::*;
1596 use crate::store::Store;
1597
1598 fn store() -> Store {
1599 Store::open_in_memory().expect("in-memory store")
1600 }
1601
1602 /// A poisoned [`WorkspaceSet`] must still *report* what it holds, and must
1603 /// still *refuse* to resolve one.
1604 ///
1605 /// The split is a decision, not an accident, so it is asserted rather than
1606 /// left to a doc comment. A reader (`names`, `workspace_handles`, `linked`,
1607 /// and through them `containing` and the error messages' `known:` list) reads
1608 /// through the poisoning: returning an empty list instead would render a
1609 /// poisoned set as "no workspaces configured" and blank the `known:` list in
1610 /// the very error someone is reading to find out what broke. A resolver
1611 /// (`select`, `select_name`) still fails, because the one writer replaces
1612 /// `entries` and `default` as two moves and a default resolved against a
1613 /// half-swapped set is silently the wrong workspace.
1614 ///
1615 /// Without this, "simplifying" `peek` back to `unwrap_or_default()` is a
1616 /// green diff.
1617 #[test]
1618 fn a_poisoned_set_still_reports_but_refuses_to_resolve() {
1619 let set = WorkspaceSet::from_workspaces([
1620 ("api".to_owned(), Workspace::single("api", store()), true),
1621 ("web".to_owned(), Workspace::single("web", store()), false),
1622 ]);
1623 // Poison the lock the way a writer panicking mid-swap would.
1624 let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1625 let _guard = set.inner.write().expect("write lock");
1626 panic!("simulated panic while swapping the registry");
1627 }));
1628 assert!(poisoned.is_err(), "the closure must have panicked");
1629 assert!(set.inner.is_poisoned(), "the lock must be poisoned");
1630
1631 // Reports still report.
1632 assert_eq!(set.names(), vec!["api".to_owned(), "web".to_owned()]);
1633 assert_eq!(set.workspace_handles().len(), 2);
1634 assert_eq!(set.linked("api"), Some(true));
1635 assert_eq!(set.linked("web"), Some(false));
1636
1637 // Resolutions still refuse.
1638 assert!(matches!(
1639 set.select(Some("api")).err().expect("select must fail"),
1640 WorkspaceError::Poisoned
1641 ));
1642 assert!(matches!(
1643 set.select_name(None).expect_err("select_name must fail"),
1644 WorkspaceError::Poisoned
1645 ));
1646 }
1647
1648 #[test]
1649 fn single_project_is_the_default_and_resolves_bare() {
1650 let ws = Workspace::single("myrepo", store());
1651 assert_eq!(ws.names(), vec!["myrepo".to_owned()]);
1652 assert!(!ws.is_multi());
1653 // A bare call resolves to the sole project.
1654 assert_eq!(ws.resolve(None).unwrap(), "myrepo");
1655 // Naming it explicitly works too.
1656 assert_eq!(ws.resolve(Some("myrepo")).unwrap(), "myrepo");
1657 // with_store hands over the store.
1658 let n = ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
1659 assert_eq!(n, 0);
1660 }
1661
1662 #[test]
1663 fn from_stores_dedupes_colliding_names() {
1664 // Two stores sharing the base name `repo` must both survive: the second
1665 // is suffixed `repo-2` (like `from_repo_paths`), never dropped.
1666 let ws = Workspace::from_stores([("repo", store()), ("repo", store())]);
1667 let mut names = ws.names();
1668 names.sort();
1669 assert_eq!(names, vec!["repo".to_owned(), "repo-2".to_owned()]);
1670 assert!(ws.is_multi());
1671 }
1672
1673 #[test]
1674 fn unknown_project_is_an_error_naming_the_known_ones() {
1675 let ws = Workspace::single("a", store());
1676 let err = ws.resolve(Some("b")).unwrap_err();
1677 assert!(matches!(err, WorkspaceError::UnknownProject { .. }));
1678 assert!(err.to_string().contains("known: a"));
1679 }
1680
1681 #[test]
1682 fn cached_store_handle_is_reused() {
1683 let ws = Workspace::single("a", store());
1684 // Two accesses return the same underlying handle (cache hit).
1685 ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
1686 let again = ws.handle("a").unwrap();
1687 // The handle is held by both the cache and this local, so ≥ 2.
1688 assert!(Arc::strong_count(&again) >= 2);
1689 }
1690
1691 #[test]
1692 fn parse_qualified_splits_on_the_first_double_colon_only() {
1693 // Bare keys carry single colons; only `::` separates the project.
1694 assert_eq!(
1695 parse_qualified("app::sym:rust:a.rs#B"),
1696 Some(("app", "sym:rust:a.rs#B"))
1697 );
1698 assert_eq!(parse_qualified("app::file:x"), Some(("app", "file:x")));
1699 // Not qualified / malformed.
1700 assert_eq!(parse_qualified("sym:rust:a.rs#B"), None);
1701 assert_eq!(parse_qualified("::x"), None);
1702 assert_eq!(parse_qualified("app::"), None);
1703 }
1704
1705 #[test]
1706 fn resolve_qualified_finds_drift_and_bad_targets() {
1707 use crate::model::{Node, NodeKind};
1708 let mut s = store();
1709 s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
1710 "file:cfg.rs",
1711 NodeKind::File,
1712 "cfg.rs",
1713 )))
1714 .unwrap();
1715 let ws = Workspace::single("app", s);
1716
1717 // Resolves an existing node in the named project.
1718 let hit = ws.resolve_qualified("app::file:cfg.rs").unwrap();
1719 assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
1720 // Well-formed but absent → drift (Ok(None)).
1721 assert!(ws.resolve_qualified("app::file:gone.rs").unwrap().is_none());
1722 // Unknown target project → an error the caller reports as drift.
1723 assert!(matches!(
1724 ws.resolve_qualified("ghost::file:x").unwrap_err(),
1725 WorkspaceError::UnknownProject { .. }
1726 ));
1727 // Not project-qualified at all.
1728 assert!(matches!(
1729 ws.resolve_qualified("file:cfg.rs").unwrap_err(),
1730 WorkspaceError::Unqualified { .. }
1731 ));
1732 }
1733
1734 #[test]
1735 fn follow_external_ref_walks_a_placeholder_to_its_target() {
1736 use crate::links::external_ref_node;
1737 use crate::model::{Node, NodeKind};
1738 let mut s = store();
1739 // A real target node, plus a placeholder standing in for it (as it would
1740 // live in a spoke store pointing back at this project).
1741 s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
1742 "file:cfg.rs",
1743 NodeKind::File,
1744 "cfg.rs",
1745 )))
1746 .unwrap();
1747 let ws = Workspace::single("app", s);
1748
1749 // Following the placeholder resolves the qualified target to the real node.
1750 let placeholder = external_ref_node("app::file:cfg.rs");
1751 let hit = ws.follow_external_ref(&placeholder).unwrap();
1752 assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
1753
1754 // A placeholder for a removed target is drift (Ok(None)), not an error.
1755 let gone = external_ref_node("app::file:gone.rs");
1756 assert!(ws.follow_external_ref(&gone).unwrap().is_none());
1757
1758 // A plain (non-external-ref) node is simply not followed.
1759 let plain = Node::new("file:cfg.rs", NodeKind::File, "cfg.rs");
1760 assert!(ws.follow_external_ref(&plain).unwrap().is_none());
1761 }
1762
1763 // -- follow-the-link hop: config_key → struct bridge ------------------
1764
1765 /// A config-key node as extraction emits it: key `cfgkey:<file>#<dotted>`,
1766 /// name the dotted key, `meta { key, value }`.
1767 fn cfg_node(dotted: &str) -> crate::model::Node {
1768 use crate::model::{Node, NodeKind};
1769 let mut n = Node::new(
1770 format!("cfgkey:config.toml#{dotted}"),
1771 NodeKind::Other("config_key".to_owned()),
1772 dotted,
1773 );
1774 n.meta = serde_json::json!({ "key": dotted, "value": "x" });
1775 n
1776 }
1777
1778 /// A struct node as extraction emits it, carrying its declared field names in
1779 /// `meta.fields` (the bridge's join signal).
1780 fn struct_node(name: &str, fields: &[&str]) -> crate::model::Node {
1781 use crate::model::{Node, NodeKind};
1782 let mut n = Node::new(format!("sym:rust:config.rs#{name}"), NodeKind::Struct, name);
1783 n.meta = serde_json::json!({ "fields": fields });
1784 n
1785 }
1786
1787 /// Build a hub with a `ServeConfig`/`addr` struct field AND its `serve.addr`
1788 /// config key — plus decoys — so the bridge's confidence rules are exercised.
1789 fn bridge_hub() -> Workspace {
1790 use crate::model::FactSet;
1791 let mut s = store();
1792 s.apply_factset(
1793 &FactSet::new()
1794 .with_node(struct_node("ServeConfig", &["addr", "tools", "tls_cert"]))
1795 .with_node(struct_node("ModelsConfig", &["embedding", "generative"]))
1796 .with_node(cfg_node("serve.addr"))
1797 .with_node(cfg_node("serve.tls_cert"))
1798 .with_node(cfg_node("serve.ghost")) // resolves, but no such field
1799 .with_node(cfg_node("mystery.addr")) // no struct for section `mystery`
1800 .with_node(cfg_node("port")), // single-segment: no section
1801 )
1802 .unwrap();
1803 Workspace::single("hub", s)
1804 }
1805
1806 #[test]
1807 fn follow_bridges_config_key_to_its_defining_struct_field() {
1808 let ws = bridge_hub();
1809 // `serve.addr` bridges to the `ServeConfig` struct, field `addr`.
1810 match ws
1811 .follow_definition("hub::cfgkey:config.toml#serve.addr")
1812 .unwrap()
1813 {
1814 Follow::StructField { node, field } => {
1815 assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1816 assert_eq!(field, "addr");
1817 }
1818 other => panic!("expected a struct-field bridge, got {other:?}"),
1819 }
1820 // Separator-insensitive on the leaf: `serve.tls_cert` → field `tls_cert`.
1821 match ws
1822 .follow_definition("hub::cfgkey:config.toml#serve.tls_cert")
1823 .unwrap()
1824 {
1825 Follow::StructField { node, field } => {
1826 assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1827 assert_eq!(field, "tls_cert");
1828 }
1829 other => panic!("expected a struct-field bridge, got {other:?}"),
1830 }
1831 }
1832
1833 #[test]
1834 fn follow_falls_back_to_config_key_when_not_confidently_bridgeable() {
1835 let ws = bridge_hub();
1836 // Section matches a struct, but the struct has no such field → fall back.
1837 let ghost = ws
1838 .follow_definition("hub::cfgkey:config.toml#serve.ghost")
1839 .unwrap();
1840 assert!(
1841 matches!(&ghost, Follow::Node { node } if node.name == "serve.ghost"),
1842 "unmatched field falls back to the config_key node, got {ghost:?}"
1843 );
1844 // No struct maps to section `mystery` → fall back.
1845 let mystery = ws
1846 .follow_definition("hub::cfgkey:config.toml#mystery.addr")
1847 .unwrap();
1848 assert!(matches!(&mystery, Follow::Node { node } if node.name == "mystery.addr"));
1849 // A single-segment key names no section → never bridged.
1850 let port = ws
1851 .follow_definition("hub::cfgkey:config.toml#port")
1852 .unwrap();
1853 assert!(matches!(&port, Follow::Node { node } if node.name == "port"));
1854 }
1855
1856 #[test]
1857 fn follow_does_not_bridge_on_ambiguity() {
1858 use crate::model::FactSet;
1859 // TWO structs both map to section `serve` and both declare `addr` — a
1860 // genuinely ambiguous mapping must fall back, never guess a wrong node.
1861 let mut s = store();
1862 s.apply_factset(
1863 &FactSet::new()
1864 .with_node(struct_node("ServeConfig", &["addr"]))
1865 .with_node(struct_node("Serve", &["addr"])) // also matches `serve`
1866 .with_node(cfg_node("serve.addr")),
1867 )
1868 .unwrap();
1869 let ws = Workspace::single("hub", s);
1870 let out = ws
1871 .follow_definition("hub::cfgkey:config.toml#serve.addr")
1872 .unwrap();
1873 assert!(
1874 matches!(&out, Follow::Node { node } if node.name == "serve.addr"),
1875 "ambiguous (two matching structs) falls back, got {out:?}"
1876 );
1877 }
1878
1879 #[test]
1880 fn follow_narrow_lookup_ignores_unrelated_structs_with_the_same_field() {
1881 use crate::model::FactSet;
1882 // The name-narrowed struct lookup must return exactly what a full scan
1883 // would: an unrelated struct that happens to declare `addr` is NOT the
1884 // `serve` section's struct, so `serve.addr` still bridges only to
1885 // `ServeConfig` — proving the narrowing preserves bridging semantics.
1886 let mut s = store();
1887 s.apply_factset(
1888 &FactSet::new()
1889 .with_node(struct_node("ServeConfig", &["addr"]))
1890 .with_node(struct_node("Unrelated", &["addr"]))
1891 .with_node(struct_node("Widget", &["addr", "size"]))
1892 .with_node(struct_node("ModelsConfig", &["embedding"]))
1893 .with_node(cfg_node("serve.addr")),
1894 )
1895 .unwrap();
1896 let ws = Workspace::single("hub", s);
1897 match ws
1898 .follow_definition("hub::cfgkey:config.toml#serve.addr")
1899 .unwrap()
1900 {
1901 Follow::StructField { node, field } => {
1902 assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
1903 assert_eq!(field, "addr");
1904 }
1905 other => panic!("expected a struct-field bridge to ServeConfig, got {other:?}"),
1906 }
1907 }
1908
1909 #[test]
1910 fn follow_reports_drift_and_passes_through_non_config_targets() {
1911 use crate::model::{FactSet, Node, NodeKind};
1912 let mut s = store();
1913 s.apply_factset(&FactSet::new().with_node(Node::new(
1914 "sym:rust:a.rs#Thing",
1915 NodeKind::Struct,
1916 "Thing",
1917 )))
1918 .unwrap();
1919 let ws = Workspace::single("hub", s);
1920 // A well-formed target whose node is gone → drift.
1921 assert_eq!(
1922 ws.follow_definition("hub::cfgkey:config.toml#gone")
1923 .unwrap(),
1924 Follow::Drift
1925 );
1926 // A spoke pointing straight at a symbol (an authored link, not a config
1927 // key) passes the node through unbridged.
1928 match ws.follow_definition("hub::sym:rust:a.rs#Thing").unwrap() {
1929 Follow::Node { node } => assert_eq!(node.key, "sym:rust:a.rs#Thing"),
1930 other => panic!("expected pass-through, got {other:?}"),
1931 }
1932 }
1933
1934 #[test]
1935 fn workspace_set_select_single_ambiguous_and_unknown() {
1936 // One workspace ⇒ the default; a bare or named select both resolve to it.
1937 let one = WorkspaceSet::from_workspaces([(
1938 "only".to_owned(),
1939 Workspace::single("only", store()),
1940 true,
1941 )]);
1942 assert_eq!(one.names(), vec!["only".to_owned()]);
1943 assert_eq!(one.linked("only"), Some(true));
1944 assert!(one.linked("nope").is_none());
1945 assert!(one.select(None).is_ok());
1946 assert!(one.select(Some("only")).is_ok());
1947 assert!(matches!(
1948 one.select(Some("ghost")),
1949 Err(WorkspaceError::UnknownWorkspace { .. })
1950 ));
1951
1952 // Several workspaces ⇒ a bare select is ambiguous (listing the names), a
1953 // named select works, and an unknown name errors.
1954 let many = WorkspaceSet::from_workspaces([
1955 ("api".to_owned(), Workspace::single("api", store()), true),
1956 ("web".to_owned(), Workspace::single("web", store()), false),
1957 ]);
1958 assert_eq!(many.names(), vec!["api".to_owned(), "web".to_owned()]);
1959 assert_eq!(many.linked("web"), Some(false));
1960 // (`select` yields `&Workspace`, which isn't `Debug`, so match the error
1961 // out rather than `unwrap_err`.)
1962 let Err(err) = many.select(None) else {
1963 panic!("a bare select over several workspaces must be ambiguous");
1964 };
1965 assert!(matches!(err, WorkspaceError::AmbiguousWorkspace { .. }));
1966 assert!(err.to_string().contains("api"));
1967 assert!(err.to_string().contains("web"));
1968 assert!(many.select(Some("web")).is_ok());
1969 assert!(matches!(
1970 many.select(Some("ghost")),
1971 Err(WorkspaceError::UnknownWorkspace { .. })
1972 ));
1973
1974 // No workspaces ⇒ a bare select reports the empty set.
1975 let none = WorkspaceSet::from_workspaces(std::iter::empty());
1976 assert!(matches!(none.select(None), Err(WorkspaceError::Empty)));
1977 }
1978
1979 #[test]
1980 fn workspace_set_containing_finds_the_owning_workspace_by_db_path() {
1981 // Build two workspaces from explicit (name, graph.db) pairs — no git needed
1982 // — so `containing` can match a repo's db against each workspace's members.
1983 let api_db = PathBuf::from("/ws/api/svc/.git/roteiro/graph.db");
1984 let web_db = PathBuf::from("/ws/web/app/.git/roteiro/graph.db");
1985 let set = WorkspaceSet::from_workspaces([
1986 (
1987 "api".to_owned(),
1988 Workspace::from_named_dbs([("svc".to_owned(), api_db.clone())]),
1989 true,
1990 ),
1991 (
1992 "web".to_owned(),
1993 Workspace::from_named_dbs([("app".to_owned(), web_db.clone())]),
1994 false,
1995 ),
1996 ]);
1997 assert_eq!(set.containing(&api_db).as_deref(), Some("api"));
1998 assert_eq!(set.containing(&web_db).as_deref(), Some("web"));
1999 // A db in no workspace matches nothing.
2000 assert_eq!(
2001 set.containing(Path::new("/elsewhere/.git/roteiro/graph.db")),
2002 None
2003 );
2004 }
2005
2006 /// The shallow rule is deliberate; being **invisible** is the defect
2007 /// (issue #580). A scan therefore reports what it walked past, so a caller
2008 /// can say so at the moment the project count surprises somebody.
2009 ///
2010 /// The layout is the one the issue reports: one repo at depth 1 beside
2011 /// organisation directories whose repos are one level further down.
2012 #[test]
2013 fn a_shallow_scan_reports_the_directories_it_walked_past() {
2014 let base = std::env::temp_dir().join(format!("rto-scan-{}", std::process::id()));
2015 std::fs::remove_dir_all(&base).ok();
2016 for dir in ["direct/.git", "orgA/repo1/.git", "orgB/repo2/.git", "empty"] {
2017 std::fs::create_dir_all(base.join(dir)).expect("mkdir");
2018 }
2019 let scan = scan_root(&base).expect("scan");
2020
2021 // Membership is unchanged — this is not a change to the rule.
2022 assert_eq!(scan.repos, vec![base.join("direct")]);
2023 assert_eq!(discover_repos_under(&base).expect("discover"), scan.repos);
2024
2025 // And the three directories it did not descend into are recorded.
2026 assert_eq!(
2027 scan.skipped,
2028 vec![base.join("empty"), base.join("orgA"), base.join("orgB")],
2029 );
2030
2031 // The deeper probe names only the ones that would have yielded a repo,
2032 // so a message built from it is actionable rather than a directory dump.
2033 assert_eq!(
2034 scan.nested_repo_parents(64),
2035 vec![base.join("orgA").as_path(), base.join("orgB").as_path()],
2036 );
2037
2038 // Bounded: the probe costs a `read_dir` per candidate, so a caller can
2039 // cap it. `skipped` is sorted, so `limit` takes a defined prefix.
2040 assert_eq!(
2041 scan.nested_repo_parents(2),
2042 vec![base.join("orgA").as_path()],
2043 "`limit` bounds the directories examined, not the ones reported",
2044 );
2045 assert!(scan.nested_repo_parents(0).is_empty());
2046
2047 std::fs::remove_dir_all(&base).ok();
2048 }
2049
2050 #[test]
2051 fn a_bundle_is_a_closed_frontmatter_declaring_okf_version() {
2052 let base = std::env::temp_dir().join(format!("rto-okfprobe-{}", std::process::id()));
2053 std::fs::remove_dir_all(&base).ok();
2054
2055 let write = |repo: &str, index: &str| {
2056 let dir = base.join(repo).join(super::OKF_BUNDLE_DIR);
2057 std::fs::create_dir_all(&dir).expect("mkdir");
2058 std::fs::write(dir.join("index.md"), index).expect("write");
2059 base.join(repo)
2060 };
2061
2062 let good = write("good", "---\nokf_version: \"0.2\"\n---\n\n# Peer\n");
2063 assert_eq!(
2064 super::okf_bundle_in(&good),
2065 Some(good.join(super::OKF_BUNDLE_DIR))
2066 );
2067
2068 // Windows line endings throughout. Copilot suggested on #711 that the
2069 // closing fence would be missed, since it is written `\r\n---` while the
2070 // search is for `\n---`. It is **not** missed — `\r\n---` contains
2071 // `\n---` — and the `\r` left on the key's line is removed by the
2072 // `trim()` the check already does. Kept as a fixture rather than
2073 // dropped: the claim was plausible, and the next reader deserves the
2074 // answer without having to re-derive it.
2075 let crlf = write(
2076 "crlf",
2077 "---\r\nokf_version: \"0.2\"\r\n---\r\n\r\n# Peer\r\n",
2078 );
2079 assert_eq!(
2080 super::okf_bundle_in(&crlf),
2081 Some(crlf.join(super::OKF_BUNDLE_DIR))
2082 );
2083
2084 // A directory called `okf` proves nothing.
2085 let plain = write("plain", "# Just some notes\n");
2086 assert_eq!(super::okf_bundle_in(&plain), None);
2087
2088 // No closing fence: `okf_version` here is prose under an unterminated
2089 // block, not a declaration. Reported by Copilot on #711 — the earlier
2090 // `split(…).next()` accepted it.
2091 //
2092 // The line must be a *bare* `okf_version:` at the start of a line, not
2093 // prose mentioning it: the reader matches on the key before the first
2094 // colon, so "we should set okf_version: 0.2" never matched anyway and a
2095 // fixture using it proved nothing. This is an `index.md` whose
2096 // frontmatter is unterminated and whose body shows an example block —
2097 // an ordinary thing for a directory documenting the format.
2098 let unterminated = write(
2099 "unterminated",
2100 "---\ntitle: notes\n\nAn example bundle root looks like:\n\nokf_version: \"0.2\"\n",
2101 );
2102 assert_eq!(super::okf_bundle_in(&unterminated), None);
2103
2104 // Frontmatter that closes but declares nothing.
2105 let no_version = write("no-version", "---\ntitle: notes\n---\n\n# Notes\n");
2106 assert_eq!(super::okf_bundle_in(&no_version), None);
2107
2108 // An empty value is not a declaration either.
2109 let empty = write("empty", "---\nokf_version:\n---\n\n# Notes\n");
2110 assert_eq!(super::okf_bundle_in(&empty), None);
2111
2112 // No bundle directory at all.
2113 std::fs::create_dir_all(base.join("none")).expect("mkdir");
2114 assert_eq!(super::okf_bundle_in(&base.join("none")), None);
2115
2116 std::fs::remove_dir_all(&base).ok();
2117 }
2118}