vivac 0.12.0

Provenance tree for work: every node knows which node it was born from
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
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
//! The project registry: `<store_dir>/projects`.
//!
//! One file, one job: remember which projects exist on this machine and
//! where, so a later fan-out (`d232`) does not have to be told by hand. It
//! is not verified data and not a disposable projection either -- `f267` --
//! and past `path` it also holds what using a project has turned up since:
//! the root commit of every repository its lanes declare (`repos`), the
//! folder each lane is (`lanes`), and any other folder seen holding a tree
//! that starts with the same first event (`copies`). What is unique to this
//! file is not what those four say -- a repository's root commit is also in
//! the log, in `event::Repo::root` -- it is *where* each one is: `path`,
//! every lane's folder, and every copy's folder are written down nowhere
//! else.
//!
//! Keyed by the id of each project's first event (`d201`), not by
//! `Config::project_id`: `Store::open` silently regenerates a missing
//! `config`, which would mint a fresh id for a project that already has one.
//!
//! Written as a side effect of using a project, never as its own command,
//! and never allowed to turn a working command into a failing one: see
//! `note`.

use crate::failure::Failure;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};

const FILE: &str = "projects";

/// Whether this `.vivac/` is the global store rather than a project's.
/// The registry is the mark: nothing else writes that file, and asking
/// what a directory holds keeps working after `VIVAC_HOME` moves it,
/// which comparing paths would not.
pub fn marks_global_store(dir: &Path) -> bool {
    dir.join(FILE).is_file()
}

const VERSION: u32 = 2;

/// What the registry knows about one project.
///
/// `path` is where it lives. `repos` holds the root commit of every
/// repository the tree's lanes declare, so `setup` can tell that a folder
/// it has never seen holds a product that is already mapped. `lanes` maps
/// each lane id to the folder it is -- the only place a lane's path is
/// ever written down, since `.vivac/lane` deliberately holds none. `copies`
/// holds every other folder seen holding a tree that starts with this same
/// first event (`d201`): `path` never moves off whichever folder used a
/// `vivac` command first while the registry already knew this project, and
/// every other one is recorded here instead, so it can be told about too.
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
struct Project {
    path: String,
    #[serde(default)]
    repos: Vec<String>,
    #[serde(default)]
    lanes: BTreeMap<String, String>,
    #[serde(default)]
    copies: Vec<String>,
}

/// Version 1 wrote a project as a bare path string. It is read and never
/// written: the next write that has something to say replaces the whole
/// file with version 2, so there is no migration step anywhere.
#[derive(Deserialize)]
#[serde(untagged)]
enum Entry {
    V1(String),
    V2(Project),
}

#[derive(Deserialize)]
struct OnDisk {
    version: u32,
    projects: BTreeMap<String, Entry>,
}

#[derive(Serialize)]
struct ToDisk<'a> {
    version: u32,
    projects: &'a BTreeMap<String, Project>,
}

/// What the caller knows about the project it just used.
pub struct Sighting<'a> {
    /// The folder the tree lives in.
    pub root: &'a Path,
    /// The lane the command ran in, and its folder. `None` for a folder
    /// that has not joined yet: the next command in it will say so.
    pub lane: Option<(&'a str, &'a Path)>,
    /// The root commits of every repository this tree's lanes declare.
    /// `None` means "leave what is on file": only the commands that fold
    /// the tree before noting it -- `setup` and `relocate` -- know this.
    pub repos: Option<&'a [String]>,
}

/// What using a project turned out to say about it.
pub enum Noted {
    /// Nothing worth telling anybody.
    Fine,
    /// One or more other folders hold a tree that starts with the same
    /// event, so each of them -- together with this one and whichever
    /// folder is asking -- is a copy of the rest (`d201`). Which folder is
    /// "the" original is **not** what this answers. The registry has room
    /// for one `path` per project, so whichever folder used a `vivac`
    /// command first while the registry already knew this project keeps
    /// that slot, and `path` never moves off it; every other folder
    /// sighted since is recorded in `copies` instead. That first folder
    /// can be the original or a copy in reality -- nothing here knows
    /// which one came first, only which one reached the registry first --
    /// so every folder eventually learns the same membership, itself
    /// excluded: `live_others` is the one function both `note` and
    /// `copy_of` build this from, so a folder never learns a different
    /// set of siblings depending on which of them is asking.
    ///
    /// `first` and `rest` rather than one list: a copy with nobody to name
    /// is not representable, so `copy_notice` never has to guard against
    /// an empty one that only a comment promised could not happen.
    ///
    /// Each folder is named, never its path, and a name is withheld when
    /// the redaction guard rejects it (`d600`) -- this text reaches the
    /// agent's context. Order matches `copies`: insertion order, never
    /// reshuffled.
    Copy {
        first: Option<String>,
        rest: Vec<Option<String>>,
    },
}

/// Records what `s` says about the project keyed by `project_id`.
///
/// Steady state is two small reads and no write: an absent key is inserted,
/// a key that already says exactly this writes nothing, and a key that
/// says something else -- a different path, a new lane, an addition to
/// `repos` -- is updated in place. A copy (`Noted::Copy`) is the one case
/// that never moves `path`: the registry already points elsewhere, and
/// that elsewhere still holds a tree with `project_id`'s own first event,
/// so `s.root` is recorded into `copies` instead, the first time it is
/// seen -- steady state for a copy already known is a no-write read too.
///
/// Never fails. The registry serves a surface that does not exist yet, so a
/// missing or unwritable `store_dir`, a `projects` file that will not
/// parse, or one written by a newer vivac than this, all leave the
/// caller's own result untouched -- and answer `Noted::Fine`, the same as
/// nothing worth telling. A file that will not parse is replaced wholesale
/// on the next successful write, not repaired.
pub fn note(store_dir: &Path, project_id: &str, s: Sighting<'_>) -> Noted {
    try_note(store_dir, project_id, &s).unwrap_or(Noted::Fine)
}

/// Points the registry at `s.root` outright, for a caller that already
/// knows this is a move and has nothing to infer.
///
/// `note`'s own `decide` asks whether `path` still shows a live tree to
/// tell a move from a copy, because an ordinary command never knows which
/// one it is looking at -- it only has a folder and a sighting. `relocate`
/// is not that caller: it just renamed the origin's own `events` out of
/// the way itself, so calling `note` and hoping `path_disagrees` reads the
/// silence correctly is asking one function to re-derive a fact its
/// caller already holds. This skips the guess and writes `s.root` in
/// directly, the way `apply_sighting` always has for an ordinary sighting.
///
/// **Fails the caller.** `note` never does, because for an ordinary write
/// the registry is a comfort a command can do without. `relocate` cannot
/// afford that: once the origin's own log is gone, the registry is the
/// only durable record of where the tree went, and a caller that cannot
/// tell this failed has no way to roll back and warn instead of leaving a
/// tree nothing durable points at.
pub fn record_move(store_dir: &Path, project_id: &str, s: Sighting<'_>) -> std::io::Result<()> {
    std::fs::create_dir_all(store_dir)?;
    let path = store_dir.join(FILE);
    let _lock = crate::store::lock_with_deadline(&store_dir.join(LOCK), LOCK_WAIT)
        .map_err(|e| std::io::Error::other(e.message()))?;
    let Some(mut projects) = read(&path) else {
        return Err(std::io::Error::other(
            "the registry was written by a newer vivac and cannot be updated",
        ));
    };
    apply_sighting(&mut projects, project_id, &s);
    write(store_dir, &path, &projects)
}

/// Whether another folder on this machine still holds a tree that starts
/// with this same event. Read-only: unlike `note`, it never writes, so a
/// reading command can ask without the registry moving under it -- and,
/// because it never writes, it can never lean on a write to have already
/// cleaned anything up: `live_others` does its own filtering, every time,
/// including filtering `root` itself out.
///
/// No folder is favored: `path` and every entry in `copies` are all
/// candidates alike, so whichever folder is asking sees every other live
/// one, itself excluded -- the folder on `path` and the folder not on it
/// go through the very same call.
pub fn copy_of(store_dir: &Path, project_id: &str, root: &Path) -> Noted {
    let Some(projects) = read(&store_dir.join(FILE)) else {
        return Noted::Fine;
    };
    let Some(existing) = projects.get(project_id) else {
        return Noted::Fine;
    };
    copy_or_fine(live_others(existing, project_id, root))
}

/// What every surface that reports one or more copies prints: the heading
/// and the body, chosen together in the very same call so the two can
/// never disagree about how many folders there are. `check` used to read
/// a standalone `COPY_HEADING` and this function's body separately --
/// harmless while there was only ever one shape, and exactly the kind of
/// split that a second shape (this one; `t594` §4.7, fix round 2) would
/// desync the moment only one of the two remembered to change.
///
/// Kept in the one module that already owns what a copy is (`Noted::Copy`,
/// `live_others`) rather than in whichever surface prints it first:
/// `check`, the brief and the per-write stderr notice (`warn_if_wrote`)
/// all read it from here. A security-relevant sentence copied into more
/// than one call site only agrees with itself until somebody edits one of
/// them, which is exactly what happened elsewhere in this work two days
/// before it was written the first time.
///
/// Each surface lays the result out to its own shape -- `check` indents its
/// blocks differently from the brief -- so only the heading and the words
/// are shared; indentation is the caller's.
pub struct CopyNotice {
    pub heading: &'static str,
    pub body: String,
}

/// The width every paragraph below wraps to, before the command line that
/// follows it. A name list has as many names as there are copies, which
/// is not bounded, so splitting it into lines by hand is wrong by
/// construction and not by oversight; `render::wrap` is the same
/// word-wrap `why` and `changes` already read through.
const NOTICE_WIDTH: usize = 76;

/// Wraps `prose`, then puts `command` on a line of its own after it, one
/// indent deeper -- whole, never wrapped, since a command line broken
/// across two lines is not one anybody can paste.
fn wrapped_with_command(prose: &str, command: &str) -> String {
    let mut lines = crate::render::wrap(prose, NOTICE_WIDTH, "");
    lines.push(format!("  {command}"));
    lines.join("\n")
}

/// `first` is the one other folder `Noted::Copy` is guaranteed to carry;
/// `rest` is every one after it, empty for the ordinary case of exactly
/// one copy.
pub fn copy_notice(first: Option<&str>, rest: &[Option<String>]) -> CopyNotice {
    if rest.is_empty() {
        return match first {
            Some(name) => CopyNotice {
                heading: "COPY OF ANOTHER TREE",
                body: wrapped_with_command(
                    &format!(
                        "This tree starts with the same event as the one in folder \"{name}\", \
                         so one of them is a copy, and copies diverge in silence. Keep one: \
                         delete the other, or delete this one and join this folder to it with"
                    ),
                    &format!("vivac setup claude-code --join {}", quote_if_needed(name)),
                ),
            },
            // The command used to sit inline at the end of this
            // paragraph rather than on its own line like its siblings --
            // a mistake in the original prose, not a deliberate
            // difference: form 1 is the same sentence with the name
            // filled in, and its own command already stood on its own
            // line. Fixed here so all five forms wrap the same way.
            None => CopyNotice {
                heading: "COPY OF ANOTHER TREE",
                body: wrapped_with_command(
                    "This tree starts with the same event as one in another folder on this \
                     machine, so one of them is a copy, and copies diverge in silence. Keep \
                     one: delete the other, or delete this one and join this folder to it \
                     with",
                    "vivac setup claude-code --join <path to that folder>",
                ),
            },
        };
    }
    let total = 1 + rest.len();
    let named: Vec<&str> = first
        .into_iter()
        .chain(rest.iter().filter_map(|o| o.as_deref()))
        .collect();
    let names = named
        .iter()
        .map(|n| format!("\"{n}\""))
        .collect::<Vec<_>>()
        .join(", ");
    let command = "vivac setup claude-code --join <the folder you kept>";
    let body = if named.len() == total {
        wrapped_with_command(
            &format!(
                "These folders on this machine start with the same event as this one: \
                 {names}. They are copies of each other, and copies diverge in silence. \
                 Keep one, delete the rest, and join the folders you still work in to the \
                 one you kept:"
            ),
            command,
        )
    } else if named.is_empty() {
        wrapped_with_command(
            "Other folders on this machine hold a tree that starts with the same event as \
             this one, under names this tool will not write down. They are copies of each \
             other, and copies diverge in silence. Keep one, delete the rest, and join the \
             folders you still work in to the one you kept:",
            command,
        )
    } else {
        wrapped_with_command(
            &format!(
                "These folders on this machine start with the same event as this one: \
                 {names}. More hold it too, under names this tool will not write down. \
                 They are copies of each other, and copies diverge in silence. Keep one, \
                 delete the rest, and join the folders you still work in to the one you \
                 kept:"
            ),
            command,
        )
    };
    CopyNotice {
        heading: "COPIES OF THIS TREE",
        body,
    }
}

/// Where every caller that works out a `Noted` leaves it for `warn_if_wrote`
/// to decide about, once whichever command produced it is done running.
/// Never printed from here, and never printed from any of those callers
/// either (`t594`): at every one of those call sites the
/// write the warning would be reporting on has not necessarily happened
/// yet, so deciding there -- by which verb is running, `may_append`'s old
/// mistake -- got the order backwards. Exactly one of `main.rs`'s two call
/// sites, `setup::claude_code`'s `note_registry` and `--join`, ever sets
/// this in a given process: a fresh project has no first event to be a
/// copy of yet, and an existing one is read by exactly one of them, so
/// nothing here is ever asked to arbitrate between two real answers.
static PENDING_NOTICE: std::sync::OnceLock<Noted> = std::sync::OnceLock::new();

/// Leaves `noted` for `warn_if_wrote` to read once this process is done
/// running. Ignores a second call in the same process rather than
/// panicking: harmless, since the doc above already says it should not
/// happen, and a copy warning is not worth a crash if it somehow does.
pub fn set_pending(noted: Noted) {
    let _ = PENDING_NOTICE.set(noted);
}

/// Prints the copy warning on `stderr`. Private: `warn_if_wrote`, below, is
/// the only caller, which is what makes "once" true here -- not a runtime
/// guard racing every call site to be first, the mistake a `std::sync::Once`
/// used to paper over (`t594`): no real path ever called
/// this function more than once in a process, guard or no guard, because
/// nothing outside this module ever called it directly at all.
///
/// `stderr`, never `stdout`: the DX pillar says the agent's own output gets
/// parsed, so this can never land inside a JSON payload or in the middle of
/// a verb's own rendering.
///
/// Flushes `stdout` first, the same discipline every other stderr write in
/// this crate follows: a terminal that merges the two streams shows them out
/// of order otherwise.
fn warn_once_if_copy(noted: &Noted) {
    let Noted::Copy { first, rest } = noted else {
        return;
    };
    crate::output::flush();
    let notice = copy_notice(first.as_deref(), rest);
    eprintln!();
    eprintln!("{}", notice.heading);
    eprintln!();
    for line in notice.body.lines() {
        eprintln!("  {line}");
    }
    eprintln!();
}

/// The single seat (`t594`): called once, from `main`,
/// after the command that might have written has already finished running
/// -- never before, and never keyed by which verb ran. Warns only when
/// this process actually wrote to a tree (`store::wrote`): a usage failure
/// that never reaches a real write leaves that `false`, and stays silent
/// here too, no matter which verb refused.
///
/// Two exceptions, both read off a fact rather than off a verb's name:
/// `store::shown` is true once the brief has already put the very same
/// words in front of whoever is reading, earlier in this same process
/// (`session start` does this before it ever writes a byte), so saying
/// them again here would be the block twice for the one reason it exists
/// once; and `store::is_resident` is true for a server that outlives every
/// one of its own calls (`vivac mcp`) -- its `stderr` reaches nobody once
/// it is running headless, so this is not that process's warning to print
/// on that stream at all. Its own seat is the brief instead, recomputed
/// fresh on every `vivac_brief` call for as long as it lives, which is
/// exactly what `store::shown` being unable to silence a *later* call
/// leaves in place: nothing here ever turns that recomputation off.
pub fn warn_if_wrote() {
    if crate::store::is_resident() || crate::store::shown() || !crate::store::wrote() {
        return;
    }
    if let Some(noted) = PENDING_NOTICE.get() {
        warn_once_if_copy(noted);
    }
}

/// Whether `name` is safe to paste into a shell unquoted: only letters,
/// digits, `-`, `_` and `.`. The short list is the safe one and the long
/// list is the dangerous one, so this names what is allowed rather than
/// what is not -- a folder can be called almost anything, and guessing
/// which of the rest a given shell treats specially is how `A&B` used to
/// get through unquoted and split in two.
fn shell_safe(name: &str) -> bool {
    name.chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}

/// `pub(crate)`, not private: `setup` (`t594` §4.5) quotes a project name
/// the same way when it prints `--join <name>`, the same command this
/// file's own `copy_notice` already prints -- one rule for what a shell
/// needs quoted, not two that could drift.
pub(crate) fn quote_if_needed(name: &str) -> String {
    if shell_safe(name) {
        name.to_string()
    } else {
        format!("\"{name}\"")
    }
}

/// The registry's own lock, in the global store. It is **not** any tree's
/// lock: two different projects can be planted at the same time, and the
/// file they both write is this one. Taken only when there is something to
/// write -- the steady state is two small reads and no write, and that is
/// what keeps `note` off the write budget (`f603`).
const LOCK: &str = "registry.lock";

/// Nobody holds this lock longer than a rename takes, so a second is
/// already generous for either of this file's two writers, not just the
/// one that can afford to give up quietly. `note` can never fail its
/// caller, so for it this is about not hanging a command that already has
/// its own answer. `record_move` is the other one, and it cannot make
/// that same trade: giving up here aborts a move already under way, out
/// loud rather than in silence, which is the right failure for a lock
/// that should only ever be held for a handoff measured in microseconds
/// -- a full second stuck on it already means something else is wrong,
/// not merely running.
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(1);

/// What a read of the registry says to do next.
enum Decision {
    /// Nothing to write; this is the whole answer.
    Done(Noted),
    /// A copy not recorded yet: add `s.root` to the entry's `copies`,
    /// `path` untouched. Carries the `Noted::Copy` already worked out
    /// from `live_others`, so the write side never has to recompute it.
    RecordCopy(Noted),
    /// An ordinary sighting with something new to say: fold `s` into the
    /// entry the way `apply_sighting` always has.
    RecordSighting,
}

/// One read, decided: whether there is nothing to do, a copy to add to
/// `copies`, or an ordinary field to fold in. Called once outside the
/// lock, to decide whether there is anything worth taking it for, and
/// once again under it, on a fresh read, since another writer may have
/// landed between the two.
fn decide(projects: &BTreeMap<String, Project>, project_id: &str, s: &Sighting<'_>) -> Decision {
    if let Some(existing) = projects.get(project_id) {
        if path_disagrees(existing, project_id, s.root) {
            let copy = copy_or_fine(live_others(existing, project_id, s.root));
            let known = existing
                .copies
                .iter()
                .any(|c| crate::anchor::same_folder(Path::new(c), s.root));
            return if known {
                Decision::Done(copy)
            } else {
                Decision::RecordCopy(copy)
            };
        }
    }
    if unchanged(projects, project_id, s) {
        return Decision::Done(Noted::Fine);
    }
    Decision::RecordSighting
}

fn try_note(store_dir: &Path, project_id: &str, s: &Sighting<'_>) -> std::io::Result<Noted> {
    let path = store_dir.join(FILE);
    let Some(projects) = read(&path) else {
        return Ok(Noted::Fine);
    };
    if let Decision::Done(outcome) = decide(&projects, project_id, s) {
        return Ok(outcome);
    }
    std::fs::create_dir_all(store_dir)?;
    let _lock = crate::store::lock_with_deadline(&store_dir.join(LOCK), LOCK_WAIT)
        .map_err(|e| std::io::Error::other(e.message()))?;
    // Read again under the lock: the value that decided there was work to
    // do was read outside it, and another writer may have landed since.
    let Some(mut projects) = read(&path) else {
        return Ok(Noted::Fine);
    };
    match decide(&projects, project_id, s) {
        Decision::Done(outcome) => Ok(outcome),
        Decision::RecordCopy(outcome) => {
            let entry = projects.entry(project_id.to_string()).or_default();
            entry.copies.push(s.root.to_string_lossy().into_owned());
            prune_dead_copies(entry, project_id);
            write(store_dir, &path, &projects)?;
            Ok(outcome)
        }
        Decision::RecordSighting => {
            apply_sighting(&mut projects, project_id, s);
            write(store_dir, &path, &projects)?;
            Ok(Noted::Fine)
        }
    }
}

/// Whether `root` disagrees with what is on `path`, and `path`'s own tree
/// is still there to prove it: the boolean `decide` needs to tell a copy
/// sighting from an ordinary one. That is not a move: both exist, so one
/// is a copy of the other. Short-circuits before ever reading `path`'s own
/// log when `root` already agrees with it -- the ordinary case, on every
/// command -- so the common path costs nothing extra here.
///
/// `same_folder`, not a raw comparison: `path` and `root` are two
/// spellings of a folder built by genuinely independent means -- one read
/// back from a previous sighting, the other from the `cd` in force right
/// now -- and a case difference or an alias between them is `f612`, the
/// same class `ops::repo_at` already had to answer for once.
fn path_disagrees(existing: &Project, project_id: &str, root: &Path) -> bool {
    let other = Path::new(&existing.path);
    !crate::anchor::same_folder(other, root)
        && crate::store::first_event_id(other).as_deref() == Some(project_id)
}

/// The folder the registry still points `project_id` at, when that is not
/// the folder asking.
#[derive(Debug)]
pub struct Elsewhere {
    /// Its own name, or `None` once the redaction guard has withheld it
    /// (`d600`). The path itself never crosses this boundary: where the
    /// other folder sits is this machine's business, the same rule
    /// `folder_name` already applies to a copy's folder.
    pub name: Option<String>,
}

/// The folder on `path` for `project_id`, when `root` is not that folder
/// and `path`'s own folder is still there holding a tree that starts with
/// this same first event. `None` for every other shape: no entry for this
/// project at all, `path` already naming this very folder, or a `path`
/// whose folder is gone, holds no tree, or holds a different one.
///
/// `path_disagrees` -- `decide`'s own question on the write path -- asked
/// by a caller outside this module. `relocate` needs exactly that answer
/// before it moves anything: a move made from a folder the registry does
/// not point at is a move made from a copy, and pointing `path` at its
/// destination would leave the folder that still holds the tree out of the
/// entry entirely, with every lane that resolves through the registry
/// following the copy (`t594` §4.7). A second notion of "still there"
/// would be one more place for the two to drift apart.
///
/// Read-only, like `copy_of` and for the same reason: nothing here may
/// move the registry under whoever else is reading it, and the caller is
/// still deciding whether to go ahead at all.
///
/// A registry written by a newer vivac reads as `None` rather than as a
/// refusal: this cannot tell what it says, and the caller has a better
/// answer waiting anyway -- `record_move` refuses that same file outright,
/// before the origin has lost anything.
pub fn path_elsewhere(store_dir: &Path, project_id: &str, root: &Path) -> Option<Elsewhere> {
    let projects = read(&store_dir.join(FILE))?;
    let existing = projects.get(project_id)?;
    if !path_disagrees(existing, project_id, root) {
        return None;
    }
    Some(Elsewhere {
        name: folder_name(Path::new(&existing.path)),
    })
}

/// Every folder this registry knows might hold `project_id`'s tree, other
/// than `root` itself, verified alive right now: `path` is one candidate
/// and every entry in `copies` is another, on equal footing. A dead one --
/// a folder that no longer has a tree to show for this event, `root`
/// itself included when a write that would have said so never landed --
/// is silently dropped rather than reported. `note` (a fresh copy, or one
/// already known) and `copy_of` (`path` itself, or any other folder,
/// asked either way) both build `Noted::Copy` from this one list, so no
/// folder ever learns a different set of siblings than any other.
fn live_others(existing: &Project, project_id: &str, root: &Path) -> Vec<Option<String>> {
    std::iter::once(existing.path.as_str())
        .chain(existing.copies.iter().map(String::as_str))
        .map(Path::new)
        .filter(|p| !crate::anchor::same_folder(p, root))
        .filter(|p| crate::store::first_event_id(p).as_deref() == Some(project_id))
        .map(folder_name)
        .collect()
}

/// `Noted::Fine` for an empty list, `Noted::Copy` for anything else --
/// the one place that split happens, so `Noted::Copy` itself never has to
/// represent "a copy with nobody in it".
fn copy_or_fine(mut others: Vec<Option<String>>) -> Noted {
    if others.is_empty() {
        Noted::Fine
    } else {
        let first = others.remove(0);
        Noted::Copy {
            first,
            rest: others,
        }
    }
}

/// The folder's own name, or nothing when the redaction guard rejects it.
/// Never the path: where a copy sits is this machine's business, and this
/// name travels into an agent's context.
pub fn folder_name(p: &Path) -> Option<String> {
    let name = p.file_name()?.to_string_lossy().into_owned();
    match crate::redact::check_field("project name", &name) {
        Some(_) => None,
        None => Some(name),
    }
}

/// A folder's name, quoted, or `"another folder"` once the guard has
/// withheld it: the one placeholder every refusal that names a folder
/// falls back to, rather than a copy of the same fallback prose per
/// surface. It lived in `setup::claude_code` while that was the only
/// module that named a folder it could not always name; `relocate`'s own
/// refusal for a move made from a copy is the second, so it moved here,
/// beside the guard it always reads through.
pub(crate) fn label_for(name: Option<&str>) -> String {
    match name {
        Some(n) => format!("\"{n}\""),
        None => "another folder".to_string(),
    }
}

/// Whether `s` says nothing that is not already on file for `project_id`:
/// the no-write path that keeps `note` off the write budget (`f603`).
/// `s.repos` and `s.lane` read as "leave what is on file" when absent, so
/// neither counts against an entry that has nothing to compare them to.
///
/// `path` and a lane's folder are both compared with `same_folder`, never
/// as raw strings: entering the very folder this project's own entry
/// already names, under a second spelling, must read as unchanged, not as
/// a new value to write -- `f612` a third time is exactly the mistake this
/// function existed to avoid catching.
fn unchanged(projects: &BTreeMap<String, Project>, project_id: &str, s: &Sighting<'_>) -> bool {
    let Some(p) = projects.get(project_id) else {
        return false;
    };
    if !crate::anchor::same_folder(Path::new(&p.path), s.root) {
        return false;
    }
    if let Some(repos) = s.repos {
        if p.repos.as_slice() != repos {
            return false;
        }
    }
    if let Some((id, dir)) = s.lane {
        let same = p
            .lanes
            .get(id)
            .is_some_and(|existing| crate::anchor::same_folder(Path::new(existing), dir));
        if !same {
            return false;
        }
    }
    true
}

/// Drops every `copies` entry whose folder no longer holds a tree with
/// `project_id`'s own first event, the moment this project has anything
/// else to write anyway. Only ever called from a write already in
/// progress: `copy_of` never prunes, since a read must never move the
/// registry out from under whoever else might be reading it at the same
/// time -- a dead entry still answers correctly there (`first_event_id`
/// is re-checked on every read), it just waits for a real reason to leave
/// the file.
fn prune_dead_copies(entry: &mut Project, project_id: &str) {
    entry
        .copies
        .retain(|c| crate::store::first_event_id(Path::new(c)).as_deref() == Some(project_id));
}

/// Which of `project_id`'s lanes have a folder that is no longer there,
/// checked with `exists()` right now and never written down: a disk that
/// disconnects and comes back changes the answer both ways, and the
/// registry has no second state to keep in sync with the filesystem's own
/// (`d33`, decision 2 of this task). Unlike `prune_dead_copies`, which
/// drops a dead `copies` entry the moment there is a write to fold it
/// into, this never removes anything from `lanes`: a lane's history stays
/// whether its folder answers or not.
///
/// `t594` tramo 5's OTHER LANES is the first reader; `stack --lanes` and
/// the web are next.
pub fn lanes_with_missing_folder(store_dir: &Path, project_id: &str) -> Vec<String> {
    let Some(projects) = read(&store_dir.join(FILE)) else {
        return Vec::new();
    };
    let Some(entry) = projects.get(project_id) else {
        return Vec::new();
    };
    let mut missing = Vec::new();
    for (id, dir) in &entry.lanes {
        if !Path::new(dir).exists() {
            missing.push(id.clone());
        }
    }
    missing
}

/// Folds `s` into `projects`, minting the entry when `project_id` is new.
/// `s.repos` and `s.lane` only ever add: `None` leaves what is already
/// there.
///
/// `copies` is pruned twice here, for two different reasons: dead entries
/// go first (`prune_dead_copies` -- a copy that gets deleted stops being
/// listed the next time there is anything to write, not only the next
/// time somebody reads), and only then does an entry that now names
/// `path` itself get dropped, with `same_folder` rather than a raw
/// comparison -- the folder `copies` recorded a sighting of can later
/// become `path` in its own right (its old owner's tree gone, `s.root`
/// unchanged from what that entry already said), and a copy of yourself
/// is not a copy of anything.
fn apply_sighting(projects: &mut BTreeMap<String, Project>, project_id: &str, s: &Sighting<'_>) {
    let entry = projects.entry(project_id.to_string()).or_default();
    entry.path = s.root.to_string_lossy().into_owned();
    prune_dead_copies(entry, project_id);
    entry
        .copies
        .retain(|c| !crate::anchor::same_folder(Path::new(c), s.root));
    if let Some(repos) = s.repos {
        entry.repos = repos.to_vec();
    }
    if let Some((id, dir)) = s.lane {
        entry
            .lanes
            .insert(id.to_string(), dir.to_string_lossy().into_owned());
    }
}

/// Where the tree keyed by `project_id` lives, as the registry last heard.
/// A lane names its tree by that key and by nothing else, so this is the
/// lookup a working folder that does not hold the tree depends on.
pub fn root_of(store_dir: &Path, project_id: &str) -> Option<PathBuf> {
    read(&store_dir.join(FILE))
        .unwrap_or_default()
        .get(project_id)
        .map(|p| PathBuf::from(&p.path))
}

/// Every root the registry currently points at, in no particular order.
/// `find --everywhere` (`d273`) is the first reader that wants the roots
/// themselves rather than the id each one is keyed by, so the map's keys
/// stay inside this module the way `note`'s already do.
pub fn roots(store_dir: &Path) -> Vec<PathBuf> {
    read(&store_dir.join(FILE))
        .unwrap_or_default()
        .into_values()
        .map(|p| PathBuf::from(p.path))
        .filter(|r| !marks_global_store(&r.join(crate::store::DIR)))
        .collect()
}

/// A project on this machine that shares at least one repository with the
/// folder being set up. Named by its folder, never by its path: this text
/// reaches an agent's context, and `d600` withholds a name the redaction
/// guard rejects.
#[derive(Debug)]
pub struct Sharing {
    pub name: Option<String>,
    pub root: PathBuf,
    /// The root commits both hold, so the caller can name its own copies
    /// of them by the folder names it already has.
    pub shared: Vec<String>,
}

/// Every project the registry knows that shares at least one of `repos` --
/// root commits -- with the folder being set up: `t594` §4.5, the check
/// that tells a folder `setup` has never seen apart from one that still
/// holds a product already mapped. A single shared repository is enough,
/// the same reason a fresh root that adds one more repository to a product
/// is still that product.
///
/// A project whose `path` no longer holds a tree with its own first event
/// is dropped rather than named: the same aliveness check `live_others`
/// already does for a copy, applied here because `refuse_second_map`'s own
/// remedy names a project by walking this list and offering `--join` on
/// whichever one it finds first -- pointing that remedy at a folder that
/// no longer has a tree to join is worse than saying nothing (`t594`).
///
/// Most shared repositories first, ties broken by name -- a withheld name
/// sorts after every real one, since there is nothing to compare it
/// against.
pub fn sharing_repos(store_dir: &Path, repos: &[String]) -> Vec<Sharing> {
    let Some(projects) = read(&store_dir.join(FILE)) else {
        return Vec::new();
    };
    let mut found: Vec<Sharing> = projects
        .iter()
        .filter_map(|(id, p)| {
            let root = PathBuf::from(&p.path);
            if crate::store::first_event_id(&root).as_deref() != Some(id.as_str()) {
                return None;
            }
            let shared: Vec<String> = p
                .repos
                .iter()
                .filter(|r| repos.contains(r))
                .cloned()
                .collect();
            if shared.is_empty() {
                return None;
            }
            Some(Sharing {
                name: folder_name(&root),
                root,
                shared,
            })
        })
        .collect();
    found.sort_by(|a, b| {
        b.shared.len().cmp(&a.shared.len()).then_with(|| {
            a.name
                .as_deref()
                .unwrap_or("\u{10FFFF}")
                .cmp(b.name.as_deref().unwrap_or("\u{10FFFF}"))
        })
    });
    found
}

/// Resolves `--project`'s value against the registry: a bare name -- the
/// directory's own base name, exactly what `find --everywhere` prints --
/// tried first, and a path otherwise. `d273`'s second half: a hit
/// `find --everywhere` returns names its project this way, and this is what
/// lets `why` open it.
///
/// Two roots can carry the same base name, and choosing between them would
/// answer a question about the wrong tree while looking right, so a name
/// that matches more than one root refuses instead of guessing. The message
/// never names the candidates by path -- the security pillar allows nothing
/// but a project's name across this boundary, and two candidates sharing a
/// name have no path-free way to tell apart, so the count is what it names.
/// A name that matches no root is read as a path only when it looks like
/// one, or when it is really a folder sitting there (`resolve_no_match`,
/// `f616`); `Store::open` is what answers whether that path holds a
/// project at all. Anything else is an unknown name, said as one.
///
/// That path is absolutized and normalized lexically first (`absolute`,
/// below), never left relative to whichever folder this process happened
/// to be started in: `--join ../T` used to write that literal string into
/// `entry.path` (`apply_sighting`), and every other reader of `path` -- a
/// lane, `--project`, `root_of`, `find --everywhere` -- resolves it from a
/// folder of its own, not from the one that typed it (`t594`).
pub fn resolve(spec: &str) -> Result<PathBuf, Failure> {
    let known = crate::store::store_dir()
        .map(|d| roots(&d))
        .unwrap_or_default();
    let mut matches: Vec<PathBuf> = known
        .into_iter()
        .filter(|root| crate::render::project_name(root) == spec)
        .collect();
    match matches.len() {
        1 => Ok(matches.remove(0)),
        0 => resolve_no_match(spec),
        n => Err(Failure::usage(format!(
            "\"{spec}\" names {n} projects on this machine. Pass a path instead."
        ))),
    }
}

/// `f616`: no known root's name matches `spec`, and this used to fall
/// straight through to being read as a path -- so a typo in a project's
/// name answered as "that folder has no tree yet", pointing the fix at a
/// folder nobody typed. A folder that is really there, at this spelling,
/// right now, is evidence a path was meant; nothing else is, so anything
/// else reads as a name the registry does not know.
///
/// The ambiguous case is a name that also happens to be a folder here:
/// `resolve` has no way to tell that apart from a path on the evidence it
/// has, and a folder that is actually on disk outweighs a spelling that
/// might be a typo, so it is read as the folder -- `join`'s own "has no
/// tree yet" then names the fix that is really in front of whoever typed
/// it.
fn resolve_no_match(spec: &str) -> Result<PathBuf, Failure> {
    let candidate = absolute(Path::new(spec));
    if looks_like_a_path(spec) || candidate.is_dir() {
        return Ok(candidate);
    }
    Err(Failure::Model(format!(
        "  No project named {spec} in the registry.\n\n  \
         These do:  vivac vivacs\n  \
         If {spec} was meant as a folder, it has no tree: vivac setup claude-code"
    )))
}

/// Whether `spec` was written as a path rather than a bare name: it
/// carries a separator, or is `.` or `..`, or is already absolute. A
/// project's own name is a folder's base name (`render::project_name`)
/// and never contains any of those, so this only ever misreads a name
/// nobody would have typed as one.
fn looks_like_a_path(spec: &str) -> bool {
    spec == "."
        || spec == ".."
        || spec.contains('/')
        || spec.contains('\\')
        || Path::new(spec).is_absolute()
}

/// `p`, made absolute against the current directory when it is not
/// already, then resolved lexically the same way `anchor::normalize`
/// resolves `.` and `..` -- component by component, never touching disk
/// and never `canonicalize`, which a relative `spec` this loose has no
/// business asking of the filesystem before this call even knows the path
/// exists.
fn absolute(p: &Path) -> PathBuf {
    let based = if p.is_absolute() {
        p.to_path_buf()
    } else {
        std::env::current_dir()
            .map(|cwd| cwd.join(p))
            .unwrap_or_else(|_| p.to_path_buf())
    };
    crate::anchor::normalize(&based)
}

/// Reads the registry. `None` when the file names a version newer than
/// `VERSION`: a registry written by a newer vivac is not understood, and
/// -- the same silence `note` already answers a missed lock with -- is
/// never overwritten. Anything else that will not parse, version 1's own
/// bare strings included, reads as the projects it can make out; garbage
/// reads as no projects at all rather than `None`, and gets replaced whole
/// on the next write that has something to say, same as it always has.
fn read(path: &Path) -> Option<BTreeMap<String, Project>> {
    let Ok(text) = std::fs::read_to_string(path) else {
        return Some(BTreeMap::new());
    };
    let on_disk: OnDisk = match serde_json::from_str(&text) {
        Ok(v) => v,
        Err(_) => return Some(BTreeMap::new()),
    };
    if on_disk.version > VERSION {
        return None;
    }
    Some(
        on_disk
            .projects
            .into_iter()
            .map(|(id, entry)| {
                let project = match entry {
                    Entry::V1(path) => Project {
                        path,
                        ..Project::default()
                    },
                    Entry::V2(p) => p,
                };
                (id, project)
            })
            .collect(),
    )
}

fn write(
    store_dir: &Path,
    path: &Path,
    projects: &BTreeMap<String, Project>,
) -> std::io::Result<()> {
    std::fs::create_dir_all(store_dir)?;
    let tmp = store_dir.join(format!("{FILE}.{}.tmp", crate::id::ulid()));
    let payload = ToDisk {
        version: VERSION,
        projects,
    };
    {
        let mut f = File::create(&tmp)?;
        f.write_all(serde_json::to_string_pretty(&payload)?.as_bytes())?;
        f.write_all(b"\n")?;
    }
    std::fs::rename(&tmp, path)
}

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

    fn temp_dir(prefix: &str) -> std::path::PathBuf {
        std::env::temp_dir().join(format!("vivac-{prefix}-{}", id::ulid()))
    }

    /// A `Sighting` with nothing but a root, the shape every test that does
    /// not care about lanes or repos wants.
    fn sighting(root: &Path) -> Sighting<'_> {
        Sighting {
            root,
            lane: None,
            repos: None,
        }
    }

    /// Plants a fresh tree at `root` and gives it one event, so it has a
    /// first event id to be keyed by.
    fn seed_at(root: &Path) -> String {
        let mut s = store::Store::create(root).unwrap();
        let lock = s.lock_for_write().unwrap();
        s.append(
            &lock,
            crate::lane::MAIN,
            vec![crate::event::Body::NodeNoted {
                node: "t1".into(),
                note: "seed".into(),
            }],
            0,
            false,
        )
        .unwrap();
        store::first_event_id(root).unwrap()
    }

    /// A seeded project under a fresh temp folder.
    fn seeded_project(prefix: &str) -> (std::path::PathBuf, String) {
        let root = temp_dir(prefix);
        let id = seed_at(&root);
        (root, id)
    }

    /// A directory removed when this value is dropped, whether the test
    /// passed or panicked: a trailing `remove_dir_all(...).ok()` only runs
    /// on the way past an assertion that held, so a failing test used to
    /// leave its folders in the system's temporary directory for good.
    struct Owned(std::path::PathBuf);

    impl Drop for Owned {
        fn drop(&mut self) {
            std::fs::remove_dir_all(&self.0).ok();
        }
    }

    /// `relocate`'s own guard for a move made from a copy: the folder the
    /// registry still points at is named, so the refusal can say where the
    /// project does live.
    #[test]
    fn path_elsewhere_names_the_folder_that_still_holds_the_tree() {
        let store_dir = Owned(temp_dir("reg-elsewhere-live-store"));
        let (root, id) = seeded_project("elsewhere-live");
        let root = Owned(root);
        let copy = Owned(temp_dir("reg-elsewhere-live-copy"));
        std::fs::create_dir_all(copy.0.join(store::DIR)).unwrap();
        std::fs::copy(
            root.0.join(store::DIR).join(store::LOG),
            copy.0.join(store::DIR).join(store::LOG),
        )
        .unwrap();
        note(&store_dir.0, &id, sighting(&root.0));

        let elsewhere =
            path_elsewhere(&store_dir.0, &id, &copy.0).expect("the folder on path is still there");

        assert_eq!(
            elsewhere.name.as_deref(),
            root.0.file_name().and_then(|n| n.to_str()),
            "the folder still holding the tree was not named"
        );
    }

    /// Nothing to disagree with: a project this registry has never heard
    /// of cannot say some other folder owns it.
    #[test]
    fn path_elsewhere_answers_nothing_for_a_project_the_registry_never_heard_of() {
        let store_dir = Owned(temp_dir("reg-elsewhere-unknown-store"));
        let (root, id) = seeded_project("elsewhere-unknown");
        let root = Owned(root);

        assert!(
            path_elsewhere(&store_dir.0, &id, &root.0).is_none(),
            "a registry with no entry for this project must not claim one"
        );
    }

    /// The legitimate move: the folder on `path` is gone, so the folder
    /// asking is the only tree left and nothing is left behind by moving
    /// it. The same aliveness question `live_others` already asks of a
    /// copy, which is why both go through `first_event_id` rather than
    /// through whether a path is still spelled the same way.
    #[test]
    fn path_elsewhere_answers_nothing_once_the_folder_on_path_holds_no_tree() {
        let store_dir = Owned(temp_dir("reg-elsewhere-dead-store"));
        let (root, id) = seeded_project("elsewhere-dead");
        let survivor = Owned(temp_dir("reg-elsewhere-survivor"));
        note(&store_dir.0, &id, sighting(&root));

        std::fs::remove_dir_all(&root).unwrap();

        assert!(
            path_elsewhere(&store_dir.0, &id, &survivor.0).is_none(),
            "a folder that no longer holds this tree must not block the one that does"
        );
    }

    /// `f612` once more, at the door `relocate` now reads through: the
    /// folder already on `path`, entered under a second spelling of its
    /// own name, is not another folder.
    #[test]
    fn path_elsewhere_answers_nothing_for_the_folder_on_path_by_another_spelling() {
        let store_dir = Owned(temp_dir("reg-elsewhere-spelling-store"));
        let (root, id) = seeded_project("Elsewhere-Spelling");
        let root = Owned(root);
        note(&store_dir.0, &id, sighting(&root.0));

        let Some(second) = second_spelling(&root.0) else {
            eprintln!(
                "skipped: this platform offers no second spelling of the same folder to test with"
            );
            return;
        };

        assert!(
            path_elsewhere(&store_dir.0, &id, &second).is_none(),
            "a second spelling of the folder already on path read as another folder"
        );
    }

    /// `d600`: the name travels into an agent's context through
    /// `relocate`'s own refusal, so a folder name the guard rejects is
    /// withheld there exactly as it is for a copy.
    #[test]
    fn path_elsewhere_withholds_a_folder_name_the_guard_rejects() {
        let rejected_name = "someone@example.com";
        assert!(
            crate::redact::check_field("project name", rejected_name).is_some(),
            "the guard must actually reject this name, or the test proves nothing"
        );
        let parent = Owned(temp_dir("reg-elsewhere-redacted-parent"));
        std::fs::create_dir_all(&parent.0).unwrap();
        let named = parent.0.join(rejected_name);
        let id = seed_at(&named);
        let store_dir = Owned(temp_dir("reg-elsewhere-redacted-store"));
        let asking = Owned(temp_dir("reg-elsewhere-redacted-asking"));
        note(&store_dir.0, &id, sighting(&named));

        let elsewhere = path_elsewhere(&store_dir.0, &id, &asking.0)
            .expect("the folder on path is still there");

        assert_eq!(
            elsewhere.name, None,
            "a folder name the guard rejects must not reach the caller"
        );
    }

    #[test]
    fn a_fresh_registry_gets_the_project_inserted() {
        let store_dir = temp_dir("reg");
        let (root, id) = seeded_project("proj");

        note(&store_dir, &id, sighting(&root));

        let projects = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(
            projects.get(&id).map(|p| p.path.as_str()),
            Some(root.to_string_lossy().as_ref())
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn two_different_projects_both_appear() {
        let store_dir = temp_dir("reg");
        let (root_a, id_a) = seeded_project("proj-a");
        let (root_b, id_b) = seeded_project("proj-b");

        note(&store_dir, &id_a, sighting(&root_a));
        note(&store_dir, &id_b, sighting(&root_b));

        let projects = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(projects.len(), 2);
        assert_eq!(projects.get(&id_a).unwrap().path, root_a.to_string_lossy());
        assert_eq!(projects.get(&id_b).unwrap().path, root_b.to_string_lossy());

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root_a).ok();
        std::fs::remove_dir_all(&root_b).ok();
    }

    #[test]
    fn an_unwritable_store_directory_never_fails_the_caller() {
        // A file where a directory is expected: `create_dir_all` cannot make
        // a directory out of it, and `note` still has to return nothing to
        // panic on.
        let blocked = temp_dir("blocked");
        std::fs::write(&blocked, b"not a directory").unwrap();
        let (root, id) = seeded_project("proj");

        note(&blocked, &id, sighting(&root));

        std::fs::remove_file(&blocked).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    /// The one thing `note` cannot do and `record_move` exists for: `path`
    /// moves even while the folder already on file still shows a live
    /// tree. `note`'s own `decide` would read that as a copy and leave
    /// `path` exactly where it was -- `a_second_folder_with_the_same_first_event_is_a_copy`,
    /// above, is that behaviour, pinned on purpose. `record_move` never
    /// asks the question.
    #[test]
    fn record_move_points_path_at_the_destination_even_though_the_origin_still_shows_a_tree() {
        let store_dir = temp_dir("reg-move");
        let (origin, id) = seeded_project("move-origin");
        let destination = temp_dir("move-destination");
        note(&store_dir, &id, sighting(&origin));

        record_move(&store_dir, &id, sighting(&destination)).unwrap();

        let projects = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(
            projects.get(&id).unwrap().path,
            destination.to_string_lossy(),
            "record_move must not read a live origin as reason to call this a copy"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&origin).ok();
    }

    /// `note`'s whole contract is that it never fails its caller. This is
    /// the opposite contract, on purpose: `relocate` has nothing durable
    /// left to point at the tree once its own log is gone, so a caller
    /// that cannot tell this write failed has no way to roll back.
    #[test]
    fn record_move_fails_the_caller_when_the_store_directory_cannot_be_written() {
        let blocked = temp_dir("move-blocked");
        std::fs::write(&blocked, b"not a directory").unwrap();
        let (root, id) = seeded_project("move-blocked-proj");

        let result = record_move(&blocked, &id, sighting(&root));

        assert!(
            result.is_err(),
            "a blocked store directory must fail record_move"
        );

        std::fs::remove_file(&blocked).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn a_nonexistent_store_directory_never_fails_the_caller() {
        let store_dir = temp_dir("does-not-exist-yet");
        let (root, id) = seeded_project("proj");

        note(&store_dir, &id, sighting(&root));
        assert!(store_dir.join(FILE).exists());

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn garbage_in_projects_never_fails_the_caller_and_gets_replaced() {
        let store_dir = temp_dir("reg");
        std::fs::create_dir_all(&store_dir).unwrap();
        std::fs::write(store_dir.join(FILE), b"not json at all").unwrap();
        let (root, id) = seeded_project("proj");

        note(&store_dir, &id, sighting(&root));

        let projects = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(projects.get(&id).unwrap().path, root.to_string_lossy());

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn root_of_answers_a_noted_project_and_none_for_an_unknown_key() {
        let store_dir = temp_dir("reg");
        let (root, id) = seeded_project("proj");

        note(&store_dir, &id, sighting(&root));

        assert_eq!(root_of(&store_dir, &id), Some(root.clone()));
        assert_eq!(root_of(&store_dir, "01nosuchprojectaaaaaaaaaaa"), None);

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn two_writers_do_not_lose_a_project() {
        let store_dir = temp_dir("reg-race");
        std::fs::create_dir_all(&store_dir).unwrap();
        let projects: Vec<(std::path::PathBuf, String)> = (0..8)
            .map(|i| seeded_project(&format!("race-{i}")))
            .collect();
        std::thread::scope(|s| {
            for (root, id) in &projects {
                let dir = store_dir.clone();
                s.spawn(move || note(&dir, id, sighting(root)));
            }
        });
        let noted = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(
            noted.len(),
            8,
            "a concurrent write dropped a project the registry already had"
        );
        std::fs::remove_dir_all(&store_dir).ok();
        for (root, _) in &projects {
            std::fs::remove_dir_all(root).ok();
        }
    }

    #[test]
    fn the_registry_is_never_left_half_written() {
        // The torn read `f603` names: a reader that opens the file between
        // truncate and write parses an empty registry and answers that the
        // machine knows no projects at all. With a rename there is no such
        // window: the file is either the old one or the new one.
        let store_dir = temp_dir("reg-torn");
        let (root, id) = seeded_project("torn");
        note(&store_dir, &id, sighting(&root));
        let before = std::fs::read(store_dir.join(FILE)).unwrap();
        let (root2, id2) = seeded_project("torn2");
        let reader = {
            let dir = store_dir.clone();
            std::thread::spawn(move || {
                let mut empty = 0;
                for _ in 0..2_000 {
                    if let Ok(t) = std::fs::read_to_string(dir.join(FILE)) {
                        if serde_json::from_str::<OnDisk>(&t)
                            .map(|c| c.projects.is_empty())
                            .unwrap_or(true)
                        {
                            empty += 1;
                        }
                    }
                }
                empty
            })
        };
        // Alternating the lane keeps every one of these 200 rounds a real
        // write -- copy detection (`t594` §4.7) would otherwise turn a
        // second root noted for the same project into a no-op, and the
        // reader above would never see a rename at all. The lane, not
        // `repos`: every command `main.rs` runs passes one, and `repos`
        // has no production caller yet, so alternating that would stress
        // a write nothing real makes.
        let lane_a_dir = temp_dir("torn-lane-a");
        let lane_b_dir = temp_dir("torn-lane-b");
        for i in 0..200 {
            // Same lane id every round, its folder flipped back and forth:
            // `lanes` only ever grows, so alternating the id instead would
            // stop writing the moment both ids had been seen once each.
            let dir = if i % 2 == 0 { &lane_a_dir } else { &lane_b_dir };
            note(
                &store_dir,
                &id2,
                Sighting {
                    root: &root2,
                    lane: Some(("lane-torn", dir)),
                    repos: None,
                },
            );
        }
        assert_eq!(reader.join().unwrap(), 0, "a reader saw an empty registry");
        assert!(!before.is_empty());
        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
        std::fs::remove_dir_all(&root2).ok();
    }

    #[test]
    fn a_version_1_file_is_read_and_rewritten_as_version_2() {
        let store_dir = temp_dir("reg-v1");
        std::fs::create_dir_all(&store_dir).unwrap();
        let id_a = "01aaaaaaaaaaaaaaaaaaaaaaaa";
        let id_b = "01bbbbbbbbbbbbbbbbbbbbbbbb";
        std::fs::write(
            store_dir.join(FILE),
            format!(r#"{{"version":1,"projects":{{"{id_a}":"/old/a","{id_b}":"/old/b"}}}}"#),
        )
        .unwrap();
        let new_root = std::path::PathBuf::from("/new/a");

        note(&store_dir, id_a, sighting(&new_root));

        let text = std::fs::read_to_string(store_dir.join(FILE)).unwrap();
        let on_disk: OnDisk = serde_json::from_str(&text).unwrap();
        assert_eq!(on_disk.version, VERSION);
        assert_eq!(on_disk.projects.len(), 2);
        match &on_disk.projects[id_a] {
            Entry::V2(p) => assert_eq!(p.path, new_root.to_string_lossy()),
            Entry::V1(_) => panic!("id_a is still a bare string after the rewrite"),
        }
        match &on_disk.projects[id_b] {
            Entry::V2(p) => assert_eq!(p.path, "/old/b"),
            Entry::V1(_) => panic!("id_b is still a bare string after the rewrite"),
        }

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

    #[test]
    fn nothing_changed_writes_nothing() {
        let store_dir = temp_dir("reg-nothing");
        let (root, id) = seeded_project("nothing");

        note(&store_dir, &id, sighting(&root));
        let before = std::fs::metadata(store_dir.join(FILE))
            .unwrap()
            .modified()
            .unwrap();
        note(&store_dir, &id, sighting(&root));
        let after = std::fs::metadata(store_dir.join(FILE))
            .unwrap()
            .modified()
            .unwrap();

        assert_eq!(
            before, after,
            "the no-write case is the one that protects the budget"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn copy_of_never_writes() {
        let store_dir = temp_dir("reg-copy-of");
        let (root, id) = seeded_project("copy-of");
        note(&store_dir, &id, sighting(&root));
        let before = std::fs::metadata(store_dir.join(FILE))
            .unwrap()
            .modified()
            .unwrap();

        let outcome = copy_of(&store_dir, &id, &root);

        let after = std::fs::metadata(store_dir.join(FILE))
            .unwrap()
            .modified()
            .unwrap();
        assert!(matches!(outcome, Noted::Fine));
        assert_eq!(before, after, "copy_of must never write to the registry");

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    /// The write that would ordinarily clear a stale self-reference --
    /// `apply_sighting` moving `path` onto this very folder, dropping it
    /// from `copies` -- never has to happen for `copy_of` to answer
    /// correctly: it promises to keep working when the registry cannot be
    /// written at all, and a read must never lean on a write it cannot
    /// see has happened.
    #[test]
    fn copy_of_never_names_the_folder_asking_about_itself() {
        let store_dir = temp_dir("reg-self-copy");
        let (original, id) = seeded_project("self-copy-original");
        let copy_root = temp_dir("self-copy-copy");
        std::fs::create_dir_all(copy_root.join(store::DIR)).unwrap();
        std::fs::copy(
            original.join(store::DIR).join(store::LOG),
            copy_root.join(store::DIR).join(store::LOG),
        )
        .unwrap();

        note(&store_dir, &id, sighting(&original));
        // A genuine copy, sighted once: this is what writes `copy_root`
        // into `copies` in the first place.
        note(&store_dir, &id, sighting(&copy_root));

        // The original's own tree is gone, so the write that would move
        // `path` onto `copy_root` and drop it from `copies` never
        // happens -- `copy_of` never writes, whether or not the registry
        // even could.
        std::fs::remove_dir_all(&original).unwrap();

        let outcome = copy_of(&store_dir, &id, &copy_root);
        assert!(
            matches!(outcome, Noted::Fine),
            "a stale self-reference in copies must never name the folder asking about itself"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&copy_root).ok();
    }

    #[test]
    fn a_newer_registry_is_left_alone() {
        let store_dir = temp_dir("reg-newer");
        std::fs::create_dir_all(&store_dir).unwrap();
        std::fs::write(store_dir.join(FILE), br#"{"version":99,"projects":{}}"#).unwrap();
        let before = std::fs::read(store_dir.join(FILE)).unwrap();
        let (root, id) = seeded_project("too-new");

        let outcome = note(&store_dir, &id, sighting(&root));

        let after = std::fs::read(store_dir.join(FILE)).unwrap();
        assert!(matches!(outcome, Noted::Fine));
        assert_eq!(
            before, after,
            "a registry from a newer vivac must not be overwritten"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn a_second_folder_with_the_same_first_event_is_a_copy() {
        let store_dir = temp_dir("reg-copy");
        let (first, id) = seeded_project("copy-first");
        let second = temp_dir("copy-second");
        // A second, real tree with the very same first event: not a
        // fixture, an actual copy of the first (`d201`).
        std::fs::create_dir_all(second.join(store::DIR)).unwrap();
        std::fs::copy(
            first.join(store::DIR).join(store::LOG),
            second.join(store::DIR).join(store::LOG),
        )
        .unwrap();
        let expected_name = first.file_name().unwrap().to_string_lossy().into_owned();

        note(&store_dir, &id, sighting(&first));
        let outcome = note(&store_dir, &id, sighting(&second));

        match outcome {
            Noted::Copy { first, rest } => {
                assert_eq!(first, Some(expected_name));
                assert!(rest.is_empty());
            }
            Noted::Fine => panic!("a second tree with the same first event is a copy, not a move"),
        }
        let projects = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(
            projects.get(&id).unwrap().path,
            first.to_string_lossy(),
            "the registry must keep pointing at the folder already on file"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&first).ok();
        std::fs::remove_dir_all(&second).ok();
    }

    #[test]
    fn a_folder_that_moved_is_not_a_copy() {
        let store_dir = temp_dir("reg-moved");
        let (old_root, id) = seeded_project("moved-from");
        let new_root = temp_dir("moved-to");

        note(&store_dir, &id, sighting(&old_root));
        // The folder moved: nothing is left where it used to be.
        std::fs::remove_dir_all(&old_root).unwrap();
        let outcome = note(&store_dir, &id, sighting(&new_root));

        assert!(matches!(outcome, Noted::Fine));
        let projects = read(&store_dir.join(FILE)).unwrap();
        assert_eq!(projects.get(&id).unwrap().path, new_root.to_string_lossy());

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&new_root).ok();
    }

    #[test]
    fn a_copy_whose_folder_name_the_guard_rejects_is_not_named() {
        let rejected_name = "someone@example.com";
        assert!(
            crate::redact::check_field("project name", rejected_name).is_some(),
            "the guard must actually reject this name, or the test proves nothing"
        );

        let parent = temp_dir("reg-copy-redacted-parent");
        std::fs::create_dir_all(&parent).unwrap();
        let first = parent.join(rejected_name);
        let id = seed_at(&first);
        let second = temp_dir("reg-copy-redacted-second");
        std::fs::create_dir_all(second.join(store::DIR)).unwrap();
        std::fs::copy(
            first.join(store::DIR).join(store::LOG),
            second.join(store::DIR).join(store::LOG),
        )
        .unwrap();
        let store_dir = temp_dir("reg-copy-redacted-store");

        note(&store_dir, &id, sighting(&first));
        let outcome = note(&store_dir, &id, sighting(&second));

        assert!(
            matches!(outcome, Noted::Copy { first: None, rest } if rest.is_empty()),
            "a folder name the guard rejects must not reach the caller"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&parent).ok();
        std::fs::remove_dir_all(&second).ok();
    }

    #[test]
    fn lanes_learn_from_whoever_runs_a_command() {
        let store_dir = temp_dir("reg-lanes");
        let (root, id) = seeded_project("lanes");
        let lane_a_dir = temp_dir("lane-a");
        let lane_b_dir = temp_dir("lane-b");

        note(
            &store_dir,
            &id,
            Sighting {
                root: &root,
                lane: Some(("lane-a", &lane_a_dir)),
                repos: None,
            },
        );
        note(
            &store_dir,
            &id,
            Sighting {
                root: &root,
                lane: Some(("lane-b", &lane_b_dir)),
                repos: None,
            },
        );

        let projects = read(&store_dir.join(FILE)).unwrap();
        let project = projects.get(&id).unwrap();
        assert_eq!(
            project.lanes.get("lane-a").map(String::as_str),
            Some(lane_a_dir.to_string_lossy().as_ref())
        );
        assert_eq!(
            project.lanes.get("lane-b").map(String::as_str),
            Some(lane_b_dir.to_string_lossy().as_ref())
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    /// It is checked when read, never stored, and the lane is never
    /// removed: its history stays (`d33`).
    #[test]
    fn a_lane_whose_folder_is_gone_is_known_to_be_gone_and_is_not_removed() {
        let store_dir = temp_dir("reg-missing-folder");
        let (root, id) = seeded_project("missing-folder");
        let live_dir = temp_dir("lane-live");
        std::fs::create_dir_all(&live_dir).unwrap();
        let gone_dir = temp_dir("lane-gone");

        note(
            &store_dir,
            &id,
            Sighting {
                root: &root,
                lane: Some(("live", &live_dir)),
                repos: None,
            },
        );
        note(
            &store_dir,
            &id,
            Sighting {
                root: &root,
                lane: Some(("gone", &gone_dir)),
                repos: None,
            },
        );

        assert_eq!(
            lanes_with_missing_folder(&store_dir, &id),
            vec!["gone".to_string()],
            "only the lane whose folder does not exist should come back"
        );

        let projects = read(&store_dir.join(FILE)).unwrap();
        let project = projects.get(&id).unwrap();
        assert!(
            project.lanes.contains_key("gone"),
            "a folder that is gone must not erase the lane's own history"
        );
        assert!(
            project.lanes.contains_key("live"),
            "a live lane is unaffected by another one's missing folder"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
        std::fs::remove_dir_all(&live_dir).ok();
    }

    /// A second, independent spelling of `p`'s own folder name -- every
    /// ASCII letter's case swapped -- answered rather than assumed, so a
    /// caller with nothing to swap (a name with no letters at all) skips
    /// its own test with a reason instead of silently comparing a path
    /// against itself. Windows only: case is what `f612` was actually
    /// caught by, and this crate makes no claim about case on a
    /// filesystem where it is significant.
    #[cfg(windows)]
    fn second_spelling(p: &Path) -> Option<PathBuf> {
        let name = p.file_name()?.to_str()?;
        let other: String = name
            .chars()
            .map(|c| {
                if c.is_ascii_uppercase() {
                    c.to_ascii_lowercase()
                } else if c.is_ascii_lowercase() {
                    c.to_ascii_uppercase()
                } else {
                    c
                }
            })
            .collect();
        (other != name).then(|| p.with_file_name(other))
    }

    #[cfg(not(windows))]
    fn second_spelling(_p: &Path) -> Option<PathBuf> {
        None
    }

    /// The second harm `f612` did here and not in `ops::repo_at`: a false
    /// `Noted::Copy` returns before `try_note` ever reaches
    /// `apply_sighting`, so a sighting from the second spelling taught the
    /// registry nothing at all -- not just "no copy", the lane it carried
    /// went with it, in silence.
    #[test]
    fn a_folder_reached_by_two_spellings_still_gets_its_lane_recorded() {
        let store_dir = temp_dir("reg-spelling-lane");
        let (root, id) = seeded_project("Spelling");
        let lane_dir = temp_dir("spelling-lane-dir");

        note(
            &store_dir,
            &id,
            Sighting {
                root: &root,
                lane: Some(("lane-a", &lane_dir)),
                repos: None,
            },
        );

        let Some(second) = second_spelling(&root) else {
            eprintln!(
                "skipped: this platform offers no second spelling of the same folder to test with"
            );
            std::fs::remove_dir_all(&store_dir).ok();
            std::fs::remove_dir_all(&root).ok();
            return;
        };

        note(
            &store_dir,
            &id,
            Sighting {
                root: &second,
                lane: Some(("lane-b", &lane_dir)),
                repos: None,
            },
        );

        let projects = read(&store_dir.join(FILE)).unwrap();
        let project = projects.get(&id).unwrap();
        assert!(
            project.lanes.contains_key("lane-b"),
            "the second spelling's own sighting never reached the registry: {:?}",
            project.lanes
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    /// `unchanged`'s own comparison, not `decide`'s: the folder already on
    /// `path` re-entered under a second spelling must read as unchanged,
    /// or every differently-cased command run in it would rewrite the
    /// registry for nothing -- exactly the write budget
    /// `nothing_changed_writes_nothing` already guards, a second spelling
    /// standing in for a second, identical call.
    #[test]
    fn same_folder_by_two_spellings_writes_nothing_the_second_time() {
        let store_dir = temp_dir("reg-spelling-unchanged");
        let (root, id) = seeded_project("Spelling-Unchanged");

        note(&store_dir, &id, sighting(&root));
        let before = std::fs::metadata(store_dir.join(FILE))
            .unwrap()
            .modified()
            .unwrap();

        let Some(second) = second_spelling(&root) else {
            eprintln!(
                "skipped: this platform offers no second spelling of the same folder to test with"
            );
            std::fs::remove_dir_all(&store_dir).ok();
            std::fs::remove_dir_all(&root).ok();
            return;
        };
        note(&store_dir, &id, sighting(&second));
        let after = std::fs::metadata(store_dir.join(FILE))
            .unwrap()
            .modified()
            .unwrap();

        assert_eq!(
            before, after,
            "a second spelling of the folder already on file must not write the registry again"
        );

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&root).ok();
    }

    /// `resolve`'s own no-match branch, isolated from `store_dir()`: a unit
    /// test cannot set `VIVAC_HOME` without racing every other test in this
    /// process, so this pins the pure half directly (`t594`).
    #[test]
    fn absolute_resolves_dot_dot_lexically_against_the_current_directory() {
        let cwd = std::env::current_dir().unwrap();
        let resolved = absolute(Path::new("../elsewhere"));
        assert!(resolved.is_absolute(), "{resolved:?} is still relative");
        assert_eq!(resolved, cwd.parent().unwrap().join("elsewhere"));
    }

    #[test]
    fn absolute_normalizes_a_path_already_absolute() {
        let messy = std::env::current_dir()
            .unwrap()
            .join("a")
            .join("..")
            .join("b");
        assert_eq!(absolute(&messy), std::env::current_dir().unwrap().join("b"));
    }

    /// `sharing_repos`'s own order, pinned directly: `refuse_second_map`
    /// takes the first entry this returns to build its remedy, so which
    /// project comes first decides which name a person is told to
    /// `--join` (`t594`).
    #[test]
    fn sharing_repos_orders_most_shared_first_then_by_name() {
        let store_dir = temp_dir("reg-sharing-order");
        let parent = temp_dir("reg-sharing-order-parent");
        std::fs::create_dir_all(&parent).unwrap();
        let root_beta = parent.join("Beta");
        let root_alpha = parent.join("Alpha");
        let root_two = parent.join("TwoRepos");
        let id_beta = seed_at(&root_beta);
        let id_alpha = seed_at(&root_alpha);
        let id_two = seed_at(&root_two);

        note(
            &store_dir,
            &id_beta,
            Sighting {
                root: &root_beta,
                lane: None,
                repos: Some(&["r1".to_string()]),
            },
        );
        note(
            &store_dir,
            &id_alpha,
            Sighting {
                root: &root_alpha,
                lane: None,
                repos: Some(&["r1".to_string()]),
            },
        );
        note(
            &store_dir,
            &id_two,
            Sighting {
                root: &root_two,
                lane: None,
                repos: Some(&["r1".to_string(), "r2".to_string()]),
            },
        );

        let found = sharing_repos(&store_dir, &["r1".to_string(), "r2".to_string()]);
        let names: Vec<String> = found.into_iter().map(|s| s.name.unwrap()).collect();
        assert_eq!(names, vec!["TwoRepos", "Alpha", "Beta"]);

        std::fs::remove_dir_all(&store_dir).ok();
        std::fs::remove_dir_all(&parent).ok();
    }

    /// A project whose folder no longer holds a tree with that project's own
    /// first event must not be offered as a `--join` remedy: the folder is
    /// gone, or holds something else now (`t594`).
    #[test]
    fn sharing_repos_drops_a_project_whose_folder_no_longer_holds_that_tree() {
        let store_dir = temp_dir("reg-sharing-dead");
        let (root, id) = seeded_project("sharing-dead");
        note(
            &store_dir,
            &id,
            Sighting {
                root: &root,
                lane: None,
                repos: Some(&["r1".to_string()]),
            },
        );

        std::fs::remove_dir_all(&root).unwrap();

        let found = sharing_repos(&store_dir, &["r1".to_string()]);
        assert!(
            found.is_empty(),
            "a dead folder was still offered as a --join remedy: {found:?}"
        );

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