rto-graph 2.1.1

Provenance-tagged codebase knowledge graph store for Roteiro. Implementation detail of the roteiro CLI; no API stability guarantee.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
//! A **workspace**: many per-repo graphs served by one process (ADR-0008).
//!
//! Each Roteiro graph is per-repo — a small `SQLite` store at
//! `<repo>/.git/roteiro/graph.db`. The expensive resource a server holds is the
//! *model*, not the graphs, so one process can hold the model once and answer
//! questions about **any** registered repo by opening that repo's store on
//! demand and caching it. A [`Workspace`] is that registry + on-demand,
//! cached store resolver; the tool surfaces (MCP and the `/v1` model server)
//! call [`Workspace::with_store`] with an optional `project` selector.
//!
//! Single-repo serving is just a workspace with one project (see
//! [`Workspace::single`]), so the default `serve` path is unchanged.
//!
//! The registry can be **reloaded** in place ([`Workspace::reload_from`]) so a
//! long-lived server can pick up added/removed repos without a restart (a SIGHUP
//! trigger); already-open stores for still-present projects keep their warm
//! connections, and dropped projects are evicted. An optional first-open hook
//! ([`Workspace::with_on_open`], `serve --sync-on-access`) (re)builds a project's
//! graph the first time it is queried.

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use crate::git::{GitError, Repo};
use crate::model::Node;
use crate::store::{Store, StoreError};

/// A failure resolving or opening a project's graph.
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceError {
    /// A call named a project the workspace does not know.
    #[error("no project named `{name}` (known: {known})")]
    UnknownProject {
        /// The requested name.
        name: String,
        /// Comma-separated list of known project names.
        known: String,
    },
    /// A call omitted `project` but the workspace has no single default (it holds
    /// several projects), so the selection is ambiguous.
    #[error("this server hosts several projects ({known}); name one with `project`")]
    AmbiguousProject {
        /// Comma-separated list of known project names.
        known: String,
    },
    /// The workspace is registered but empty (no repos resolved).
    #[error("no projects registered")]
    Empty,
    /// A selector named a workspace the [`WorkspaceSet`] does not know.
    #[error("no workspace named `{name}` (known: {known})")]
    UnknownWorkspace {
        /// The requested workspace name.
        name: String,
        /// Comma-separated list of known workspace names.
        known: String,
    },
    /// A selection omitted a name but the [`WorkspaceSet`] holds several
    /// workspaces, so the choice is ambiguous.
    #[error("several workspaces configured ({known}); select one with `--workspace-name`")]
    AmbiguousWorkspace {
        /// Comma-separated list of known workspace names.
        known: String,
    },
    /// Reading a workspace root directory during repo discovery failed.
    #[error("reading workspace root `{}`: {msg}", .root.display())]
    Discover {
        /// The root directory that could not be read.
        root: PathBuf,
        /// The underlying I/O error message.
        msg: String,
    },
    /// A cross-repo target was not a project-qualified key (`<project>::<key>`).
    #[error("`{key}` is not a project-qualified key (expected `<project>::<key>`)")]
    Unqualified {
        /// The malformed key.
        key: String,
    },
    /// The project's graph store does not exist yet — its repo has not been
    /// synced (`roteiro sync`).
    #[error("project `{name}` has no graph yet — run `roteiro sync` in {}", .path.display())]
    NoGraph {
        /// The project name.
        name: String,
        /// The repo directory whose graph is missing.
        path: PathBuf,
    },
    /// The on-open hook (`serve --sync-on-access`) failed to prepare a project's
    /// graph before it was first served.
    #[error("failed to prepare project `{name}` on first access: {msg}")]
    Prepare {
        /// The project name.
        name: String,
        /// The hook's error message.
        msg: String,
    },
    /// A store lock was poisoned by a panic in another thread.
    #[error("store lock poisoned")]
    Poisoned,
    /// Discovering the repo for a registered path failed.
    #[error(transparent)]
    Git(#[from] GitError),
    /// Opening the project's store failed.
    #[error(transparent)]
    Store(#[from] StoreError),
}

/// Where a project's store comes from: a `graph.db` to open on demand, or an
/// already-open store (the single-repo default and tests).
#[derive(Clone)]
enum Source {
    /// Open this `graph.db` path on first use, for the repository whose working
    /// tree is rooted at `root`.
    ///
    /// `root` is *carried* rather than derived from `db`, because a
    /// repository's own configuration governs how it is scanned, whoever is
    /// asking ([`Workspace::project_root`]) — and the "repo dir is the store's
    /// grandparent" shortcut is wrong for a **linked worktree**, whose git dir
    /// is `<main>/.git/worktrees/<name>`, not `<repo>/.git`. [`build_registry`]
    /// already holds the true working-tree root, so it is recorded here instead
    /// of guessed later. `None` where the caller supplied only a `graph.db`
    /// path ([`Workspace::from_named_dbs`]).
    Path {
        /// The `graph.db` to open.
        db: PathBuf,
        /// The repository's working-tree root, when known.
        root: Option<PathBuf>,
    },
    /// A pre-opened store, shared directly.
    Open(Arc<Mutex<Store>>),
}

/// The registry plus the open-store cache, behind one lock. Held only briefly —
/// to look up a source or (un)cache a handle — never across a graph query, which
/// runs on the returned per-store `Mutex` after this lock is released.
struct Inner {
    /// Project name → its store source, in stable name order.
    projects: BTreeMap<String, Source>,
    /// The project used when a call omits `project` (the sole project, if there
    /// is exactly one; otherwise `None` and a bare call is ambiguous).
    default: Option<String>,
    /// Opened stores, cached by project name, tagged with the [`Source`] they
    /// were opened from. `Store` is `!Sync` (it holds a rusqlite connection), so
    /// each is behind its own `Mutex`. The tag lets a reload keep a warm
    /// connection only when the project still maps to the *same* source, and
    /// never serve a handle for a repo the name no longer points at.
    cache: HashMap<String, (Source, Arc<Mutex<Store>>)>,
}

/// Whether two sources denote the same store: the same `graph.db` path, or the
/// very same pre-opened handle. The `graph.db` path *is* the store's identity,
/// so the recorded working-tree root does not enter the comparison.
fn source_eq(a: &Source, b: &Source) -> bool {
    match (a, b) {
        (Source::Path { db: x, .. }, Source::Path { db: y, .. }) => x == y,
        (Source::Open(x), Source::Open(y)) => Arc::ptr_eq(x, y),
        _ => false,
    }
}

/// A hook run against a project's `graph.db` path the first time it is opened —
/// used by `serve --sync-on-access` to (re)build a stale or missing graph before
/// it is served (ADR-0008). Returns a human-readable error on failure.
pub type OnOpen = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;

/// A named set of per-repo graphs, each opened on demand and cached. Cheap to
/// hold: the stores are small `SQLite` files opened lazily; the caller (a server)
/// holds the one expensive model. The registry is reloadable in place.
pub struct Workspace {
    inner: Mutex<Inner>,
    /// Optional first-open hook (`serve --sync-on-access`): run against a
    /// project's `graph.db` path before it is opened, to sync it on demand.
    on_open: Option<OnOpen>,
}

impl Workspace {
    /// A single-project workspace over an already-open `store`, named `name`.
    /// This is the single-repo `serve` default and the test constructor; a bare
    /// (no-`project`) call resolves to it. Not reloadable (no repo paths).
    #[must_use]
    pub fn single(name: impl Into<String>, store: Store) -> Self {
        let name = name.into();
        let mut projects = BTreeMap::new();
        projects.insert(name.clone(), Source::Open(Arc::new(Mutex::new(store))));
        Self {
            inner: Mutex::new(Inner {
                projects,
                default: Some(name),
                cache: HashMap::new(),
            }),
            on_open: None,
        }
    }

    /// A workspace over several already-open stores, one per named project — the
    /// in-memory counterpart of [`Workspace::from_repo_paths`] (which opens each
    /// project's `graph.db` from disk lazily). Used for multi-repo serving of
    /// pre-built stores and for tests. With exactly one project it becomes the
    /// default (as [`Workspace::single`]); with several, a bare (no-`project`)
    /// call is ambiguous. Not reloadable (no repo paths).
    #[must_use]
    pub fn from_stores<I, S>(stores: I) -> Self
    where
        I: IntoIterator<Item = (S, Store)>,
        S: Into<String>,
    {
        let mut projects = BTreeMap::new();
        for (name, store) in stores {
            // Dedupe like `from_repo_paths` (`-2`, `-3`, …) so two stores sharing a
            // base name both survive instead of the second silently overwriting the
            // first (which would drop a project).
            let name = dedupe_name(&projects, name.into());
            projects.insert(name, Source::Open(Arc::new(Mutex::new(store))));
        }
        // Mirror `from_repo_paths`: a lone project is the default; several are
        // ambiguous until a call names one.
        let default = if projects.len() == 1 {
            projects.keys().next().cloned()
        } else {
            None
        };
        Self {
            inner: Mutex::new(Inner {
                projects,
                default,
                cache: HashMap::new(),
            }),
            on_open: None,
        }
    }

    /// Build a workspace from repo directories: each is `git`-discovered, named
    /// after its working-tree directory (collisions get a `-2`, `-3`, … suffix),
    /// and its `graph.db` opened lazily. With exactly one repo, that repo is the
    /// default project.
    ///
    /// # Errors
    /// [`WorkspaceError::Git`] if a path is not inside a git repository, or
    /// [`WorkspaceError::Empty`] if `paths` resolves to no repos.
    pub fn from_repo_paths<I, P>(paths: I) -> Result<Self, WorkspaceError>
    where
        I: IntoIterator<Item = P>,
        P: AsRef<Path>,
    {
        let (projects, default) = build_registry(paths)?;
        Ok(Self {
            inner: Mutex::new(Inner {
                projects,
                default,
                cache: HashMap::new(),
            }),
            on_open: None,
        })
    }

    /// Build a workspace from explicit `(project name, graph.db path)` pairs,
    /// **without** git discovery — used where the names and store locations are
    /// already known ([`WorkspaceSet`] construction re-uses the CLI's discovery
    /// upstream, and tests build synthetic registries). Names are taken verbatim
    /// (deduplicate before calling if a collision is possible); with exactly one
    /// pair, that project is the default.
    #[must_use]
    pub fn from_named_dbs<I>(dbs: I) -> Self
    where
        I: IntoIterator<Item = (String, PathBuf)>,
    {
        let projects: BTreeMap<String, Source> = dbs
            .into_iter()
            .map(|(n, db)| (n, Source::Path { db, root: None }))
            .collect();
        let default = (projects.len() == 1)
            .then(|| projects.keys().next().cloned())
            .flatten();
        Self {
            inner: Mutex::new(Inner {
                projects,
                default,
                cache: HashMap::new(),
            }),
            on_open: None,
        }
    }

    /// The `graph.db` paths of the workspace's lazily-opened (`Path`) projects, in
    /// stable name order. Pre-opened (`single`) projects carry no path and are
    /// omitted. Used by [`WorkspaceSet::containing`] to find which workspace holds
    /// a given repo.
    #[must_use]
    pub fn member_dbs(&self) -> Vec<PathBuf> {
        self.lock()
            .map(|i| {
                i.projects
                    .values()
                    .filter_map(|s| match s {
                        Source::Path { db, .. } => Some(db.clone()),
                        Source::Open(_) => None,
                    })
                    .collect()
            })
            .unwrap_or_default()
    }

    /// The **working-tree root** of `project`'s repository, resolving `project`
    /// the same way [`Workspace::with_store`] does (so `None` means the default
    /// project).
    ///
    /// This exists so a caller can read *that repository's own* configuration
    /// rather than the invoking process's. The rule, following ADR-0009's
    /// per-repo `[[links]]` resolution: **a repository's own config governs how
    /// it is scanned, whoever is asking.** Without it, a server started in repo
    /// A answers questions about repo B using A's settings — and B's own
    /// `[debt] ignore` never applies, so the API and B's CLI disagree about B.
    ///
    /// Returns `Ok(None)` when the project's store was handed over pre-opened
    /// ([`Workspace::single`] / [`Workspace::from_stores`]) or registered by
    /// `graph.db` path alone ([`Workspace::from_named_dbs`]): there is no
    /// repository on disk to consult, and the caller falls back to its own
    /// configuration.
    ///
    /// # Errors
    /// [`WorkspaceError::UnknownProject`] / [`WorkspaceError::AmbiguousProject`]
    /// as [`Workspace::resolve`], or [`WorkspaceError::Poisoned`].
    pub fn project_root(&self, project: Option<&str>) -> Result<Option<PathBuf>, WorkspaceError> {
        let name = self.resolve(project)?;
        let inner = self.lock()?;
        Ok(match inner.projects.get(&name) {
            Some(Source::Path { root, .. }) => root.clone(),
            _ => None,
        })
    }

    /// Set a first-open hook (`serve --sync-on-access`): before a project's store
    /// is opened for the first time, `hook` is run against its `graph.db` path to
    /// (re)build it. Applies to lazily-opened `Path` projects; a pre-opened
    /// `single` store is already loaded, so the hook does not fire for it.
    #[must_use]
    pub fn with_on_open(mut self, hook: OnOpen) -> Self {
        self.on_open = Some(hook);
        self
    }

    /// Rebuild the registry from a fresh set of repo `paths`: added repos become
    /// available, removed ones are dropped (and their cached store evicted), and
    /// still-present ones keep their warm connection. Returns the new project
    /// names. Use this to reload a running server (e.g. on SIGHUP) without a
    /// restart. A single-project pre-opened workspace ([`Workspace::single`]) has
    /// no repo paths, so reloading it simply replaces it with the given repos.
    ///
    /// # Errors
    /// As [`Workspace::from_repo_paths`].
    pub fn reload_from<I, P>(&self, paths: I) -> Result<Vec<String>, WorkspaceError>
    where
        I: IntoIterator<Item = P>,
        P: AsRef<Path>,
    {
        // Build the new registry outside the lock (discovery does git I/O).
        let (projects, default) = build_registry(paths)?;
        let names: Vec<String> = projects.keys().cloned().collect();
        let mut inner = self.lock()?;
        // Keep a warm connection only where the project still maps to the *same*
        // source; drop it if the name is gone or now points at a different
        // `graph.db` (or was a pre-opened `single` store), so a query never hits
        // the wrong repo.
        inner
            .cache
            .retain(|name, (src, _)| projects.get(name).is_some_and(|new| source_eq(new, src)));
        inner.projects = projects;
        inner.default = default;
        Ok(names)
    }

    /// The registered project names, in stable order.
    #[must_use]
    pub fn names(&self) -> Vec<String> {
        self.lock()
            .map(|i| i.projects.keys().cloned().collect())
            .unwrap_or_default()
    }

    /// Whether the workspace holds more than one project (so `project` selection
    /// is meaningful to expose to callers/tools).
    #[must_use]
    pub fn is_multi(&self) -> bool {
        self.lock().is_ok_and(|i| i.projects.len() > 1)
    }

    /// Resolve `project` (or the default) to a concrete project name.
    ///
    /// # Errors
    /// [`WorkspaceError::UnknownProject`] if named but absent,
    /// [`WorkspaceError::AmbiguousProject`] if omitted with several projects, or
    /// [`WorkspaceError::Empty`] if there are none.
    pub fn resolve(&self, project: Option<&str>) -> Result<String, WorkspaceError> {
        let inner = self.lock()?;
        match project {
            Some(name) if inner.projects.contains_key(name) => Ok(name.to_owned()),
            Some(name) => Err(WorkspaceError::UnknownProject {
                name: name.to_owned(),
                known: keys(&inner.projects),
            }),
            None => inner.default.clone().ok_or_else(|| {
                if inner.projects.is_empty() {
                    WorkspaceError::Empty
                } else {
                    WorkspaceError::AmbiguousProject {
                        known: keys(&inner.projects),
                    }
                }
            }),
        }
    }

    /// Run `f` with the resolved project's store (opened and cached on first
    /// use). The store lock is held only for `f`, never across an `.await`.
    ///
    /// # Errors
    /// As [`Workspace::resolve`], plus [`WorkspaceError::NoGraph`] if the store
    /// file is absent, [`WorkspaceError::Store`] on open failure, or
    /// [`WorkspaceError::Poisoned`] if a lock was poisoned.
    pub fn with_store<R>(
        &self,
        project: Option<&str>,
        f: impl FnOnce(&Store) -> R,
    ) -> Result<R, WorkspaceError> {
        let name = self.resolve(project)?;
        let handle = self.handle(&name)?;
        let store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
        Ok(f(&store))
    }

    /// Like [`Workspace::with_store`], but hands `f` a **mutable** store so it can
    /// persist into the graph (e.g. [`Store::apply_import_layer`]). The store lock
    /// is held only for `f`, never across an `.await`. Backs the explorer's
    /// `links/write` endpoint, which materialises the inferred cross-repo links into
    /// a spoke's graph as a durable import layer.
    ///
    /// # Errors
    /// As [`Workspace::with_store`].
    pub fn with_store_mut<R>(
        &self,
        project: Option<&str>,
        f: impl FnOnce(&mut Store) -> R,
    ) -> Result<R, WorkspaceError> {
        let name = self.resolve(project)?;
        let handle = self.handle(&name)?;
        let mut store = handle.lock().map_err(|_| WorkspaceError::Poisoned)?;
        Ok(f(&mut store))
    }

    /// Resolve a **project-qualified** key `"<project>::<key>"` to its node across
    /// the workspace, opening the target project on demand (ADR-0009). `Ok(None)`
    /// means the key is well-formed and the project exists but the node does not —
    /// i.e. **cross-repo drift** (a removed or renamed target). Errors distinguish
    /// the other failure modes so a caller can report them precisely:
    /// [`WorkspaceError::Unqualified`] (not in `<project>::<key>` form),
    /// [`WorkspaceError::UnknownProject`] (target repo not in the workspace),
    /// [`WorkspaceError::NoGraph`] (target repo unsynced).
    ///
    /// # Errors
    /// As above, plus [`WorkspaceError::Store`] / [`WorkspaceError::Poisoned`].
    pub fn resolve_qualified(&self, qualified: &str) -> Result<Option<Node>, WorkspaceError> {
        let (project, key) =
            parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
                key: qualified.to_owned(),
            })?;
        let key = key.to_owned();
        self.with_store(Some(project), move |s| s.get_node(&key))?
            .map_err(WorkspaceError::from)
    }

    /// Follow an **external-ref** placeholder node to the real node it stands for,
    /// resolving its project-qualified target across the workspace (ADR-0009). An
    /// external-ref lives in a spoke's store as a local stand-in for a node in the
    /// hub's store (see [`crate::external_ref_node`]); this walks it through to the
    /// hub. `Ok(None)` means either `node` is not an external-ref, or its target no
    /// longer resolves — cross-repo drift (a removed or renamed hub key). Errors
    /// distinguish the other failure modes, as [`Workspace::resolve_qualified`].
    ///
    /// # Errors
    /// As [`Workspace::resolve_qualified`].
    pub fn follow_external_ref(&self, node: &Node) -> Result<Option<Node>, WorkspaceError> {
        match crate::external_ref_target(node) {
            Some(qualified) => self.resolve_qualified(&qualified),
            None => Ok(None),
        }
    }

    /// Follow a **project-qualified** cross-repo target to the most specific
    /// *definition* it names — the follow-the-link hop that turns a click on a
    /// spoke's app-key target into a jump to the hub node that defines it.
    ///
    /// [`Workspace::resolve_qualified`] lands on the raw hub node a spoke points
    /// at, which for a config override is the hub's `config_key` node (e.g.
    /// `cfgkey:config.toml#serve.addr`), *not* the Rust struct that declares the
    /// setting. This method adds the net-new **`config_key` → struct bridge**: when
    /// the resolved node is a config key whose dotted path maps — with confidence —
    /// to exactly one hub struct and one of its named fields, it returns that
    /// struct as the jump target ([`Follow::StructField`], carrying the matched
    /// field name). Otherwise it returns the resolved node unchanged
    /// ([`Follow::Node`]) — a config key we could not bridge, or any non-config
    /// target (e.g. an authored `[[links]]` that already points at a symbol). A
    /// well-formed target whose node is gone is [`Follow::Drift`].
    ///
    /// The bridge is deliberately conservative (see [`bridge_config_key`]): it
    /// fires only on a *unique* match of both an independent section→struct-name
    /// signal and a field-presence signal, so it never jumps to a **wrong** node —
    /// an ambiguous or unmatched key falls back to the config-key node.
    ///
    /// # Errors
    /// As [`Workspace::resolve_qualified`] (a well-formed but unhosted / unsynced
    /// target project still errors; a resolved-but-missing node is `Drift`).
    pub fn follow_definition(&self, qualified: &str) -> Result<Follow, WorkspaceError> {
        let (project, key) =
            parse_qualified(qualified).ok_or_else(|| WorkspaceError::Unqualified {
                key: qualified.to_owned(),
            })?;
        let key = key.to_owned();
        self.with_store(Some(project), move |store| -> Result<Follow, StoreError> {
            let Some(node) = store.get_node(&key)? else {
                return Ok(Follow::Drift);
            };
            // Only a config-key node needs bridging; anything else the spoke points
            // at is already a definition-level target. Compare against the stable
            // token via `as_str()` — no allocation to build a throwaway `NodeKind`.
            if node.kind.as_str() == crate::config_keys::KIND {
                match bridge_config_key(store, &node)? {
                    Some((target, field)) => Ok(Follow::StructField {
                        node: target,
                        field,
                    }),
                    None => Ok(Follow::Node { node }),
                }
            } else {
                Ok(Follow::Node { node })
            }
        })?
        .map_err(WorkspaceError::from)
    }

    /// Lock the inner state, mapping a poisoned lock to [`WorkspaceError::Poisoned`].
    fn lock(&self) -> Result<std::sync::MutexGuard<'_, Inner>, WorkspaceError> {
        self.inner.lock().map_err(|_| WorkspaceError::Poisoned)
    }

    /// Get (opening + caching on first use) the shared store handle for `name`.
    /// Opens `graph.db` **outside** the registry lock so a first-touch open never
    /// blocks other projects' queries.
    fn handle(&self, name: &str) -> Result<Arc<Mutex<Store>>, WorkspaceError> {
        // Fast path and pre-opened sources resolve under a single short lock.
        let (db, root) = {
            let mut inner = self.lock()?;
            if let Some((_, handle)) = inner.cache.get(name) {
                return Ok(handle.clone());
            }
            match inner.projects.get(name) {
                Some(Source::Open(handle)) => {
                    let handle = handle.clone();
                    inner.cache.insert(
                        name.to_owned(),
                        (Source::Open(handle.clone()), handle.clone()),
                    );
                    return Ok(handle);
                }
                Some(Source::Path { db, root }) => (db.clone(), root.clone()),
                None => {
                    return Err(WorkspaceError::UnknownProject {
                        name: name.to_owned(),
                        known: keys(&inner.projects),
                    });
                }
            }
        };
        // `serve --sync-on-access`: (re)build this project's graph before opening
        // it, so a stale or never-synced repo is prepared on first touch. Runs
        // outside the registry lock (it does extraction I/O).
        if let Some(on_open) = &self.on_open {
            on_open(&db).map_err(|msg| WorkspaceError::Prepare {
                name: name.to_owned(),
                msg,
            })?;
        }
        if !db.exists() {
            return Err(WorkspaceError::NoGraph {
                name: name.to_owned(),
                // The repo dir is the store's grandparent (`…/.git/roteiro`).
                path: db
                    .parent()
                    .and_then(Path::parent)
                    .and_then(Path::parent)
                    .unwrap_or(&db)
                    .to_path_buf(),
            });
        }
        let handle = Arc::new(Mutex::new(Store::open(&db)?));
        let opened = Source::Path {
            db: db.clone(),
            root,
        };
        let mut inner = self.lock()?;
        // Another thread may have opened it while we were; prefer the existing.
        if let Some((_, existing)) = inner.cache.get(name) {
            return Ok(existing.clone());
        }
        // Only cache if the registry still maps this name to the DB we opened —
        // a concurrent `reload_from` may have remapped or removed it. If so,
        // return the freshly-opened handle for this call (the caller resolved
        // before the reload) but do not cache a now-stale mapping.
        if inner
            .projects
            .get(name)
            .is_some_and(|current| source_eq(current, &opened))
        {
            inner
                .cache
                .insert(name.to_owned(), (opened, handle.clone()));
        }
        Ok(handle)
    }
}

/// Comma-separated project names (for error messages).
fn keys(projects: &BTreeMap<String, Source>) -> String {
    projects.keys().cloned().collect::<Vec<_>>().join(", ")
}

/// Split a **project-qualified** key `"<project>::<key>"` into `(project, key)`,
/// or `None` if it carries no `::` separator (a bare, within-repo key). A project
/// name never contains `::`; a bare key may itself contain single colons (e.g.
/// `sym:rust:…`), so only the **first** double-colon separates the project
/// (ADR-0009).
#[must_use]
pub fn parse_qualified(key: &str) -> Option<(&str, &str)> {
    key.split_once("::")
        .filter(|(project, bare)| !project.is_empty() && !bare.is_empty())
}

/// The outcome of [`Workspace::follow_definition`]: where a cross-repo follow-hop
/// lands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Follow {
    /// Bridged past a `config_key` node to the hub **struct** that declares the
    /// setting, carrying the specific named field that matched (e.g. the
    /// `ServeConfig` struct for `serve.addr`, `field = "addr"`). The `node` is the
    /// real struct node, so a caller can center it in the hub graph.
    StructField {
        /// The defining struct node (`sym:rust:<file>#<Struct>`).
        node: Node,
        /// The struct field the dotted key resolved to (its declared identifier).
        field: String,
    },
    /// The resolved target node itself, unbridged — a `config_key` we could not map
    /// to a struct with confidence (the safe fallback), or any non-config target a
    /// spoke points straight at.
    Node {
        /// The resolved hub node.
        node: Node,
    },
    /// The target is well-formed but its node is gone — cross-repo drift.
    Drift,
}

/// Bridge a hub **`config_key`** node to the Rust **struct** that declares it, plus
/// the specific field matched — the net-new step behind [`Workspace::follow_definition`].
///
/// The mapping from a dotted config key (`serve.addr`) to a defining Rust field is
/// not recorded anywhere in the graph (the extractor models structs as nodes but
/// not their fields as nodes, and a field's *type* is not captured), so this is a
/// **resolve-time join** over two independent, deterministic signals — and it only
/// bridges when they agree on exactly one struct:
///
/// 1. **section → struct name.** The dotted key's head segment (`serve`) must name
///    the struct: its lower-cased name, with a trailing `Config` stripped, equals
///    the section (`ServeConfig` → `serve`; a bare `Serve` also matches). See
///    [`struct_matches_section`].
/// 2. **field presence.** The struct must actually declare a field whose
///    normalised name equals the key's leaf (`addr`, or `tls_cert` for
///    `serve.tls_cert`) — read from the struct's `meta.fields`. See
///    [`struct_field_matching`].
///
/// Requiring a **unique** `(struct, field)` hit is the correctness rule: a key that
/// matches zero structs (no such section, or the field isn't declared) or more than
/// one (genuinely ambiguous) returns `None`, and the caller falls back to the
/// config-key node rather than risk jumping to a wrong definition.
///
/// Known limits (documented, deliberate): a single-segment key (no section, e.g.
/// `port`) is never bridged; a key nested past one level (`serve.tls.cert` where
/// `tls` is a sub-struct) won't match a flat field and falls back; and a struct
/// whose name doesn't follow the `<Section>Config` convention won't be found. All
/// three degrade to the existing config-key target — never to a wrong one.
fn bridge_config_key(store: &Store, cfg_node: &Node) -> Result<Option<(Node, String)>, StoreError> {
    // The dotted key: authoritative from `meta.key`, falling back to the node name
    // (both are the dotted path in practice — see config-key extraction).
    let dotted = cfg_node
        .meta
        .get("key")
        .and_then(serde_json::Value::as_str)
        .unwrap_or(cfg_node.name.as_str());
    let Some((section, leaf)) = split_section_field(dotted) else {
        return Ok(None);
    };
    let leaf_norm = crate::config_keys::normalize(leaf);
    if leaf_norm.is_empty() {
        return Ok(None);
    }

    // Fetch only the CANDIDATE struct(s) for this section by name, rather than
    // loading and JSON-decoding every `struct` node in the graph on each hop
    // (a latency spike on a large hub). `section_struct_names` yields the exact
    // lower-cased names `struct_matches_section` would accept, so this narrows the
    // scan without changing the bridging semantics; `struct_matches_section` is
    // still applied below as the authoritative check.
    let mut candidates: Vec<Node> = Vec::new();
    for name in section_struct_names(section) {
        candidates.extend(store.nodes_by_kind_named(&crate::NodeKind::Struct, &name)?);
    }

    let mut hits = candidates
        .into_iter()
        .filter(|s| struct_matches_section(&s.name, section))
        .filter_map(|s| struct_field_matching(&s, &leaf_norm).map(|field| (s, field)));

    match (hits.next(), hits.next()) {
        // Exactly one confident match → bridge to it.
        (Some(one), None) => Ok(Some(one)),
        // Zero or ambiguous (>1) → fall back to the config-key node.
        _ => Ok(None),
    }
}

/// Split a dotted config key into `(section, leaf)` on its **first** separator:
/// `serve.addr` → `("serve", "addr")`, `serve.tls_cert` → `("serve", "tls_cert")`.
/// A single-segment key (`port`) has no section to identify a struct by, so it is
/// `None` (never bridged).
fn split_section_field(dotted: &str) -> Option<(&str, &str)> {
    dotted
        .split_once('.')
        .filter(|(section, leaf)| !section.is_empty() && !leaf.is_empty())
}

/// The section's canonical form for name-matching: normalised, separators removed
/// (`serve` → `serve`, `serve_mode` → `servemode`). Empty when the section carries
/// no alphanumerics.
fn section_key(section: &str) -> String {
    crate::config_keys::normalize(section).replace('.', "")
}

/// The lower-cased struct names a config `section` can map to — exactly the names
/// [`struct_matches_section`] accepts: `serve` → `["serve", "serveconfig"]`. Used
/// to fetch just the candidate struct(s) by name instead of scanning them all
/// (kept in lock-step with [`struct_matches_section`], which remains the check).
fn section_struct_names(section: &str) -> Vec<String> {
    let want = section_key(section);
    if want.is_empty() {
        return Vec::new();
    }
    let with_config = format!("{want}config");
    vec![want, with_config]
}

/// Whether a struct `name` is the one a config `section` maps to: its lower-cased
/// name with a trailing `config` stripped equals the section (case- and
/// separator-insensitive). `ServeConfig`/`Serve` both match section `serve`;
/// `ServeSettings` does not (so an unrelated struct is never bridged to).
fn struct_matches_section(name: &str, section: &str) -> bool {
    let lname = name.to_ascii_lowercase();
    let base = lname.strip_suffix("config").unwrap_or(&lname);
    let want = section_key(section);
    !want.is_empty() && base == want
}

/// The struct field whose normalised identifier equals `leaf_norm`, read from the
/// struct node's `meta.fields` (see extraction). Returns the field's original
/// declared name (for display), or `None` when the struct declares no such field.
fn struct_field_matching(struct_node: &Node, leaf_norm: &str) -> Option<String> {
    struct_node
        .meta
        .get("fields")?
        .as_array()?
        .iter()
        .filter_map(serde_json::Value::as_str)
        .find(|field| crate::config_keys::normalize(field) == leaf_norm)
        .map(ToOwned::to_owned)
}

/// Discover repos at `paths` into a `(name → Source, default)` registry: each
/// path is git-discovered, named after its working-tree directory (deduped), and
/// mapped to a lazily-opened `graph.db`. Exactly one repo ⇒ it is the default.
type Registry = (BTreeMap<String, Source>, Option<String>);
fn build_registry<I, P>(paths: I) -> Result<Registry, WorkspaceError>
where
    I: IntoIterator<Item = P>,
    P: AsRef<Path>,
{
    let mut projects: BTreeMap<String, Source> = BTreeMap::new();
    let mut seen_dbs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
    for path in paths {
        let repo = Repo::discover(path.as_ref())?;
        let db = repo.git_dir().join("roteiro").join("graph.db");
        // De-duplicate the same repo reached via different paths (O(1) lookup, so
        // discovery stays linear even on a big workspace and every reload).
        if !seen_dbs.insert(db.clone()) {
            continue;
        }
        let base = repo
            .workdir()
            .and_then(Path::file_name)
            .map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
        let name = dedupe_name(&projects, base);
        projects.insert(
            name,
            Source::Path {
                db,
                // The repository's own root, so its own config can be read later.
                root: repo.workdir().map(Path::to_path_buf),
            },
        );
    }
    if projects.is_empty() {
        return Err(WorkspaceError::Empty);
    }
    let default = if projects.len() == 1 {
        projects.keys().next().cloned()
    } else {
        None
    };
    Ok((projects, default))
}

/// Make `base` unique against the names already in `projects`, appending
/// `-2`, `-3`, … on collision.
fn dedupe_name(projects: &BTreeMap<String, Source>, base: String) -> String {
    if !projects.contains_key(&base) {
        return base;
    }
    let mut n = 2u32;
    loop {
        let candidate = format!("{base}-{n}");
        if !projects.contains_key(&candidate) {
            return candidate;
        }
        n += 1;
    }
}

/// Shallow git-repo discovery under `root`: the root itself if it is a repo, plus
/// each immediate subdirectory that is one, in sorted order. Shallow by design — a
/// code directory holding sibling checkouts is the common case, and a deep scan
/// would be slow and surprising. Shared by the CLI's workspace collection and
/// [`WorkspaceSet`] / config resolution, so the membership rule lives in one place.
///
/// A repo is any directory containing a `.git` entry (a directory in a normal
/// clone, a file in worktrees and submodules), so existence — not `is_dir` — is
/// tested.
///
/// The rule is invisible to whoever passes the root, which is a separate defect
/// from the rule being wrong: see [`RootScan`], and the `--workspace` help text
/// that now says "immediate subdirectories" rather than "under" (issue #580).
///
/// # Errors
/// [`WorkspaceError::Discover`] if `root` cannot be read.
pub fn discover_repos_under(root: &Path) -> Result<Vec<PathBuf>, WorkspaceError> {
    Ok(scan_root(root)?.repos)
}

/// Whether `dir` is a git repository: it holds a `.git` **entry**. A directory in
/// a normal clone, a file in worktrees and submodules — so existence is the test,
/// not `is_dir`.
fn is_repo(dir: &Path) -> bool {
    dir.join(".git").exists()
}

/// What a shallow scan of one root found, **including what it walked past**.
///
/// [`discover_repos_under`] answers the membership question and is what building
/// a workspace uses. This answers the diagnostic one, because the shallow rule is
/// invisible at exactly the moment it matters: a root whose repos all live one
/// level deeper (`~/GIT/<org>/<repo>`, a common layout) yields a near-empty
/// workspace and no error, so the failure presents later as "the graph tools
/// return nothing useful" rather than as a configuration mistake (issue #580).
///
/// The rule itself is deliberate and is not what this changes — see
/// [`discover_repos_under`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RootScan {
    /// The root scanned.
    pub root: PathBuf,
    /// Repos found: the root itself if it is one, plus each immediate
    /// subdirectory that is one, sorted.
    pub repos: Vec<PathBuf>,
    /// Immediate subdirectories that are **not** repos, sorted. A repo nested
    /// inside one of these is not hosted; counting them is free here because the
    /// scan already read the directory, which is why the successful-start note
    /// can report it without a second pass.
    pub skipped: Vec<PathBuf>,
}

impl RootScan {
    /// Which skipped subdirectories hold a repo **directly** beneath them — the
    /// ones a user almost certainly meant to reach.
    ///
    /// Costs one `read_dir` per skipped directory, so it is **bounded** by `limit`
    /// and is for the path where the user is already stuck: a root that yielded
    /// nothing to serve. A successful start reports [`RootScan::skipped`] instead,
    /// which the scan already knows.
    #[must_use]
    pub fn nested_repo_parents(&self, limit: usize) -> Vec<&Path> {
        self.skipped
            .iter()
            .take(limit)
            .filter(|dir| {
                std::fs::read_dir(dir).is_ok_and(|entries| {
                    entries
                        .filter_map(Result::ok)
                        .any(|e| e.path().is_dir() && is_repo(&e.path()))
                })
            })
            .map(PathBuf::as_path)
            .collect()
    }
}

/// The shallow scan behind [`discover_repos_under`], keeping what it skipped.
///
/// # Errors
/// [`WorkspaceError::Discover`] if `root` cannot be read.
pub fn scan_root(root: &Path) -> Result<RootScan, WorkspaceError> {
    let mut repos = Vec::new();
    if is_repo(root) {
        repos.push(root.to_path_buf());
    }
    let entries = std::fs::read_dir(root).map_err(|e| WorkspaceError::Discover {
        root: root.to_path_buf(),
        msg: e.to_string(),
    })?;
    let (mut children, mut skipped): (Vec<PathBuf>, Vec<PathBuf>) = entries
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| p.is_dir())
        .partition(|p| is_repo(p));
    children.sort();
    skipped.sort();
    repos.extend(children);
    Ok(RootScan {
        root: root.to_path_buf(),
        repos,
        skipped,
    })
}

/// A workspace group after config normalisation ([`crate::WorkspaceSet`] input): a
/// name, its member `roots`/`repos` (unexpanded — discovered when the set is
/// built), and whether its repos are cross-**linked** (served as one multi-repo
/// graph) or **standalone** (each its own single-repo graph, no cross-repo links).
///
/// A `linked = false` (standalone) group denotes **exactly one** single-repo graph:
/// the config normaliser emits one such group per discovered repo, and
/// [`WorkspaceSet::from_resolved`] upholds the invariant by materialising a
/// standalone group as a one-repo [`Workspace`] per member — a standalone group can
/// never collapse several repos into one unlinked multi-repo graph.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedWorkspace {
    /// The workspace name (the `--workspace-name` selector).
    pub name: String,
    /// Directories to scan for member repos (as `[workspace] roots`).
    pub roots: Vec<String>,
    /// Explicit member repo paths, in addition to anything under `roots`.
    pub repos: Vec<String>,
    /// `true` ⇒ the repos form one linked graph; `false` ⇒ **standalone**: each
    /// member repo is its own single-repo graph (no cross-repo links).
    pub linked: bool,
}

/// One entry in a [`WorkspaceSet`]: a built [`Workspace`] plus whether its member
/// repos are cross-linked. The workspace is held behind an `Arc` so an
/// already-shared workspace (e.g. the one a `serve` process holds for its model
/// tools and MCP router) can be wrapped into a set without re-opening its stores
/// ([`WorkspaceSet::from_single`]).
struct WorkspaceEntry {
    /// The per-group workspace (one repo for a standalone singleton, several for a
    /// linked group).
    workspace: Arc<Workspace>,
    /// Whether the group's repos are cross-linked.
    linked: bool,
}

/// An install's **many** named workspaces: linked groups (multi-repo graphs) and
/// standalone singletons (one-repo graphs), keyed by name in stable order (ADR-0008
/// multi-workspace). The outer layer over [`Workspace`]: it selects *which*
/// workspace a command operates on, then hands back that `Workspace` to resolve
/// projects within it. Built from normalised config ([`WorkspaceSet::from_resolved`])
/// so the `serve`/`links` selection logic is shared.
pub struct WorkspaceSet {
    /// Workspace name → its entry, in stable (`BTreeMap`) name order.
    entries: BTreeMap<String, WorkspaceEntry>,
    /// The workspace used when a selection omits a name (the sole workspace, if
    /// there is exactly one; otherwise `None` and a bare selection is ambiguous).
    default: Option<String>,
}

impl WorkspaceSet {
    /// Assemble a set from pre-built named workspaces — the shared core of
    /// [`WorkspaceSet::from_resolved`] and the test constructor. With exactly one
    /// entry, that workspace is the default (a bare selection resolves to it).
    #[must_use]
    pub fn from_workspaces<I>(entries: I) -> Self
    where
        I: IntoIterator<Item = (String, Workspace, bool)>,
    {
        let entries: BTreeMap<String, WorkspaceEntry> = entries
            .into_iter()
            .map(|(name, workspace, linked)| {
                (
                    name,
                    WorkspaceEntry {
                        workspace: Arc::new(workspace),
                        linked,
                    },
                )
            })
            .collect();
        let default = (entries.len() == 1)
            .then(|| entries.keys().next().cloned())
            .flatten();
        Self { entries, default }
    }

    /// Wrap an already-built [`Workspace`] (shared via `Arc`) as a one-entry set
    /// under `name`, with `linked` recording whether that workspace is a
    /// cross-linked multi-repo group. Used where a single `Workspace` is served as
    /// the whole set — e.g. `roteiro serve` merges the read-only graph API over the
    /// one workspace it already holds for its model tools and MCP router, so the
    /// API's flat routes resolve to it as the sole (default) workspace. The store
    /// handles are shared, never re-opened.
    #[must_use]
    pub fn from_single(name: impl Into<String>, workspace: Arc<Workspace>, linked: bool) -> Self {
        let name = name.into();
        let mut entries = BTreeMap::new();
        entries.insert(name.clone(), WorkspaceEntry { workspace, linked });
        Self {
            entries,
            default: Some(name),
        }
    }

    /// Build a set from normalised config groups: each group's `roots`/`repos` are
    /// discovered into member repo paths and opened as [`Workspace`]s. A **linked**
    /// group becomes one multi-repo graph. A **standalone** (`linked = false`) group
    /// becomes one single-repo graph **per member repo** — the invariant that a
    /// standalone workspace is exactly one repo is upheld *here*, by splitting, so a
    /// hand-built group can never collapse several repos into one unlinked multi-repo
    /// graph (the config normaliser already emits standalone as per-repo singletons,
    /// so in practice each such group has exactly one repo and the split is a no-op).
    /// On a split, the extra members take a `-2`/`-3` suffix off the group name. A
    /// group that resolves to **no** repos is skipped, so a stale root never aborts
    /// the whole set.
    ///
    /// # Errors
    /// [`WorkspaceError::Discover`] if a group's root cannot be read, or
    /// [`WorkspaceError::Git`] if an explicit repo path is not inside a git repo.
    pub fn from_resolved(resolved: Vec<ResolvedWorkspace>) -> Result<Self, WorkspaceError> {
        let mut entries: BTreeMap<String, WorkspaceEntry> = BTreeMap::new();
        for rw in resolved {
            let mut paths: Vec<PathBuf> = Vec::new();
            for root in &rw.roots {
                paths.extend(discover_repos_under(Path::new(root))?);
            }
            for repo in &rw.repos {
                paths.push(PathBuf::from(repo));
            }
            if paths.is_empty() {
                // A group naming nothing (e.g. a `roots` dir with no repos) is
                // simply absent rather than an error.
                continue;
            }
            if rw.linked {
                let workspace = Workspace::from_repo_paths(&paths)?;
                entries.insert(
                    rw.name.clone(),
                    WorkspaceEntry {
                        workspace: Arc::new(workspace),
                        linked: true,
                    },
                );
            } else {
                // Standalone: one single-repo graph per member, enforcing the
                // `linked = false` ⇒ exactly-one-repo invariant structurally (the
                // config normaliser already emits one repo per group, so this is a
                // no-op split there; it only matters if a group is hand-built).
                for (i, path) in paths.iter().enumerate() {
                    let workspace = Workspace::from_repo_paths([path])?;
                    let name = if i == 0 {
                        rw.name.clone()
                    } else {
                        format!("{}-{}", rw.name, i + 1)
                    };
                    entries.insert(
                        name,
                        WorkspaceEntry {
                            workspace: Arc::new(workspace),
                            linked: false,
                        },
                    );
                }
            }
        }
        let default = (entries.len() == 1)
            .then(|| entries.keys().next().cloned())
            .flatten();
        Ok(Self { entries, default })
    }

    /// The configured workspace names, in stable order.
    #[must_use]
    pub fn names(&self) -> Vec<String> {
        self.entries.keys().cloned().collect()
    }

    /// Each configured workspace as a `(name, shared handle)` pair, in stable name
    /// order. The `Arc<Workspace>` is the very handle the set holds, so a caller can
    /// build a **per-workspace** view — e.g. a tool registry confined to one
    /// workspace's projects — over the same lazily-opened stores, never re-opening
    /// them. Used by `serve` to scope the workspace-level Ask to the selected
    /// workspace (ADR-0008), mirroring how [`WorkspaceSet::select`] scopes the
    /// read-only `/v1/graph/workspaces/{ws}/…` routes.
    #[must_use]
    pub fn workspace_handles(&self) -> Vec<(String, Arc<Workspace>)> {
        self.entries
            .iter()
            .map(|(name, entry)| (name.clone(), entry.workspace.clone()))
            .collect()
    }

    /// Whether workspace `name` is linked (`Some(true)`), standalone
    /// (`Some(false)`), or unknown (`None`).
    #[must_use]
    pub fn linked(&self, name: &str) -> Option<bool> {
        self.entries.get(name).map(|e| e.linked)
    }

    /// Select a workspace by `name`, or the default when `name` is `None`.
    ///
    /// # Errors
    /// [`WorkspaceError::UnknownWorkspace`] if named but absent,
    /// [`WorkspaceError::AmbiguousWorkspace`] if omitted with several configured,
    /// or [`WorkspaceError::Empty`] if none are configured.
    pub fn select(&self, name: Option<&str>) -> Result<&Workspace, WorkspaceError> {
        if let Some(n) = name {
            return self
                .entries
                .get(n)
                .map(|e| e.workspace.as_ref())
                .ok_or_else(|| WorkspaceError::UnknownWorkspace {
                    name: n.to_owned(),
                    known: self.known(),
                });
        }
        // No name given: the sole workspace, else ambiguous / empty.
        let name = self.default.as_ref().ok_or_else(|| {
            if self.entries.is_empty() {
                WorkspaceError::Empty
            } else {
                WorkspaceError::AmbiguousWorkspace {
                    known: self.known(),
                }
            }
        })?;
        Ok(self.entries[name].workspace.as_ref())
    }

    /// The **name** of the workspace [`WorkspaceSet::select`] resolves for `name`:
    /// the given name when present (and valid), else the sole/default workspace's
    /// name. Same resolution and errors as `select`, but returns the concrete name
    /// — so a caller (e.g. the `/follow` endpoint) can report which workspace it
    /// actually resolved in, even on a flat route where the default was implicit.
    ///
    /// # Errors
    /// As [`WorkspaceSet::select`].
    pub fn select_name(&self, name: Option<&str>) -> Result<&str, WorkspaceError> {
        if let Some(n) = name {
            return self
                .entries
                .get_key_value(n)
                .map(|(k, _)| k.as_str())
                .ok_or_else(|| WorkspaceError::UnknownWorkspace {
                    name: n.to_owned(),
                    known: self.known(),
                });
        }
        self.default.as_deref().ok_or_else(|| {
            if self.entries.is_empty() {
                WorkspaceError::Empty
            } else {
                WorkspaceError::AmbiguousWorkspace {
                    known: self.known(),
                }
            }
        })
    }

    /// The name of the workspace whose member repos include the repo whose graph is
    /// `cwd_repo_db` (`<repo>/.git/roteiro/graph.db`), or `None` if no workspace
    /// contains it. Used to default `--workspace-name` to the workspace the current
    /// directory belongs to.
    #[must_use]
    pub fn containing(&self, cwd_repo_db: &Path) -> Option<&str> {
        self.entries.iter().find_map(|(name, e)| {
            e.workspace
                .member_dbs()
                .iter()
                .any(|db| db == cwd_repo_db)
                .then_some(name.as_str())
        })
    }

    /// Comma-separated workspace names (for error messages).
    fn known(&self) -> String {
        self.entries.keys().cloned().collect::<Vec<_>>().join(", ")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::Store;

    fn store() -> Store {
        Store::open_in_memory().expect("in-memory store")
    }

    #[test]
    fn single_project_is_the_default_and_resolves_bare() {
        let ws = Workspace::single("myrepo", store());
        assert_eq!(ws.names(), vec!["myrepo".to_owned()]);
        assert!(!ws.is_multi());
        // A bare call resolves to the sole project.
        assert_eq!(ws.resolve(None).unwrap(), "myrepo");
        // Naming it explicitly works too.
        assert_eq!(ws.resolve(Some("myrepo")).unwrap(), "myrepo");
        // with_store hands over the store.
        let n = ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
        assert_eq!(n, 0);
    }

    #[test]
    fn from_stores_dedupes_colliding_names() {
        // Two stores sharing the base name `repo` must both survive: the second
        // is suffixed `repo-2` (like `from_repo_paths`), never dropped.
        let ws = Workspace::from_stores([("repo", store()), ("repo", store())]);
        let mut names = ws.names();
        names.sort();
        assert_eq!(names, vec!["repo".to_owned(), "repo-2".to_owned()]);
        assert!(ws.is_multi());
    }

    #[test]
    fn unknown_project_is_an_error_naming_the_known_ones() {
        let ws = Workspace::single("a", store());
        let err = ws.resolve(Some("b")).unwrap_err();
        assert!(matches!(err, WorkspaceError::UnknownProject { .. }));
        assert!(err.to_string().contains("known: a"));
    }

    #[test]
    fn cached_store_handle_is_reused() {
        let ws = Workspace::single("a", store());
        // Two accesses return the same underlying handle (cache hit).
        ws.with_store(None, |s| s.node_count().unwrap()).unwrap();
        let again = ws.handle("a").unwrap();
        // The handle is held by both the cache and this local, so ≥ 2.
        assert!(Arc::strong_count(&again) >= 2);
    }

    #[test]
    fn parse_qualified_splits_on_the_first_double_colon_only() {
        // Bare keys carry single colons; only `::` separates the project.
        assert_eq!(
            parse_qualified("app::sym:rust:a.rs#B"),
            Some(("app", "sym:rust:a.rs#B"))
        );
        assert_eq!(parse_qualified("app::file:x"), Some(("app", "file:x")));
        // Not qualified / malformed.
        assert_eq!(parse_qualified("sym:rust:a.rs#B"), None);
        assert_eq!(parse_qualified("::x"), None);
        assert_eq!(parse_qualified("app::"), None);
    }

    #[test]
    fn resolve_qualified_finds_drift_and_bad_targets() {
        use crate::model::{Node, NodeKind};
        let mut s = store();
        s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
            "file:cfg.rs",
            NodeKind::File,
            "cfg.rs",
        )))
        .unwrap();
        let ws = Workspace::single("app", s);

        // Resolves an existing node in the named project.
        let hit = ws.resolve_qualified("app::file:cfg.rs").unwrap();
        assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));
        // Well-formed but absent → drift (Ok(None)).
        assert!(ws.resolve_qualified("app::file:gone.rs").unwrap().is_none());
        // Unknown target project → an error the caller reports as drift.
        assert!(matches!(
            ws.resolve_qualified("ghost::file:x").unwrap_err(),
            WorkspaceError::UnknownProject { .. }
        ));
        // Not project-qualified at all.
        assert!(matches!(
            ws.resolve_qualified("file:cfg.rs").unwrap_err(),
            WorkspaceError::Unqualified { .. }
        ));
    }

    #[test]
    fn follow_external_ref_walks_a_placeholder_to_its_target() {
        use crate::links::external_ref_node;
        use crate::model::{Node, NodeKind};
        let mut s = store();
        // A real target node, plus a placeholder standing in for it (as it would
        // live in a spoke store pointing back at this project).
        s.apply_factset(&crate::model::FactSet::new().with_node(Node::new(
            "file:cfg.rs",
            NodeKind::File,
            "cfg.rs",
        )))
        .unwrap();
        let ws = Workspace::single("app", s);

        // Following the placeholder resolves the qualified target to the real node.
        let placeholder = external_ref_node("app::file:cfg.rs");
        let hit = ws.follow_external_ref(&placeholder).unwrap();
        assert_eq!(hit.map(|n| n.key), Some("file:cfg.rs".to_owned()));

        // A placeholder for a removed target is drift (Ok(None)), not an error.
        let gone = external_ref_node("app::file:gone.rs");
        assert!(ws.follow_external_ref(&gone).unwrap().is_none());

        // A plain (non-external-ref) node is simply not followed.
        let plain = Node::new("file:cfg.rs", NodeKind::File, "cfg.rs");
        assert!(ws.follow_external_ref(&plain).unwrap().is_none());
    }

    // -- follow-the-link hop: config_key → struct bridge ------------------

    /// A config-key node as extraction emits it: key `cfgkey:<file>#<dotted>`,
    /// name the dotted key, `meta { key, value }`.
    fn cfg_node(dotted: &str) -> crate::model::Node {
        use crate::model::{Node, NodeKind};
        let mut n = Node::new(
            format!("cfgkey:config.toml#{dotted}"),
            NodeKind::Other("config_key".to_owned()),
            dotted,
        );
        n.meta = serde_json::json!({ "key": dotted, "value": "x" });
        n
    }

    /// A struct node as extraction emits it, carrying its declared field names in
    /// `meta.fields` (the bridge's join signal).
    fn struct_node(name: &str, fields: &[&str]) -> crate::model::Node {
        use crate::model::{Node, NodeKind};
        let mut n = Node::new(format!("sym:rust:config.rs#{name}"), NodeKind::Struct, name);
        n.meta = serde_json::json!({ "fields": fields });
        n
    }

    /// Build a hub with a `ServeConfig`/`addr` struct field AND its `serve.addr`
    /// config key — plus decoys — so the bridge's confidence rules are exercised.
    fn bridge_hub() -> Workspace {
        use crate::model::FactSet;
        let mut s = store();
        s.apply_factset(
            &FactSet::new()
                .with_node(struct_node("ServeConfig", &["addr", "tools", "tls_cert"]))
                .with_node(struct_node("ModelsConfig", &["embedding", "generative"]))
                .with_node(cfg_node("serve.addr"))
                .with_node(cfg_node("serve.tls_cert"))
                .with_node(cfg_node("serve.ghost")) // resolves, but no such field
                .with_node(cfg_node("mystery.addr")) // no struct for section `mystery`
                .with_node(cfg_node("port")), // single-segment: no section
        )
        .unwrap();
        Workspace::single("hub", s)
    }

    #[test]
    fn follow_bridges_config_key_to_its_defining_struct_field() {
        let ws = bridge_hub();
        // `serve.addr` bridges to the `ServeConfig` struct, field `addr`.
        match ws
            .follow_definition("hub::cfgkey:config.toml#serve.addr")
            .unwrap()
        {
            Follow::StructField { node, field } => {
                assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
                assert_eq!(field, "addr");
            }
            other => panic!("expected a struct-field bridge, got {other:?}"),
        }
        // Separator-insensitive on the leaf: `serve.tls_cert` → field `tls_cert`.
        match ws
            .follow_definition("hub::cfgkey:config.toml#serve.tls_cert")
            .unwrap()
        {
            Follow::StructField { node, field } => {
                assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
                assert_eq!(field, "tls_cert");
            }
            other => panic!("expected a struct-field bridge, got {other:?}"),
        }
    }

    #[test]
    fn follow_falls_back_to_config_key_when_not_confidently_bridgeable() {
        let ws = bridge_hub();
        // Section matches a struct, but the struct has no such field → fall back.
        let ghost = ws
            .follow_definition("hub::cfgkey:config.toml#serve.ghost")
            .unwrap();
        assert!(
            matches!(&ghost, Follow::Node { node } if node.name == "serve.ghost"),
            "unmatched field falls back to the config_key node, got {ghost:?}"
        );
        // No struct maps to section `mystery` → fall back.
        let mystery = ws
            .follow_definition("hub::cfgkey:config.toml#mystery.addr")
            .unwrap();
        assert!(matches!(&mystery, Follow::Node { node } if node.name == "mystery.addr"));
        // A single-segment key names no section → never bridged.
        let port = ws
            .follow_definition("hub::cfgkey:config.toml#port")
            .unwrap();
        assert!(matches!(&port, Follow::Node { node } if node.name == "port"));
    }

    #[test]
    fn follow_does_not_bridge_on_ambiguity() {
        use crate::model::FactSet;
        // TWO structs both map to section `serve` and both declare `addr` — a
        // genuinely ambiguous mapping must fall back, never guess a wrong node.
        let mut s = store();
        s.apply_factset(
            &FactSet::new()
                .with_node(struct_node("ServeConfig", &["addr"]))
                .with_node(struct_node("Serve", &["addr"])) // also matches `serve`
                .with_node(cfg_node("serve.addr")),
        )
        .unwrap();
        let ws = Workspace::single("hub", s);
        let out = ws
            .follow_definition("hub::cfgkey:config.toml#serve.addr")
            .unwrap();
        assert!(
            matches!(&out, Follow::Node { node } if node.name == "serve.addr"),
            "ambiguous (two matching structs) falls back, got {out:?}"
        );
    }

    #[test]
    fn follow_narrow_lookup_ignores_unrelated_structs_with_the_same_field() {
        use crate::model::FactSet;
        // The name-narrowed struct lookup must return exactly what a full scan
        // would: an unrelated struct that happens to declare `addr` is NOT the
        // `serve` section's struct, so `serve.addr` still bridges only to
        // `ServeConfig` — proving the narrowing preserves bridging semantics.
        let mut s = store();
        s.apply_factset(
            &FactSet::new()
                .with_node(struct_node("ServeConfig", &["addr"]))
                .with_node(struct_node("Unrelated", &["addr"]))
                .with_node(struct_node("Widget", &["addr", "size"]))
                .with_node(struct_node("ModelsConfig", &["embedding"]))
                .with_node(cfg_node("serve.addr")),
        )
        .unwrap();
        let ws = Workspace::single("hub", s);
        match ws
            .follow_definition("hub::cfgkey:config.toml#serve.addr")
            .unwrap()
        {
            Follow::StructField { node, field } => {
                assert_eq!(node.key, "sym:rust:config.rs#ServeConfig");
                assert_eq!(field, "addr");
            }
            other => panic!("expected a struct-field bridge to ServeConfig, got {other:?}"),
        }
    }

    #[test]
    fn follow_reports_drift_and_passes_through_non_config_targets() {
        use crate::model::{FactSet, Node, NodeKind};
        let mut s = store();
        s.apply_factset(&FactSet::new().with_node(Node::new(
            "sym:rust:a.rs#Thing",
            NodeKind::Struct,
            "Thing",
        )))
        .unwrap();
        let ws = Workspace::single("hub", s);
        // A well-formed target whose node is gone → drift.
        assert_eq!(
            ws.follow_definition("hub::cfgkey:config.toml#gone")
                .unwrap(),
            Follow::Drift
        );
        // A spoke pointing straight at a symbol (an authored link, not a config
        // key) passes the node through unbridged.
        match ws.follow_definition("hub::sym:rust:a.rs#Thing").unwrap() {
            Follow::Node { node } => assert_eq!(node.key, "sym:rust:a.rs#Thing"),
            other => panic!("expected pass-through, got {other:?}"),
        }
    }

    #[test]
    fn workspace_set_select_single_ambiguous_and_unknown() {
        // One workspace ⇒ the default; a bare or named select both resolve to it.
        let one = WorkspaceSet::from_workspaces([(
            "only".to_owned(),
            Workspace::single("only", store()),
            true,
        )]);
        assert_eq!(one.names(), vec!["only".to_owned()]);
        assert_eq!(one.linked("only"), Some(true));
        assert!(one.linked("nope").is_none());
        assert!(one.select(None).is_ok());
        assert!(one.select(Some("only")).is_ok());
        assert!(matches!(
            one.select(Some("ghost")),
            Err(WorkspaceError::UnknownWorkspace { .. })
        ));

        // Several workspaces ⇒ a bare select is ambiguous (listing the names), a
        // named select works, and an unknown name errors.
        let many = WorkspaceSet::from_workspaces([
            ("api".to_owned(), Workspace::single("api", store()), true),
            ("web".to_owned(), Workspace::single("web", store()), false),
        ]);
        assert_eq!(many.names(), vec!["api".to_owned(), "web".to_owned()]);
        assert_eq!(many.linked("web"), Some(false));
        // (`select` yields `&Workspace`, which isn't `Debug`, so match the error
        // out rather than `unwrap_err`.)
        let Err(err) = many.select(None) else {
            panic!("a bare select over several workspaces must be ambiguous");
        };
        assert!(matches!(err, WorkspaceError::AmbiguousWorkspace { .. }));
        assert!(err.to_string().contains("api"));
        assert!(err.to_string().contains("web"));
        assert!(many.select(Some("web")).is_ok());
        assert!(matches!(
            many.select(Some("ghost")),
            Err(WorkspaceError::UnknownWorkspace { .. })
        ));

        // No workspaces ⇒ a bare select reports the empty set.
        let none = WorkspaceSet::from_workspaces(std::iter::empty());
        assert!(matches!(none.select(None), Err(WorkspaceError::Empty)));
    }

    #[test]
    fn workspace_set_containing_finds_the_owning_workspace_by_db_path() {
        // Build two workspaces from explicit (name, graph.db) pairs — no git needed
        // — so `containing` can match a repo's db against each workspace's members.
        let api_db = PathBuf::from("/ws/api/svc/.git/roteiro/graph.db");
        let web_db = PathBuf::from("/ws/web/app/.git/roteiro/graph.db");
        let set = WorkspaceSet::from_workspaces([
            (
                "api".to_owned(),
                Workspace::from_named_dbs([("svc".to_owned(), api_db.clone())]),
                true,
            ),
            (
                "web".to_owned(),
                Workspace::from_named_dbs([("app".to_owned(), web_db.clone())]),
                false,
            ),
        ]);
        assert_eq!(set.containing(&api_db), Some("api"));
        assert_eq!(set.containing(&web_db), Some("web"));
        // A db in no workspace matches nothing.
        assert_eq!(
            set.containing(Path::new("/elsewhere/.git/roteiro/graph.db")),
            None
        );
    }

    /// The shallow rule is deliberate; being **invisible** is the defect
    /// (issue #580). A scan therefore reports what it walked past, so a caller
    /// can say so at the moment the project count surprises somebody.
    ///
    /// The layout is the one the issue reports: one repo at depth 1 beside
    /// organisation directories whose repos are one level further down.
    #[test]
    fn a_shallow_scan_reports_the_directories_it_walked_past() {
        let base = std::env::temp_dir().join(format!("rto-scan-{}", std::process::id()));
        std::fs::remove_dir_all(&base).ok();
        for dir in ["direct/.git", "orgA/repo1/.git", "orgB/repo2/.git", "empty"] {
            std::fs::create_dir_all(base.join(dir)).expect("mkdir");
        }
        let scan = scan_root(&base).expect("scan");

        // Membership is unchanged — this is not a change to the rule.
        assert_eq!(scan.repos, vec![base.join("direct")]);
        assert_eq!(discover_repos_under(&base).expect("discover"), scan.repos);

        // And the three directories it did not descend into are recorded.
        assert_eq!(
            scan.skipped,
            vec![base.join("empty"), base.join("orgA"), base.join("orgB")],
        );

        // The deeper probe names only the ones that would have yielded a repo,
        // so a message built from it is actionable rather than a directory dump.
        assert_eq!(
            scan.nested_repo_parents(64),
            vec![base.join("orgA").as_path(), base.join("orgB").as_path()],
        );

        // Bounded: the probe costs a `read_dir` per candidate, so a caller can
        // cap it. `skipped` is sorted, so `limit` takes a defined prefix.
        assert_eq!(
            scan.nested_repo_parents(2),
            vec![base.join("orgA").as_path()],
            "`limit` bounds the directories examined, not the ones reported",
        );
        assert!(scan.nested_repo_parents(0).is_empty());

        std::fs::remove_dir_all(&base).ok();
    }
}