semlith 0.46.0

A fast local vector store for AI agents — index files once, keep it current as you save, and answer questions across all of it in milliseconds without leaving the machine.
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
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
//! The store home, and how a command decides which store it means.
//!
//! Before 0.9.0 a store was a `.semlith` directory beside the corpus, and the
//! only way to name one was `--store`. That made every client configuration
//! carry a path, and it put a store one forgotten `.gitignore` line away from
//! being committed into the repository it indexes.
//!
//! From 0.9.0 a new store is created under `~/.semlith/stores/<name>`, and
//! `registry.json` beside it records which roots each store covers. The
//! registry is the single source of truth three things read: `semlith start`
//! opens and watches every store in it, the portal lists them, and a client
//! stanza becomes `semlith mcp` with no arguments.
//!
//! The layout inside a store directory does not change. A store written by
//! 0.8.0 opens unchanged wherever it sits, which is why the resolution order
//! below prefers an existing `./.semlith` over the home: nobody's setup changes
//! until they run `semlith adopt`.
//!
//! The registry is written by semlith and by nothing else. It is state, not
//! configuration — there is no supported way to hand-edit it, and a field it
//! does not recognise is dropped on the next write.

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// Overrides the store home. One greppable path on every platform beats three
/// platform conventions, and anyone who disagrees sets this.
pub const HOME_ENV: &str = "SEMLITH_HOME";

/// The variable [`user_home`] starts from, named so that a test which has to
/// save and restore it does not become a second place that reads it. The
/// invariant is that `var_os("HOME")` appears once in `src/`, inside
/// [`user_home`], and `tests/home.rs` asserts exactly that.
pub const HOME_VAR: &str = "HOME";

/// One lock for every test that touches a home directory variable.
///
/// `HOME` and `SEMLITH_HOME` are process-wide, and `cargo test` runs the whole
/// crate's tests on one thread pool, so a test that repoints either of them
/// repoints it for every other test running at that moment. Living in a
/// separate module does not serialise anything — `setup.rs` carried a comment
/// saying it did, and the test it guarded was repointing the real `HOME` out
/// from under `service::remove`, which resolves the login service's path
/// through it. That is how `removing_a_service_that_is_not_installed_is_not_
/// an_error` failed in a full run and passed on its own.
///
/// Every test that *sets* one of these takes this lock, and every test that
/// *reads* one and would be confused by a different answer takes it too.
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Run `body` with one environment variable set, and put it back afterwards.
///
/// Holds [`ENV_LOCK`] for the whole of it. The guard is taken with
/// `unwrap_or_else(into_inner)` so one panicking test does not poison every
/// later one into failing for a reason that is not theirs.
#[cfg(test)]
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn with_env_vars<T>(vars: &[(&str, &std::ffi::OsStr)], body: impl FnOnce() -> T) -> T {
    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let was: Vec<_> = vars
        .iter()
        .map(|(name, _)| std::env::var_os(name))
        .collect();
    for (name, value) in vars {
        // SAFETY: the lock above makes this the only thread touching them.
        unsafe { std::env::set_var(name, value) };
    }
    let out = body();
    for ((name, _), previous) in vars.iter().zip(was) {
        match previous {
            Some(previous) => unsafe { std::env::set_var(name, previous) },
            None => unsafe { std::env::remove_var(name) },
        }
    }
    out
}

#[cfg(test)]
pub(crate) fn with_env_var<T>(name: &str, value: &std::ffi::OsStr, body: impl FnOnce() -> T) -> T {
    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let was = std::env::var_os(name);
    // SAFETY: the lock above makes this the only thread touching the
    // variable, and every test that reads it holds the same lock.
    unsafe { std::env::set_var(name, value) };
    let out = body();
    match was {
        Some(previous) => unsafe { std::env::set_var(name, previous) },
        None => unsafe { std::env::remove_var(name) },
    }
    out
}

/// The user's home directory, per platform. The only place in `src/` that
/// reads `HOME`.
///
/// `HOME` when it is set and non-empty, which covers unix and every Windows
/// shell that sets it (Git Bash, MSYS, WSL). On Windows nothing sets it by
/// default, so `USERPROFILE` comes next — it is what `install.ps1` used to
/// place the binary and what PowerShell derives `$HOME` from, so matching it
/// is what makes the installer and the runtime agree — and then the
/// `HOMEDRIVE` + `HOMEPATH` pair that predates it. Nothing else: a resolution
/// order with a default at the end is the bug this function exists to remove
/// (#70, #73).
///
/// There is deliberately no variant of this that returns a path when it does
/// not know one. Every caller takes the `Result`, so a machine with no home
/// gets an error naming the variables rather than a store, a registry and an
/// agent key written into whatever directory the process started in.
pub fn user_home() -> Result<PathBuf> {
    if let Some(dir) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
        return Ok(PathBuf::from(dir));
    }
    #[cfg(windows)]
    {
        if let Some(dir) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) {
            return Ok(PathBuf::from(dir));
        }
        let drive = std::env::var_os("HOMEDRIVE").filter(|v| !v.is_empty());
        let path = std::env::var_os("HOMEPATH").filter(|v| !v.is_empty());
        if let (Some(mut drive), Some(path)) = (drive, path) {
            drive.push(path);
            return Ok(PathBuf::from(drive));
        }
        bail!(
            "none of HOME, USERPROFILE or HOMEDRIVE+HOMEPATH is set, so semlith does \
             not know where your home directory is. Set {HOME_ENV} to the directory \
             you want its stores in."
        );
    }
    #[cfg(not(windows))]
    bail!(
        "HOME is not set, so semlith does not know where your home directory is. \
         Set {HOME_ENV} to the directory you want its stores in."
    )
}

/// The store home: [`HOME_ENV`] when set, else `<home>/.semlith`.
///
/// With neither available this is an error rather than `./.semlith`. A semlith
/// run by a daemon supervisor, a cron job, a container entrypoint or a Windows
/// shell with no environment used to create a store, a registry and an agent
/// key in whatever directory it happened to start in, and say nothing.
pub fn home_or_error() -> Result<PathBuf> {
    if let Some(dir) = std::env::var_os(HOME_ENV).filter(|v| !v.is_empty()) {
        return Ok(PathBuf::from(dir));
    }
    Ok(user_home()?.join(".semlith"))
}

/// The directory holding one subdirectory per store.
pub fn stores_root() -> Result<PathBuf> {
    Ok(home_or_error()?.join("stores"))
}

/// Where the install scripts put the binary, and where `semlith setup` looks
/// for it. Inside the home rather than beside it so that `SEMLITH_HOME` moves
/// one directory and not two, and so an uninstall is one `rm -rf`.
pub fn bin_dir() -> Result<PathBuf> {
    Ok(home_or_error()?.join("bin"))
}

/// The registry file. Model weights deliberately do not live under the home:
/// a cache is deletable and a store is not, so weights stay in
/// `~/.cache/semlith/models`.
pub fn registry_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("registry.json"))
}

/// Where the three values the Index page can change are kept.
///
/// Beside `registry.json` and not inside it: the registry is what stores exist
/// and where, and these are how hard this machine may work. `daemon.json` is
/// not the place either — that is per-store discovery, written beside a lock
/// and deleted when the daemon exits, and these outlive a run.
///
/// It is not a user-editable config file, which AGENTS.md says this project
/// does not have. It is tool-written state, like the registry: semlith writes
/// it when somebody moves a field on the page, and nothing documents a way to
/// hand-edit it.
pub fn settings_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("settings.json"))
}

/// The price table `semlith prices update` downloaded, when it has been run.
/// Tool-written state like `settings.json`; delete it to go back to the
/// table built into the binary.
/// What each embedding lane has managed on this machine, in chunks per
/// second: what an estimate before a run is taken from.
pub fn lane_rates_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("lane-rates.json"))
}

pub fn prices_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("prices.json"))
}

/// Where the schedules the daemon runs are kept.
///
/// Beside `registry.json` and deliberately not inside it. The registry is what
/// stores exist and where — lose it and a machine forgets every corpus it has
/// indexed. A schedule is a standing request for a report, and a schedules file
/// that has been truncated, hand-edited or written by a newer binary must be
/// able to fail on its own: folded into the registry, one unparseable schedule
/// would cost the user every store on the machine.
///
/// Tool-written state like the registry and `settings.json`, not a user-editable
/// config file, which AGENTS.md says this project does not have.
pub fn schedules_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("schedules.json"))
}

/// The projects directly under a directory: the children that are git
/// repositories, or its plain subdirectories where none of them is.
///
/// One level only. A monorepo is one store, and its nested repositories are
/// its own business — discovery that walked down would turn one corpus into
/// forty stores that each know a fortieth of it.
///
/// A `.git` file counts as much as a `.git` directory: that is what a worktree
/// and a submodule have, and both are things a developer would tick.
///
/// One implementation, called by the portal's `/api/projects` and by `semlith
/// index --projects`, so the checklist and the terminal cannot disagree about
/// what is under a folder.
pub fn projects_under(dir: &Path) -> Result<(Vec<PathBuf>, bool)> {
    let listing = std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))?;
    let mut repositories = Vec::new();
    let mut plain = Vec::new();
    for entry in listing.flatten() {
        if entry.file_name().to_string_lossy().starts_with('.') {
            continue;
        }
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let path = crate::canonical(&entry.path());
        if path.join(".git").exists() {
            repositories.push(path);
        } else {
            plain.push(path);
        }
    }
    repositories.sort();
    plain.sort();
    let found = !repositories.is_empty();
    Ok((if found { repositories } else { plain }, found))
}

/// The three values, as the file holds them.
///
/// Every field is optional and absent means "derive it". A home with no file
/// at all — which is every home until somebody moves a field — derives all
/// three, so the file's absence is a state rather than a missing default.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
    pub runs_at_once: Option<usize>,
    pub embed_threads: Option<usize>,
    pub index_memory_mb: Option<usize>,
    /// The most of this machine's CPU semlith's own process may use while it
    /// indexes, 0-100 %. Absent means 100, no cap. See [`crate::cpucap`].
    #[serde(default)]
    pub cpu_cap_percent: Option<u8>,
    /// Whether the ledger's Session replay tab may read this machine's agent
    /// transcripts. Absent means on from 0.35.0 (the panel is the ledger's
    /// answer to "was it enough", and off by default meant nobody saw it). A
    /// file that wrote `false` stays off. Read it
    /// through [`Settings::replay_on`], never with an `unwrap_or` of its own.
    #[serde(default)]
    pub session_replay: Option<bool>,
    /// The portal's Pause recording switch: no ledger row from this daemon
    /// while it is `true`. Absent means recording, as it always has.
    #[serde(default)]
    pub ledger_paused: Option<bool>,
    /// The Privacy page's airgap switch, the runtime twin of `--airgap`.
    /// Absent means off.
    #[serde(default)]
    pub airgap: Option<bool>,
    /// Whether the ledger may read each AI client's own session log for the
    /// model, tokens and cost of the calls it made. Absent means off, for the
    /// same reason as `session_replay`.
    #[serde(default)]
    pub ledger_usage: Option<bool>,
    /// The lane switches. Absent means CPU, GPU and the Neural Engine on and
    /// the experimental lanes off.
    #[serde(default)]
    pub accelerators: crate::accel::Switches,
    /// The WebGPU adapter to prefer, by a substring of its name. Absent means
    /// semlith's own choice: a discrete GPU over an integrated one.
    #[serde(default)]
    pub gpu_adapter: Option<String>,
    /// The machine-wide vector cache's cap in megabytes; 0 turns it off.
    /// Absent means [`crate::cache::DEFAULT_CAP_MB`].
    #[serde(default)]
    pub vector_cache_mb: Option<u64>,
    /// Days of symbol history a compaction keeps; 0 keeps all of it. Absent
    /// means [`crate::compact::RETENTION_DAYS`].
    pub history_retention_days: Option<u64>,
    /// The reclaimable share, in percent, past which the daemon compacts an
    /// idle store; 0 turns it off. Absent means
    /// [`crate::compact::AUTO_THRESHOLD_PERCENT`].
    pub compact_threshold_percent: Option<u64>,
}

impl Settings {
    /// Whether Session replay may read transcripts: on unless turned off.
    pub fn replay_on(&self) -> bool {
        self.session_replay.unwrap_or(true)
    }

    /// What the file says, or nothing at all.
    ///
    /// A file that cannot be read or parsed is the same answer as no file:
    /// these are three numbers with derivable defaults, and failing a daemon
    /// start over them would be refusing to work because of a preference.
    pub fn load() -> Self {
        let Ok(path) = settings_path() else {
            return Self::default();
        };
        std::fs::read_to_string(path)
            .ok()
            .and_then(|text| serde_json::from_str(&text).ok())
            .unwrap_or_default()
    }

    /// Write the file, creating the home if it is not there.
    ///
    /// The same temporary-file-and-rename the registry uses, for the same
    /// reason: a process killed mid-write leaves the previous settings rather
    /// than half of a new set.
    pub fn save(&self) -> Result<()> {
        let path = settings_path()?;
        let dir = path.parent().unwrap_or(Path::new("."));
        secure_dir(dir).with_context(|| format!("creating the store home {}", dir.display()))?;
        let temp = path.with_extension(unique_temp_suffix());
        let body = serde_json::to_string_pretty(self)? + "\n";
        write_private(&temp, body.as_bytes())
            .with_context(|| format!("writing {}", temp.display()))?;
        if let Err(e) = std::fs::rename(&temp, &path) {
            let _ = std::fs::remove_file(&temp);
            return Err(e).with_context(|| format!("writing {}", path.display()));
        }
        Ok(())
    }
}

/// A temporary file suffix no other writer in this process or any other is
/// using. The pid alone was shared by every thread of a daemon, so two routes
/// saving at once wrote one temporary file and renamed each other's half
/// (issue #132).
pub(crate) fn unique_temp_suffix() -> String {
    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    format!(
        "json.{}.{}.new",
        std::process::id(),
        NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
    )
}

/// Where the agent key lives.
///
/// Under the home rather than inside a store: it authorises the daemon's MCP
/// endpoint, which serves every store the daemon opened, and a credential that
/// moved when a store was adopted would be a credential every client had to be
/// told about again.
pub fn agent_key_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("agent.key"))
}

/// The prefix every agent key carries, so one is recognisable in a config file
/// somebody is looking at six months later.
pub const AGENT_KEY_PREFIX: &str = "sml_";

/// The agent key, created on first read and never regenerated implicitly.
///
/// This is the whole point of the credential: a client's configuration is
/// written once and stays valid across daemon restarts, upgrades and portal
/// token rotations. A key that changed per run would make every stanza stale
/// on every restart, with nothing able to repair the ones semlith did not
/// write — `setup.rs` already records that only Claude Code's config is safe
/// to write to.
pub fn agent_key() -> Result<String> {
    let path = agent_key_path()?;
    if path.exists() {
        let key = std::fs::read_to_string(&path)
            .with_context(|| format!("reading the agent key at {}", path.display()))?
            .trim()
            .to_string();
        check_key_mode(&path)?;
        if !is_agent_key(&key) {
            bail!(
                "{} does not hold an agent key. Delete it and start again, and a new key is                  written; every client then needs the new stanza.",
                path.display()
            );
        }
        return Ok(key);
    }
    let key = new_agent_key();
    write_agent_key(&key)?;
    Ok(key)
}

/// Mint a new key and replace the file, returning the new key.
///
/// Deliberately separate from [`agent_key`]: a key is only ever replaced
/// because somebody asked for it to be, and a function that could do either
/// depending on the state of the disk is one that rotates by accident.
pub fn rotate_agent_key() -> Result<String> {
    let key = new_agent_key();
    write_agent_key(&key)?;
    Ok(key)
}

/// The machine's key for the digests a redacted report prints in place of a
/// query, created on first use and never rotated implicitly.
///
/// A digest is only a redaction if nobody holding the report can recompute it:
/// 0.41.0 printed an unkeyed blake3, and `detect_binary` came back from it by
/// hashing the corpus's own symbol names (R7). keyscan's salt is the same idea
/// but lives in each store, and a report a read-only connection writes can
/// neither make one nor give one query the same digest in two stores, so the
/// report's key is the machine's, beside the agent key, 0600.
pub fn report_salt() -> Result<[u8; 32]> {
    let path = home_or_error()?.join("report.salt");
    let read = |path: &Path| -> Option<[u8; 32]> {
        let text = std::fs::read_to_string(path).ok()?;
        let text = text.trim();
        let mut out = [0u8; 32];
        if text.len() != 64 {
            return None;
        }
        for (i, byte) in out.iter_mut().enumerate() {
            *byte = u8::from_str_radix(text.get(2 * i..2 * i + 2)?, 16).ok()?;
        }
        Some(out)
    };
    if let Some(salt) = read(&path) {
        return Ok(salt);
    }
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).map_err(|e| anyhow::anyhow!("no random source: {e}"))?;
    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
    if let Some(parent) = path.parent() {
        secure_dir(parent)?;
    }
    // `create_new`, so two processes making it at once agree on the first one
    // written rather than each keeping a key the other overwrote.
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(FILE_MODE);
    }
    match options.open(&path) {
        Ok(mut file) => {
            use std::io::Write;
            file.write_all(hex.as_bytes())
                .with_context(|| format!("writing {}", path.display()))?;
            Ok(bytes)
        }
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
            read(&path).with_context(|| format!("{} does not hold a report key", path.display()))
        }
        Err(e) => Err(e).with_context(|| format!("writing {}", path.display())),
    }
}

/// Where the agent key's scope is kept: `read` or `write`, beside the key.
pub fn agent_scope_path() -> Result<PathBuf> {
    Ok(home_or_error()?.join("agent.scope"))
}

/// Whether an agent may call the tools that change a store: index, add and
/// forget.
///
/// Read-only unless the owner said otherwise. A key is a file on disk that a
/// client hands to whatever model it runs, and a model can be told what to do
/// by any page or file it reads; before 0.40.0 that key could start runs,
/// remove files from the index and send data out through `semlith_add`'s URL.
/// No file, an unreadable one and anything but `write` all read as `read`, so
/// every key that existed before 0.40.0 is read-only from its first start.
pub fn agent_may_write() -> bool {
    agent_scope_path()
        .ok()
        .and_then(|p| std::fs::read_to_string(p).ok())
        .is_some_and(|s| s.trim() == "write")
}

/// Set the agent key's scope.
pub fn set_agent_scope(write: bool) -> Result<()> {
    let path = agent_scope_path()?;
    if let Some(parent) = path.parent() {
        secure_dir(parent)?;
    }
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options
        .open(&path)
        .with_context(|| format!("writing the agent scope to {}", path.display()))?;
    use std::io::Write;
    writeln!(file, "{}", if write { "write" } else { "read" })?;
    Ok(())
}

pub fn is_agent_key(value: &str) -> bool {
    let Some(body) = value.strip_prefix(AGENT_KEY_PREFIX) else {
        return false;
    };
    body.len() == 64 && body.bytes().all(|b| b.is_ascii_hexdigit())
}

fn new_agent_key() -> String {
    // The OS random source, not a hash of the clock and a pointer: this one is
    // written to disk and lives for as long as the install does, so it has to
    // be unguessable rather than merely unique. `getrandom` is already in the
    // tree under fastembed; naming it adds no crate to the build.
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).expect("the OS random source");
    let mut out = String::with_capacity(AGENT_KEY_PREFIX.len() + 64);
    out.push_str(AGENT_KEY_PREFIX);
    for byte in bytes {
        use std::fmt::Write;
        let _ = write!(out, "{byte:02x}");
    }
    out
}

fn write_agent_key(key: &str) -> Result<()> {
    let path = agent_key_path()?;
    if let Some(parent) = path.parent() {
        secure_dir(parent)?;
    }
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    // The mode is set as the file is created rather than afterwards: a file
    // that is world-readable for the microsecond between the two is still a
    // file that was world-readable.
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options
        .open(&path)
        .with_context(|| format!("writing the agent key to {}", path.display()))?;
    use std::io::Write;
    writeln!(file, "{key}")?;
    Ok(())
}

/// Refuse a key file anyone else on the machine can read.
///
/// On Windows there is no mode to check: the home sits inside the user's
/// profile, whose default ACL grants that user alone, and semlith does not
/// carry an API to read or write an ACL. That is stated rather than silently
/// assumed.
#[cfg(unix)]
fn check_key_mode(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
    if mode & 0o077 != 0 {
        bail!(
            "{} is mode {:o}; an agent key that other users on this machine can read is not one.              Run `chmod 600 {}` and start again.",
            path.display(),
            mode,
            path.display()
        );
    }
    Ok(())
}

#[cfg(not(unix))]
fn check_key_mode(_path: &Path) -> Result<()> {
    Ok(())
}

/// What a store directory is called when it sits beside its corpus.
pub const LOCAL_DIR: &str = ".semlith";

/// The mode every directory semlith creates gets, and keeps.
pub const DIR_MODE: u32 = 0o700;

/// The mode every file semlith creates that is worth reading gets.
pub const FILE_MODE: u32 = 0o600;

/// Create a directory nobody else on the machine can read, and keep it that way.
///
/// A store holds the text of every file it indexed. On a shared machine — a
/// build box, a lab workstation, a container with more than one account — a
/// directory created with the process umask is usually `0755`, so the corpus
/// was readable by everyone. The tightening is applied on every open rather
/// than only at creation, because a store made by an older semlith is already
/// loose and its owner will never think to fix it by hand.
///
/// Only ever narrows. A directory somebody has deliberately opened up is not
/// something this widens back, and nothing here touches a directory semlith did
/// not make.
pub fn secure_dir(path: &Path) -> Result<()> {
    std::fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
    tighten_dir(path);
    Ok(())
}

/// Narrow a directory to [`DIR_MODE`] if it is looser. Best effort: a
/// filesystem without modes is not a reason to fail to open a store.
pub fn tighten_dir(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = std::fs::metadata(path) {
            let mode = meta.permissions().mode() & 0o777;
            if mode & 0o077 != 0 {
                let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(DIR_MODE));
            }
        }
    }
    #[cfg(not(unix))]
    let _ = path;
}

/// The same for a file.
pub fn tighten_file(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = std::fs::metadata(path) {
            let mode = meta.permissions().mode() & 0o777;
            if mode & 0o077 != 0 {
                let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE));
            }
        }
    }
    #[cfg(not(unix))]
    let _ = path;
}

/// Write a file nobody else on the machine can read.
///
/// The mode is set as the file is created rather than afterwards: a file that
/// is world-readable for the microsecond between the two is a file that was
/// world-readable. The registry names every store on the machine and the roots
/// each one covers, which is a map of what this user works on.
pub fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(FILE_MODE);
    }
    let mut file = options.open(path)?;
    use std::io::Write;
    file.write_all(bytes)?;
    // An existing file keeps its own mode through `truncate`, so one left loose
    // by an older semlith is narrowed here too.
    tighten_file(path);
    Ok(())
}

/// Whether this directory is readable by anyone but its owner.
///
/// What `semlith stats` and the Stores page report, so a store that was made
/// before 0.14.0 and has not been opened since says so rather than looking
/// like every other row.
pub fn loose_mode(path: &Path) -> Option<u32> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = std::fs::metadata(path).ok()?.permissions().mode() & 0o777;
        (mode & 0o077 != 0).then_some(mode)
    }
    #[cfg(not(unix))]
    {
        let _ = path;
        None
    }
}

/// One store's entry in the registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
    /// Every directory this store indexes, canonical. `semlith start` watches
    /// all of them; a root that no longer exists is reported, never fatal.
    pub roots: Vec<PathBuf>,
    /// The embedding model the store was built with, recorded so the portal
    /// and `semlith mcp` can say it without opening the store.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Unix seconds. Only ever set when the entry is created.
    pub created: u64,
    /// The Store settings panel's switches, flattened into the entry so an
    /// older binary reads the same object and ignores the keys it does not
    /// know. Every default is what a store did before the key existed.
    #[serde(flatten)]
    pub settings: StoreSettings,
}

/// What one store is for and how it is kept (0.35.0). Strings rather than
/// enums for `kind` and `lean` so a value a newer binary writes still loads;
/// the route that writes them is what refuses an unknown one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct StoreSettings {
    /// `code`, `docs` or `both`: what the person said the store holds.
    pub kind: String,
    /// `code`, `docs` or `either`: the `prefer` a search or brief scoped to
    /// this store takes when the caller sent none. `either` is no bias.
    pub lean: String,
    /// Whether filesystem events re-embed into this store.
    pub watch: bool,
    /// Whether the ledger records retrievals answered from this store.
    pub record: bool,
    /// Whether this store's walks honour `.gitignore` and the global
    /// gitignore. The deny-list, the hidden-file rule, `.semlithignore` and
    /// the credential scan apply either way.
    pub gitignore: bool,
    /// Ledger sync to Semlith Cloud (0.37.0): the org this store's ledger rows
    /// go to, and from when. Absent is off, which is the default, and is
    /// left out of the file so a store that never turned it on reads as it
    /// always did.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cloud_sync: Option<CloudSync>,
}

/// Where a store's ledger rows are sent, and the first second they count
/// from: turning sync on sends what is retrieved from then on, not the
/// store's history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CloudSync {
    pub org: String,
    pub since: i64,
}

impl Default for StoreSettings {
    fn default() -> Self {
        Self {
            kind: "both".to_string(),
            lean: "either".to_string(),
            watch: true,
            record: true,
            gitignore: true,
            cloud_sync: None,
        }
    }
}

/// The name rule for a store a person names on the page: one lowercase path
/// segment, so it is the same word in the registry, on disk and in an agent's
/// `store:` argument.
pub fn check_store_name(name: &str) -> Result<()> {
    let mut chars = name.chars();
    let ok = name.len() <= 40
        && chars
            .next()
            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
    if !ok {
        bail!("Use lowercase letters, digits and dashes — for example research-notes.");
    }
    Ok(())
}

fn taken_message(name: &str) -> String {
    format!("“{name}” is already a store on this machine. Pick another name.")
}

/// Make an empty store under the store home and register it, exactly as an
/// index would have made it: the same open, so the model is recorded and the
/// format row written. No roots yet; the first folder indexed into it
/// becomes one.
pub fn create_store(name: &str, kind: &str) -> Result<PathBuf> {
    refuse_remote(name)?;
    check_store_name(name)?;
    let mut registry = Registry::load()?;
    let dir = Registry::dir_of(name)?;
    if registry.stores.contains_key(name) || dir.exists() {
        bail!("{}", taken_message(name));
    }
    let model = crate::Semlith::open(&dir, None)?.model().to_string();
    registry.stores.insert(
        name.to_string(),
        Entry {
            roots: Vec::new(),
            model: Some(model),
            created: now(),
            settings: StoreSettings {
                kind: kind.to_string(),
                ..StoreSettings::default()
            },
        },
    );
    registry.save()?;
    Ok(dir)
}

/// Rename a registered store: the registry key and its directory move
/// together.
///
/// The directory moves rather than staying put under the old name, because
/// [`Registry::dir_of`] derives the directory from the name and every binary
/// that reads this registry — an older one included — finds a store that way.
/// A key renamed over an unmoved directory would read as a missing store to
/// all of them. The caller must have closed the store first: the daemon stops
/// its writer and drops its readers before calling this.
pub fn rename_store(old: &str, new: &str) -> Result<PathBuf> {
    refuse_remote(old)?;
    check_store_name(new)?;
    let mut registry = Registry::load()?;
    let Some(entry) = registry.stores.get(old).cloned() else {
        bail!("no registered store called {old}");
    };
    let to = Registry::dir_of(new)?;
    if registry.stores.contains_key(new) || to.exists() {
        bail!("{}", taken_message(new));
    }
    let from = Registry::dir_of(old)?;
    if from.exists() {
        std::fs::rename(&from, &to).with_context(|| {
            format!(
                "moving {} to {}; nothing was renamed",
                crate::plain(&from.display().to_string()),
                crate::plain(&to.display().to_string())
            )
        })?;
    }
    registry.stores.remove(old);
    registry.stores.insert(new.to_string(), entry);
    if let Err(e) = registry.save() {
        // Put the directory back so the name on disk and the name in the
        // registry still agree.
        let _ = std::fs::rename(&to, &from);
        return Err(e);
    }
    Ok(to)
}

/// Change one store's settings and save, answering what is now recorded.
pub fn update_store_settings(
    name: &str,
    change: impl FnOnce(&mut StoreSettings),
) -> Result<StoreSettings> {
    refuse_remote(name)?;
    let mut registry = Registry::load()?;
    let Some(entry) = registry.stores.get_mut(name) else {
        bail!("no registered store called {name}");
    };
    change(&mut entry.settings);
    let out = entry.settings.clone();
    registry.save()?;
    Ok(out)
}

/// The settings recorded for the store at `dir`, or the defaults for one the
/// registry does not know (a `--store` path, a store from before 0.35.0).
pub fn store_settings(dir: &Path) -> StoreSettings {
    Registry::load()
        .ok()
        .and_then(|r| {
            r.name_of(dir)
                .and_then(|n| r.stores.get(n))
                .map(|e| e.settings.clone())
        })
        .unwrap_or_default()
}

/// `registry.json` — store name to the roots it covers.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Registry {
    #[serde(default)]
    pub stores: BTreeMap<String, Entry>,
    /// Store directories outside the home that this user has said may be
    /// opened, canonical.
    ///
    /// A `.semlith` beside a corpus is a store somebody put there, and from
    /// 0.14.0 that somebody has to have been this user: a repository can carry
    /// one, and a cloned store is a corpus an attacker chose, answering the
    /// questions an agent asks. `semlith adopt` moves such a store into the
    /// home and `semlith trust` records it where it is; either is a deliberate
    /// act, which is the whole difference.
    ///
    /// A registry written by an older semlith has no such field and loads as an
    /// empty list, which is why the first interactive run offers to fill it.
    #[serde(default)]
    pub trusted: Vec<PathBuf>,
    /// Semlith Cloud stores this machine reads and never writes, named
    /// `<org>/<store>` (0.37.0).
    ///
    /// A map of its own beside `stores` rather than entries in it, on purpose:
    /// every older binary walks `stores` as directories under the store home,
    /// so a remote entry there would be reported as a missing store, offered
    /// a re-point and counted in every listing. Here an older binary ignores
    /// it, and drops it on its next write, which costs a `semlith cloud
    /// connect` and nothing else. Absent from the file while empty, so a
    /// connect followed by a disconnect leaves it byte for byte as it was.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub remote: BTreeMap<String, Remote>,
}

/// Where a remote store is answered: the cloud host, the org, the store's
/// own name there, and the MCP URL its tools are reached through.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Remote {
    pub host: String,
    pub org: String,
    pub store: String,
    pub mcp_url: String,
}

/// Refuse a write to a remote store, with the sentence that says why.
///
/// Called by every path that writes to a store by name — the CLI's `index
/// --name` and `drop`, the daemon's write routes and MCP's write tools — so
/// the answer is one sentence everywhere rather than "no store called …",
/// which would read as the store being missing.
pub fn refuse_remote(name: &str) -> Result<()> {
    let Ok(registry) = Registry::load() else {
        return Ok(());
    };
    if let Some(remote) = registry.remote.get(name) {
        bail!("{}", remote_refusal(name, remote));
    }
    Ok(())
}

/// The sentence [`refuse_remote`] gives.
pub fn remote_refusal(name: &str, remote: &Remote) -> String {
    format!(
        "{name} is a remote store: Semlith Cloud ({}) indexes it from its own sources and \
         this machine only reads it, so it cannot be indexed, added to, forgotten from, \
         compacted, changed or deleted here. Change it in the cloud app, or send files \
         to it with `semlith cloud push {name} <dir>`.",
        remote
            .host
            .split_once("://")
            .map_or(remote.host.as_str(), |(_, h)| h)
    )
}

impl Registry {
    /// Read the registry, or an empty one if there is no home yet.
    ///
    /// A registry that cannot be parsed is an error rather than an empty
    /// registry: silently starting over would create a second store for a
    /// corpus that already has one, and the first store's vectors would then
    /// quietly stop being updated.
    pub fn load() -> Result<Self> {
        let path = registry_path()?;
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
        };
        serde_json::from_str(&text).with_context(|| {
            format!(
                "{} is not readable as a registry; move it aside to start over",
                path.display()
            )
        })
    }

    /// Write the registry, creating the home if it is not there.
    ///
    /// Written to a temporary file and renamed, so a process killed mid-write
    /// leaves the previous registry rather than half of a new one.
    pub fn save(&self) -> Result<()> {
        let path = registry_path()?;
        let dir = path.parent().unwrap_or(Path::new("."));
        secure_dir(dir).with_context(|| format!("creating the store home {}", dir.display()))?;
        // A name of this process's own. `registry.json.new` was one fixed name
        // for every process on the machine, so two semliths saving at once
        // wrote the same temporary file and one of them renamed the other's
        // half-written bytes into place.
        let temp = path.with_extension(unique_temp_suffix());
        let body = serde_json::to_string_pretty(self)? + "\n";
        write_private(&temp, body.as_bytes())
            .with_context(|| format!("writing {}", temp.display()))?;
        if let Err(e) = std::fs::rename(&temp, &path) {
            let _ = std::fs::remove_file(&temp);
            return Err(e).with_context(|| format!("writing {}", path.display()));
        }
        Ok(())
    }

    /// Where a registered store's directory is.
    ///
    /// The name is sanitised here as well as where it is chosen: this is a
    /// public function taking a string that becomes a path, and a registry
    /// somebody edited by hand — which is not supported, and happens — should
    /// not be able to name `../..`.
    pub fn dir_of(name: &str) -> Result<PathBuf> {
        Ok(stores_root()?.join(sanitize(name)))
    }

    /// Every registered store's directory, in name order.
    pub fn dirs(&self) -> Result<Vec<PathBuf>> {
        self.stores.keys().map(|n| Self::dir_of(n)).collect()
    }

    /// The registered store whose roots cover `path`, most specific first.
    ///
    /// Most specific wins so a store registered against one package inside a
    /// monorepo keeps that package, rather than being swallowed by a store
    /// registered against the whole tree.
    pub fn covering(&self, path: &Path) -> Option<(&str, &Entry)> {
        let mut best: Option<(&str, &Entry, usize)> = None;
        for (name, entry) in &self.stores {
            for root in &entry.roots {
                if path == root || path.starts_with(root) {
                    let depth = root.components().count();
                    if best.is_none_or(|(_, _, d)| depth > d) {
                        best = Some((name, entry, depth));
                    }
                }
            }
        }
        best.map(|(n, e, _)| (n, e))
    }

    /// The name a registered store directory has, if it is one of ours.
    pub fn name_of(&self, dir: &Path) -> Option<&str> {
        let dir = crate::canonical(dir);
        self.stores
            .keys()
            .map(String::as_str)
            .find(|n| Self::dir_of(n).is_ok_and(|theirs| crate::canonical(&theirs) == dir))
    }

    /// Record a store and the root it covers, creating the entry if new.
    ///
    /// Adding a root that a recorded root already covers is a no-op, so
    /// indexing the same tree twice does not grow the watch list.
    pub fn register(&mut self, name: &str, root: &Path, model: Option<&str>) -> Result<()> {
        let root = crate::canonical(root);
        let entry = self
            .stores
            .entry(name.to_string())
            .or_insert_with(|| Entry {
                roots: Vec::new(),
                model: model.map(str::to_string),
                created: now(),
                settings: StoreSettings::default(),
            });
        if entry.model.is_none() {
            entry.model = model.map(str::to_string);
        }
        if !entry.roots.iter().any(|r| root.starts_with(r)) {
            // A new root that contains recorded ones replaces them: two watches
            // over the same tree is two re-embeds of every save.
            entry.roots.retain(|r| !r.starts_with(&root));
            entry.roots.push(root);
            entry.roots.sort();
        }
        self.save()
    }

    /// Whether this store directory may be opened without being named.
    ///
    /// Anything under the store home is trusted by being there — semlith put it
    /// there. Anything else has to be in the list.
    pub fn trusts(&self, dir: &Path) -> bool {
        let dir = crate::canonical(dir);
        if home_or_error().is_ok_and(|home| dir.starts_with(crate::canonical(&home))) {
            return true;
        }
        self.trusted.iter().any(|t| crate::canonical(t) == dir)
    }

    /// Record a store directory as one this user has chosen to open.
    ///
    /// Idempotent, and it moves nothing: `adopt` is the command that moves a
    /// store, and somebody who wants their `.semlith` to stay beside its corpus
    /// should not have to move it to keep using it.
    pub fn trust(&mut self, dir: &Path) -> Result<PathBuf> {
        let dir = crate::canonical(dir);
        if !dir.join("store.db").exists() {
            bail!(
                "{} is not a semlith store — no store.db in it",
                dir.display()
            );
        }
        if !self.trusted.iter().any(|t| t == &dir) {
            self.trusted.push(dir.clone());
            self.trusted.sort();
            self.save()?;
        }
        Ok(dir)
    }

    /// A store name that is free, derived from `stem`.
    ///
    /// A collision gets a numeric suffix rather than merging into the store
    /// that holds the name: two directories called `api` are two corpora, and
    /// merging them would make every search answer about the wrong one.
    pub fn free_name(&self, stem: &str) -> String {
        let stem = sanitize(stem);
        let taken =
            |n: &str| self.stores.contains_key(n) || Self::dir_of(n).is_ok_and(|d| d.exists());
        if !taken(&stem) {
            return stem;
        }
        for n in 2.. {
            let candidate = format!("{stem}-{n}");
            if !taken(&candidate) {
                return candidate;
            }
        }
        unreachable!("the integers run out before the names do")
    }
}

/// Which store a command means, and why.
#[derive(Debug, Clone)]
pub enum Choice {
    /// `--store` or `SEMLITH_STORE` named them explicitly.
    Named(Vec<PathBuf>),
    /// A `.semlith` directory beside the corpus, from before the home existed.
    Local(PathBuf),
    /// A registered store whose root is the anchor or an ancestor of it.
    Registered { name: String, dir: PathBuf },
    /// Nothing covers the anchor yet, so this is the store that would be made.
    New {
        name: String,
        dir: PathBuf,
        root: PathBuf,
    },
}

impl Choice {
    /// The single store directory this choice names.
    ///
    /// `Named` with several stores is refused here rather than silently using
    /// the first: a write goes to one store, and picking one of several is a
    /// guess at somebody's other repository.
    pub fn one(&self) -> Result<PathBuf> {
        Ok(match self {
            Choice::Named(dirs) => match dirs.as_slice() {
                [one] => one.clone(),
                many => bail!(
                    "this command writes, so it takes one store, not {}: {}",
                    many.len(),
                    many.iter()
                        .map(|d| d.display().to_string())
                        .collect::<Vec<_>>()
                        .join(", ")
                ),
            },
            Choice::Local(dir) => dir.clone(),
            Choice::Registered { dir, .. } | Choice::New { dir, .. } => dir.clone(),
        })
    }

    /// Every store directory this choice names.
    pub fn all(&self) -> Vec<PathBuf> {
        match self {
            Choice::Named(dirs) => dirs.clone(),
            Choice::Local(dir) => vec![dir.clone()],
            Choice::Registered { dir, .. } | Choice::New { dir, .. } => vec![dir.clone()],
        }
    }

    /// The one line of stderr a choice is worth, or nothing.
    ///
    /// Only `Local` says anything. A store found beside its corpus still works
    /// exactly as it did, and the hint is the only place a user learns that
    /// moving it into the home is a command rather than a migration.
    pub fn hint(&self) -> Option<String> {
        match self {
            Choice::Local(dir) => Some(format!(
                "semlith: using the store at {} — `semlith adopt {}` moves it into {} \
                 so `semlith mcp` and `semlith start` find it with no flags",
                dir.display(),
                dir.display(),
                store_home_for_humans(),
            )),
            _ => None,
        }
    }
}

/// Which store a command means, in the order the release documents:
///
/// 1. `--store` or `SEMLITH_STORE`,
/// 2. an existing `.semlith` beside `anchor`,
/// 3. a registered store whose root is `anchor` or an ancestor of it,
/// 4. otherwise a new store in the home, named after `anchor`.
///
/// `anchor` is the working directory for most commands and the first path
/// argument for `index`, so `semlith index ~/work/api` from anywhere makes a
/// store called `api` registered against `~/work/api` — which is what the
/// person typing it meant.
pub fn resolve(flags: &[PathBuf], anchor: &Path, name: Option<&str>) -> Result<Choice> {
    if !flags.is_empty() {
        return Ok(Choice::Named(flags.to_vec()));
    }
    if let Some(raw) = std::env::var_os("SEMLITH_STORE") {
        let dirs: Vec<PathBuf> = std::env::split_paths(&raw)
            .filter(|p| !p.as_os_str().is_empty())
            .collect();
        if !dirs.is_empty() {
            return Ok(Choice::Named(dirs));
        }
    }

    // Asked before anything resolves to a path under it, so a run with no
    // environment is an error naming both variables rather than a store
    // created in whatever directory it started in.
    home_or_error()?;
    let anchor_dir = directory_of(anchor);
    let registry = Registry::load()?;

    // An explicit `--name` is an instruction, so it skips the search: it names
    // the store to use or to create, and nothing else may answer for it.
    if let Some(name) = name {
        refuse_remote(name)?;
        let name = sanitize(name);
        let dir = Registry::dir_of(&name)?;
        return Ok(if registry.stores.contains_key(&name) {
            Choice::Registered { name, dir }
        } else {
            Choice::New {
                name,
                dir,
                root: crate::canonical(&anchor_dir),
            }
        });
    }

    let local = anchor_dir.join(LOCAL_DIR);
    if local.join("store.db").exists() {
        if !registry.trusts(&local) {
            bail!("{}", untrusted(&local));
        }
        return Ok(Choice::Local(local));
    }

    let canonical_anchor = crate::canonical(&anchor_dir);
    if let Some((name, _)) = registry.covering(&canonical_anchor) {
        return Ok(Choice::Registered {
            name: name.to_string(),
            dir: Registry::dir_of(name)?,
        });
    }

    let stem = canonical_anchor
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .filter(|n| !n.is_empty())
        .unwrap_or_else(|| "store".to_string());
    let name = registry.free_name(&stem);
    Ok(Choice::New {
        dir: Registry::dir_of(&name)?,
        name,
        root: canonical_anchor,
    })
}

/// The stores a read-only command opens with no flags: the one covering the
/// working directory, or every registered store when nothing covers it.
///
/// Standing in a repository, a question is about that repository. Standing
/// anywhere else there is no "that repository", and every store the developer
/// has is a better answer than an error about a directory that was never a
/// store.
pub fn read_dirs(flags: &[PathBuf], cwd: &Path) -> Result<Vec<PathBuf>> {
    match resolve(flags, cwd, None)? {
        Choice::New { .. } => all_dirs(flags, cwd),
        other => Ok(other.all()),
    }
}

/// Record what an index run just covered, so `semlith start` watches it.
///
/// Only a store in the home is recorded. A `--store` path and a `.semlith`
/// beside its corpus are both things the user is naming themselves every time,
/// and registering them would put a store in the portal that the next command
/// does not resolve to.
pub fn record(choice: &Choice, roots: &[PathBuf], model: &str) -> Result<()> {
    let name = match choice {
        Choice::New { name, .. } | Choice::Registered { name, .. } => name.clone(),
        Choice::Local(_) | Choice::Named(_) => return Ok(()),
    };
    let mut registry = Registry::load()?;
    for root in roots {
        let root = directory_of(&crate::canonical(root));
        registry.register(&name, &root, Some(model))?;
    }
    Ok(())
}

/// Every store a read-only command with no flags should open: each registered
/// store, plus a `.semlith` beside the working directory if there is one.
///
/// The local store is included so a developer who has not run `adopt` sees no
/// change from 0.8.0 — `semlith mcp` in a repository with a `.semlith` still
/// serves it.
pub fn all_dirs(flags: &[PathBuf], cwd: &Path) -> Result<Vec<PathBuf>> {
    if !flags.is_empty() {
        return Ok(flags.to_vec());
    }
    if let Some(raw) = std::env::var_os("SEMLITH_STORE") {
        let dirs: Vec<PathBuf> = std::env::split_paths(&raw)
            .filter(|p| !p.as_os_str().is_empty())
            .collect();
        if !dirs.is_empty() {
            return Ok(dirs);
        }
    }

    let registry = Registry::load()?;
    let mut out = Vec::new();
    let local = cwd.join(LOCAL_DIR);
    if local.join("store.db").exists() {
        // A read is not safer than a write here: the answer a search gives is
        // the whole of what a poisoned store is for.
        if !registry.trusts(&local) {
            bail!("{}", untrusted(&local));
        }
        out.push(local);
    }
    for dir in registry.dirs()? {
        if dir.join("store.db").exists() {
            out.push(dir);
        }
    }
    Ok(out)
}

/// The directories an agent may index into this store.
///
/// The roots the registry records for it, and — when the store is a `.semlith`
/// beside its corpus — the directory it sits in. That second one matters
/// because `--store` deliberately registers nothing (a path the user names
/// every time is not a store the daemon should list), so without it a store
/// created as `--store <corpus>/.semlith` has no roots at all and an agent
/// cannot index the very corpus that store is about.
///
/// A `.semlith` directory's parent *is* its corpus by construction: it is the
/// default root `semlith adopt` records for exactly that reason.
pub fn index_roots(store_dir: &Path) -> Vec<PathBuf> {
    let mut roots = Registry::load()
        .ok()
        .and_then(|registry| {
            registry
                .name_of(store_dir)
                .and_then(|name| registry.stores.get(name))
                .map(|entry| entry.roots.clone())
        })
        .unwrap_or_default();

    if store_dir.file_name().is_some_and(|n| n == LOCAL_DIR)
        && let Some(corpus) = store_dir.parent()
    {
        let corpus = crate::canonical(corpus);
        if !roots.contains(&corpus) {
            roots.push(corpus);
        }
    }
    roots
}

/// What to say about a store this user has not said may be opened.
///
/// Both commands are named because they are different answers to the same
/// question: `trust` leaves the store where it is, `adopt` moves it into the
/// home. Somebody who cloned a repository and does not recognise the store is
/// being told, in the same sentence, that there is one.
fn untrusted(dir: &Path) -> String {
    format!(
        "{} is a semlith store this machine has not been told to open.\n\
         A `.semlith` directory can arrive inside a repository, and a store is \
         what semlith answers from, so it is opened only once you have said so:\n\
         \n    semlith trust {}      keep it where it is\n\
         \n    semlith adopt {}      move it into {}\n\
         \nOr name it explicitly with --store, which is always an instruction.",
        dir.display(),
        dir.display(),
        dir.display(),
        store_home_for_humans(),
    )
}

/// The store home as a sentence fragment, for a message that is advice rather
/// than an answer. A hint is not worth failing a command over, and naming a
/// path semlith cannot resolve would be worse than naming none.
fn store_home_for_humans() -> String {
    match stores_root() {
        Ok(dir) => dir.display().to_string(),
        Err(_) => "the store home".to_string(),
    }
}

/// Move an existing store directory into the home and register it.
///
/// A rename when the home is on the same filesystem and a copy-then-remove
/// when it is not. Neither re-embeds anything: the store's bytes are the store,
/// and where they sit is not part of its format.
pub fn adopt(source: &Path, root: Option<&Path>, name: Option<&str>) -> Result<(String, PathBuf)> {
    if !source.join("store.db").exists() {
        bail!(
            "{} is not a semlith store — no store.db in it",
            source.display()
        );
    }
    let source = crate::canonical(source);

    let mut registry = Registry::load()?;
    if let Some(existing) = registry.name_of(&source) {
        bail!(
            "{} is already the registered store {existing}",
            source.display()
        );
    }

    // The root defaults to the directory the store sat in, which for a
    // `.semlith` is exactly the corpus it was indexing.
    let root = match root {
        Some(r) => crate::canonical(r),
        None => source
            .parent()
            .map(crate::canonical)
            .unwrap_or_else(|| source.clone()),
    };

    let stem = match name {
        Some(n) => n.to_string(),
        None => label_for(&source),
    };
    let name = registry.free_name(&stem);
    let target = Registry::dir_of(&name)?;

    secure_dir(&stores_root()?)?;
    match std::fs::rename(&source, &target) {
        Ok(()) => {}
        Err(_) => {
            // A different filesystem. Copy, verify the copy opens, then remove
            // the source — in that order, so a failed copy never costs the
            // original.
            copy_tree(&source, &target)
                .with_context(|| format!("copying {} to {}", source.display(), target.display()))?;
            if !target.join("store.db").exists() {
                bail!("copying {} left no store.db behind", source.display());
            }
            std::fs::remove_dir_all(&source)
                .with_context(|| format!("removing {}", source.display()))?;
        }
    }

    let model = crate::Semlith::open_existing(&target)
        .ok()
        .map(|s| s.model().to_string());
    registry.register(&name, &root, model.as_deref())?;
    Ok((name, target))
}

/// Re-point a registered store whose corpus moved.
///
/// A registry edit and nothing else: no re-embedding, no store rewritten. The
/// portal offers it beside a root it can see is missing, so the directory is
/// checked here rather than discovered by a watcher that finds nothing.
pub fn repoint(name: &str, root: &Path) -> Result<()> {
    refuse_remote(name)?;
    if !root.is_dir() {
        bail!("{} is not a directory", root.display());
    }
    let mut registry = Registry::load()?;
    let Some(entry) = registry.stores.get_mut(name) else {
        bail!(
            "no registered store called {name}; these are: {}",
            registry
                .stores
                .keys()
                .cloned()
                .collect::<Vec<_>>()
                .join(", ")
        );
    };
    entry.roots = vec![crate::canonical(root)];
    registry.save()
}

/// Unregister a store and delete everything it holds.
///
/// The vectors, the chunk text, the graph and the ledger for that corpus, and
/// then the registry entry — in that order, so a delete interrupted halfway
/// leaves a registered store with a missing directory, which the portal
/// already reports and offers to re-point, rather than an unnamed directory
/// nothing points at.
///
/// The files on disk that were indexed are not touched. This deletes what
/// semlith derived from them.
pub fn delete_store(name: &str) -> Result<PathBuf> {
    refuse_remote(name)?;
    let mut registry = Registry::load()?;
    if !registry.stores.contains_key(name) {
        bail!(
            "no registered store called {name}; these are: {}",
            registry
                .stores
                .keys()
                .cloned()
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
    let dir = Registry::dir_of(name)?;
    if dir.exists() {
        // Renamed out of the way first, then removed. On Windows a directory
        // with any handle still open inside it — a SQLite connection, a mapped
        // shard — refuses the rename, and a refused rename has deleted
        // nothing; `remove_dir_all` straight away would have taken whatever it
        // reached first and left half a store. The retries cover handles that
        // close a moment after their owner drops them.
        let doomed = dir.with_file_name(format!(
            ".{}.deleting-{}",
            dir.file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_default(),
            std::process::id()
        ));
        let mut moved = std::fs::rename(&dir, &doomed);
        for _ in 0..20 {
            if moved.is_ok() {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(100));
            moved = std::fs::rename(&dir, &doomed);
        }
        moved.with_context(|| {
            format!(
                "{} is still in use, so nothing of it was deleted; stop whatever \
                 else has it open and delete it again",
                crate::plain(&dir.display().to_string())
            )
        })?;
        // The store is gone from its name the moment the rename lands, so the
        // registry follows it before the slow part.
        registry.stores.remove(name);
        registry.save()?;
        std::fs::remove_dir_all(&doomed).with_context(|| {
            format!(
                "{} was taken out of the store home but could not be removed; \
                 delete it by hand",
                crate::plain(&doomed.display().to_string())
            )
        })?;
        return Ok(dir);
    }
    registry.stores.remove(name);
    registry.save()?;
    Ok(dir)
}

/// What to call a store adopted from `dir`: the directory holding it, because
/// almost every adopted store is a `.semlith` whose own name says nothing.
fn label_for(dir: &Path) -> String {
    let own = dir
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    if (own.starts_with('.') || own.is_empty())
        && let Some(parent) = dir
            .parent()
            .and_then(Path::file_name)
            .map(|n| n.to_string_lossy().into_owned())
            .filter(|n| !n.is_empty())
    {
        return parent;
    }
    if own.is_empty() {
        "store".to_string()
    } else {
        own
    }
}

/// The directory a path anchors to: itself, or its parent if it is a file.
fn directory_of(path: &Path) -> PathBuf {
    if path.is_file() {
        return path.parent().unwrap_or(Path::new(".")).to_path_buf();
    }
    path.to_path_buf()
}

/// A store name that is one safe path segment.
///
/// The name becomes a directory under the home and appears in the portal, so
/// anything that is not a plain character becomes a dash rather than a
/// traversal.
fn sanitize(name: &str) -> String {
    let cleaned: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '-'
            }
        })
        .collect();
    let cleaned = cleaned.trim_matches(['-', '.']).to_string();
    if cleaned.is_empty() {
        "store".to_string()
    } else {
        cleaned
    }
}

fn copy_tree(from: &Path, to: &Path) -> Result<()> {
    std::fs::create_dir_all(to)?;
    for entry in std::fs::read_dir(from)? {
        let entry = entry?;
        let target = to.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_tree(&entry.path(), &target)?;
        } else {
            std::fs::copy(entry.path(), &target)?;
        }
    }
    Ok(())
}

fn now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    fn entry(roots: &[&str]) -> Entry {
        Entry {
            roots: roots.iter().map(PathBuf::from).collect(),
            model: None,
            created: 0,
            settings: StoreSettings::default(),
        }
    }

    /// A store registered against a package inside a monorepo must not be
    /// swallowed by one registered against the whole tree: the narrower answer
    /// is the one the developer standing in that package meant.
    #[test]
    fn the_most_specific_root_wins() {
        let mut registry = Registry::default();
        registry
            .stores
            .insert("mono".into(), entry(&["/work/mono"]));
        registry
            .stores
            .insert("api".into(), entry(&["/work/mono/services/api"]));

        let (name, _) = registry
            .covering(Path::new("/work/mono/services/api/src"))
            .expect("an ancestor root covers a subdirectory");
        assert_eq!(name, "api");

        let (name, _) = registry
            .covering(Path::new("/work/mono/docs"))
            .expect("the wide root still covers everything else");
        assert_eq!(name, "mono");

        assert!(registry.covering(Path::new("/elsewhere")).is_none());
    }

    /// Two directories called `api` are two corpora. Merging them would make
    /// every search answer about whichever one was indexed second.
    #[test]
    fn a_taken_name_gets_a_suffix_rather_than_being_shared() {
        let mut registry = Registry::default();
        assert_eq!(registry.free_name("api"), "api");
        registry.stores.insert("api".into(), entry(&["/a/api"]));
        assert_eq!(registry.free_name("api"), "api-2");
        registry.stores.insert("api-2".into(), entry(&["/b/api"]));
        assert_eq!(registry.free_name("api"), "api-3");
    }

    /// The name becomes a directory under the home, so a path separator in it
    /// must not become a path.
    #[test]
    fn a_name_is_one_safe_segment() {
        assert_eq!(sanitize("../../etc"), "etc");
        assert_eq!(sanitize("my repo"), "my-repo");
        assert_eq!(sanitize("ok-name_1.2"), "ok-name_1.2");
        assert_eq!(sanitize("///"), "store");
    }

    /// Indexing the same tree twice must not grow the watch list, and a root
    /// that contains recorded ones replaces them rather than joining them.
    #[test]
    fn roots_do_not_accumulate_overlaps() {
        let mut e = entry(&[]);
        let add = |e: &mut Entry, root: &str| {
            let root = PathBuf::from(root);
            if !e.roots.iter().any(|r| root.starts_with(r)) {
                e.roots.retain(|r| !r.starts_with(&root));
                e.roots.push(root);
                e.roots.sort();
            }
        };
        add(&mut e, "/work/api/src");
        add(&mut e, "/work/api/src");
        assert_eq!(e.roots, vec![PathBuf::from("/work/api/src")]);
        add(&mut e, "/work/api/tests");
        assert_eq!(e.roots.len(), 2);
        add(&mut e, "/work/api");
        assert_eq!(
            e.roots,
            vec![PathBuf::from("/work/api")],
            "a root that contains the others replaces them"
        );
    }

    /// An adopted `.semlith` is named after the directory holding it; its own
    /// name says nothing about the corpus.
    #[test]
    fn an_adopted_store_is_named_after_its_corpus() {
        assert_eq!(label_for(Path::new("/work/api/.semlith")), "api");
        assert_eq!(label_for(Path::new("/work/api-store")), "api-store");
    }
}

#[cfg(test)]
mod agent_key_tests {
    use super::*;

    /// A key is minted once and then read, rather than reminted on every start:
    /// a client's configuration is written once and has to stay valid.
    #[test]
    fn a_key_is_created_once_and_then_read() {
        let home = tempfile::tempdir().unwrap();
        temp_env(home.path(), || {
            let first = agent_key().unwrap();
            assert!(is_agent_key(&first), "{first} is not shaped like a key");
            let second = agent_key().unwrap();
            assert_eq!(first, second, "the key was reminted on the second read");
        });
    }

    #[test]
    #[cfg(unix)]
    fn a_new_key_file_is_not_readable_by_anyone_else() {
        use std::os::unix::fs::PermissionsExt;
        let home = tempfile::tempdir().unwrap();
        temp_env(home.path(), || {
            agent_key().unwrap();
            let mode = std::fs::metadata(agent_key_path().unwrap())
                .unwrap()
                .permissions()
                .mode()
                & 0o777;
            assert_eq!(mode, 0o600, "the key file is mode {mode:o}");
        });
    }

    /// A key file the rest of the machine can read is refused rather than used.
    #[test]
    #[cfg(unix)]
    fn a_group_readable_key_is_refused() {
        use std::os::unix::fs::PermissionsExt;
        let home = tempfile::tempdir().unwrap();
        temp_env(home.path(), || {
            agent_key().unwrap();
            let path = agent_key_path().unwrap();
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
            let refused = agent_key().unwrap_err().to_string();
            assert!(refused.contains("is mode 644"), "{refused}");
        });
    }

    /// Rotation replaces the key, and the replacement is a different one.
    #[test]
    fn rotation_writes_a_different_key() {
        let home = tempfile::tempdir().unwrap();
        temp_env(home.path(), || {
            let first = agent_key().unwrap();
            let second = rotate_agent_key().unwrap();
            assert_ne!(first, second);
            assert_eq!(
                second,
                agent_key().unwrap(),
                "the new key was not persisted"
            );
        });
    }

    /// The page's name rule, word for word, and both refusals it owes.
    #[test]
    fn a_created_store_is_named_by_the_rule_and_never_twice() {
        for good in ["research-notes", "a", "0", "x-1-y"] {
            assert!(check_store_name(good).is_ok(), "{good} refused");
        }
        for bad in ["", "-a", "A", "has space", "under_score", &"a".repeat(41)] {
            let e = check_store_name(bad).unwrap_err().to_string();
            assert_eq!(
                e,
                "Use lowercase letters, digits and dashes — for example research-notes."
            );
        }
        let home = tempfile::tempdir().unwrap();
        temp_env(home.path(), || {
            let dir = create_store("research-notes", "docs").unwrap();
            assert!(dir.join("store.db").exists());
            let registry = Registry::load().unwrap();
            let entry = &registry.stores["research-notes"];
            assert!(entry.roots.is_empty());
            assert!(entry.model.is_some(), "the model was not recorded");
            assert_eq!(entry.settings.kind, "docs");
            assert_eq!(entry.settings.lean, "either");
            let again = create_store("research-notes", "both").unwrap_err();
            assert_eq!(
                again.to_string(),
                "“research-notes” is already a store on this machine. Pick another name."
            );
        });
    }

    /// A rename moves the directory with the key, so `dir_of` still finds it,
    /// and keeps every setting.
    #[test]
    fn a_rename_moves_the_directory_and_keeps_the_settings() {
        let home = tempfile::tempdir().unwrap();
        temp_env(home.path(), || {
            let old = create_store("first", "code").unwrap();
            update_store_settings("first", |s| s.lean = "code".into()).unwrap();
            let new = rename_store("first", "second").unwrap();
            assert!(!old.exists() && new.join("store.db").exists());
            assert_eq!(new, Registry::dir_of("second").unwrap());
            let registry = Registry::load().unwrap();
            assert!(!registry.stores.contains_key("first"));
            assert_eq!(registry.stores["second"].settings.lean, "code");
            create_store("third", "both").unwrap();
            assert!(rename_store("second", "third").is_err());
            assert!(rename_store("second", "Bad").is_err());
        });
    }

    /// A registry an older binary wrote loads with today's behaviour, and the
    /// keys this one writes are plain siblings of `roots`.
    #[test]
    fn store_settings_default_to_what_a_store_did_before_them() {
        let old: Registry =
            serde_json::from_str(r#"{"stores":{"a":{"roots":["/x"],"created":1}}}"#).unwrap();
        assert_eq!(old.stores["a"].settings, StoreSettings::default());
        let written = serde_json::to_value(&old).unwrap();
        assert_eq!(written["stores"]["a"]["watch"], serde_json::json!(true));
        assert_eq!(written["stores"]["a"]["lean"], serde_json::json!("either"));
    }

    /// `SEMLITH_HOME` is process-wide, so these run one at a time.
    ///
    /// The lock is [`super::ENV_LOCK`] rather than one of this module's own:
    /// `HOME` and `SEMLITH_HOME` both decide where a store home is, so two
    /// locks would let a test holding one repoint a directory a test holding
    /// the other was reading.
    fn temp_env(home: &Path, body: impl FnOnce()) {
        super::with_env_var(HOME_ENV, home.as_os_str(), body);
    }
}