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