dodot-lib 5.6.0

Core library for dodot dotfiles manager
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
//! Activation evidence — is a shell actually loading dodot, and which
//! dodot?
//!
//! `dodot up` proves the datastore is correct. It proves nothing about
//! whether any shell ever *sources* the generated init script, which is
//! the only layer users experience. This module carries the evidence
//! half of that gap (`docs/proposals/shipped/shell-hookup.lex` §2,
//! widened by `docs/proposals/shell-hookup-ergonomics.lex` §2):
//!
//! - The **generation stamp** — the init script exports
//!   [`INIT_GEN_ENV`] with the generation it was written at, and
//!   [`INIT_VERSION_ENV`] with the dodot that wrote it. Any dodot
//!   command inherits the calling shell's environment, so it knows with
//!   certainty whether the invoking shell sourced init, whether that
//!   init predates the last regeneration, and whether it came from the
//!   binary now running ([`EnvStamp`]).
//! - The **heartbeat** — the init script truncates
//!   [`Pather::hookup_heartbeat_path`] to the same two fields on every
//!   source. One redirect of static content, so parallel shell startups
//!   can't corrupt it: last writer wins. Its *mtime*, not its contents,
//!   answers "when did a shell last load dodot" ([`Heartbeat`]).
//!
//! All of it is emitted unconditionally by
//! [`crate::shell::generate_init_script`] — unlike the opt-in
//! profiling TSVs — and stays on the hot-path budget: exports and one
//! redirect, no command execution, no `dodot` invocation.
//!
//! # Evaluation
//!
//! [`Evidence`] is the whole input to the answer: both signals, the
//! reference generation, the running version, and the clock. Reading
//! it is the only IO ([`Evidence::collect`]); everything downstream —
//! [`classify_stamp`], [`classify_heartbeat`], [`evaluate`],
//! [`Evidence::state`], [`Evidence::footer`] — is pure, so every state
//! and every rendered string is unit-testable without a filesystem.
//!
//! The reference generation is "the generation a healthy shell would
//! be running", and who supplies it differs by caller:
//!
//! - `dodot status` reads it off the init script on disk.
//! - `dodot up` and `dodot down` capture it *before* regenerating the
//!   script. Comparing against the freshly written generation instead
//!   would mark the invoking shell stale on every single run, which is
//!   noise, not news.
//!
//! # The footer
//!
//! The answer renders as two lines ([`ActivationNotice`]) that every
//! `pack-status` render carries — `up`, `down` and `status` alike.
//! Line one names the state, line two the evidence behind it. The
//! strings are pinned here and asserted verbatim in the tests, because
//! the user documentation quotes them.
//!
//! The empirical probe (spec §3) lives in [`crate::shell::probe`] and
//! the rc-file machinery behind `dodot install` (spec §4) in
//! [`crate::shell::rc`]. Everything below is decided on evidence
//! alone; a caller that is allowed to spawn a shell goes through
//! [`probe::notice_with_probe`](crate::shell::probe::notice_with_probe)
//! instead, which starts here and only measures when these signals
//! come back inconclusive.

use std::fmt;
use std::path::Path;
use std::time::Duration;

use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::fs::Fs;
use crate::paths::Pather;
use crate::shell::rc::{self, HookPresence};

/// Environment variable the generated init script exports, carrying
/// the generation it was written at.
pub const INIT_GEN_ENV: &str = "DODOT_INIT_GEN";

/// Environment variable the generated init script exports, carrying
/// the dodot version that generated it.
pub const INIT_VERSION_ENV: &str = "DODOT_INIT_VERSION";

/// The last release whose evidence carried no version.
///
/// One constant, one rule: any stamp or heartbeat without a version
/// field was written by a dodot at or below this release, and renders
/// as `≤5.5.1` rather than as "unknown". It covers both halves of the
/// same situation — a pre-RCS01 binary generating a script right now,
/// and a stale heartbeat one left on disk months ago.
pub const PRE_VERSION_RELEASE: &str = "5.5.1";

/// The version of the running binary — what every piece of evidence is
/// compared against.
pub fn running_version() -> &'static str {
    env!("CARGO_PKG_VERSION")
}

/// The generation to stamp into a script written now.
///
/// Unix seconds: monotonic enough for "is this stamp older than that
/// script", and readable in a heartbeat file during debugging. Two
/// regenerations within the same second collapse to one generation,
/// which is harmless — every comparison is `>=`, so the worst case is
/// calling a just-started shell current instead of stale.
pub fn current_generation() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// Parse a generation from stamp/heartbeat text. Anything that isn't
/// ASCII decimal reads as "no signal" rather than as generation zero,
/// which would make a corrupt file look like the oldest possible
/// activation instead of no activation at all.
pub fn parse_generation(raw: &str) -> Option<u64> {
    raw.trim().parse::<u64>().ok()
}

/// The version a stamp or heartbeat reports.
///
/// Absent is not unknown: evidence without a version field can only
/// have been written by a dodot that predates the field, which is a
/// bound, not a mystery — see [`PRE_VERSION_RELEASE`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvidenceVersion {
    /// The version the evidence names.
    Known(String),
    /// No version field: written at or before [`PRE_VERSION_RELEASE`].
    PreVersion,
}

impl EvidenceVersion {
    /// Read a version field, treating an absent or blank one as
    /// [`EvidenceVersion::PreVersion`].
    pub fn from_field(field: Option<&str>) -> EvidenceVersion {
        match field.map(str::trim).filter(|v| !v.is_empty()) {
            Some(v) => EvidenceVersion::Known(v.to_string()),
            None => EvidenceVersion::PreVersion,
        }
    }

    /// Whether this evidence is consistent with having been written by
    /// `running`.
    ///
    /// [`EvidenceVersion::PreVersion`] is a bound rather than a value,
    /// so it matches exactly one running version: the bound itself.
    /// Every later release is unambiguously *not* what wrote it, which
    /// is the version skew this whole state exists to name; a binary
    /// that still *is* [`PRE_VERSION_RELEASE`] cannot tell its own
    /// version-less evidence from someone else's, and must not claim
    /// skew on evidence that ambiguous.
    pub fn is(&self, running: &str) -> bool {
        match self {
            EvidenceVersion::Known(v) => v == running,
            EvidenceVersion::PreVersion => running == PRE_VERSION_RELEASE,
        }
    }
}

/// Whether `loaded` names a dodot other than `running`.
///
/// The one place the skew rule lives. [`Evidence`] applies it to the
/// two cheap signals and [`crate::shell::probe`] applies it to what a
/// spawned shell reported, so an inferred skew and a measured one can
/// never disagree about what counts as skew. `None` — nothing loaded
/// — is never skew: there is no version to differ from.
pub fn is_skewed(loaded: Option<&EvidenceVersion>, running: &str) -> bool {
    loaded.is_some_and(|v| !v.is(running))
}

impl fmt::Display for EvidenceVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EvidenceVersion::Known(v) => f.write_str(v),
            EvidenceVersion::PreVersion => write!(f, "{PRE_VERSION_RELEASE}"),
        }
    }
}

/// Signal 1's raw form: what the calling shell's environment reports.
///
/// A generation without a version is the pre-RCS01 shape and stays
/// meaningful; a version without a generation is not evidence of
/// anything, so [`EnvStamp::version`] is only ever consulted when
/// [`EnvStamp::generation`] is set.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvStamp {
    /// `DODOT_INIT_GEN` — the generation of the script this shell
    /// sourced.
    pub generation: Option<u64>,
    /// `DODOT_INIT_VERSION` — the dodot that generated it.
    pub version: Option<String>,
}

impl EnvStamp {
    /// Read both fields from the process environment. Unset or
    /// unparseable reads as no signal.
    pub fn read() -> EnvStamp {
        EnvStamp {
            generation: std::env::var(INIT_GEN_ENV)
                .ok()
                .as_deref()
                .and_then(parse_generation),
            version: std::env::var(INIT_VERSION_ENV)
                .ok()
                .filter(|v| !v.trim().is_empty()),
        }
    }

    /// A stamp for `generation` with no version — the pre-RCS01 shape,
    /// and the shorthand tests use when the version is not the subject.
    pub fn at(generation: u64) -> EnvStamp {
        EnvStamp {
            generation: Some(generation),
            version: None,
        }
    }

    /// The version this shell loaded, or `None` when it loaded nothing.
    pub fn evidence_version(&self) -> Option<EvidenceVersion> {
        self.generation
            .map(|_| EvidenceVersion::from_field(self.version.as_deref()))
    }
}

/// Signal 2's raw form: the heartbeat file, contents and mtime.
///
/// A `Heartbeat` only exists for a file whose contents parsed: a
/// corrupt one reads as no activation at all ([`read_heartbeat`]), so
/// no field of this struct can be mistaken for evidence that a shell
/// loaded dodot. That is why [`Heartbeat::generation`] is not an
/// `Option` — an unparseable generation is the absence of a heartbeat,
/// not a heartbeat with an absent generation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heartbeat {
    /// The generation the last shell to source init was running.
    pub generation: u64,
    /// The dodot that generated that script.
    pub version: Option<String>,
    /// When the file was last written — i.e. when a shell last
    /// *sourced* the script, which is not when the script was
    /// generated. Under the file-source hook the two diverge
    /// permanently, and the mtime is the one that answers "last run".
    /// `None` when the filesystem would not say.
    pub last_run: Option<SystemTime>,
}

impl Heartbeat {
    /// The version the last shell loaded.
    ///
    /// Unconditional, unlike [`EnvStamp::evidence_version`], because a
    /// `Heartbeat` that exists already carries a parsed generation — a
    /// shell demonstrably loaded dodot — so the only question left is
    /// *which* dodot, and a missing field answers that as
    /// [`EvidenceVersion::PreVersion`].
    pub fn evidence_version(&self) -> EvidenceVersion {
        EvidenceVersion::from_field(self.version.as_deref())
    }
}

/// Split heartbeat text into its generation and version fields.
///
/// The file holds `<generation> <version>`; a pre-RCS01 one holds just
/// `<generation>`. Anything else yields no generation, which
/// [`read_heartbeat`] turns into no activation at all rather than into
/// the oldest possible one.
pub fn parse_heartbeat(raw: &str) -> (Option<u64>, Option<String>) {
    let mut fields = raw.split_whitespace();
    let generation = fields.next().and_then(parse_generation);
    let version = fields.next().map(str::to_string);
    (generation, version)
}

/// Read the heartbeat left by the last shell activation. `None` when no
/// shell has ever sourced the init script on this machine, the marker
/// is unreadable, or its contents do not parse.
///
/// The last of those is the whole point of reading the file rather than
/// stat-ing it: *existence is not activation*. A corrupt marker has a
/// perfectly readable mtime and a perfectly present version field, and
/// treating either as a signal would report "Last loaded 4 minutes ago
/// by dodot ≤5.5.1" for a file full of garbage. One gate here keeps
/// every downstream reader — the generation ladder, the version, the
/// "last loaded" time — honest without each having to remember.
pub fn read_heartbeat(fs: &dyn Fs, paths: &dyn Pather) -> Option<Heartbeat> {
    let path = paths.hookup_heartbeat_path();
    if !fs.exists(&path) {
        return None;
    }
    let raw = fs.read_to_string(&path).ok()?;
    let (generation, version) = parse_heartbeat(&raw);
    Some(Heartbeat {
        generation: generation?,
        version,
        last_run: fs.modified(&path).ok(),
    })
}

/// Read the init script currently on disk. `None` when nothing has ever
/// been deployed, or the script is unreadable.
pub fn read_script(fs: &dyn Fs, paths: &dyn Pather) -> Option<String> {
    let path = paths.init_script_path();
    if !fs.exists(&path) {
        return None;
    }
    fs.read_to_string(&path).ok()
}

/// Read the generation stamped into the init script currently on disk
/// — the generation a shell started right now would pick up. `None`
/// when the script is missing (nothing has ever been deployed) or
/// carries no stamp.
pub fn read_script_generation(fs: &dyn Fs, paths: &dyn Pather) -> Option<u64> {
    read_script(fs, paths)
        .as_deref()
        .and_then(parse_script_generation)
}

/// Extract the generation from init-script text — the value of the
/// single `export DODOT_INIT_GEN=<n>` line the generator emits.
///
/// Doubles as the test of whether a file *is* a dodot init script: no
/// other file carries that export, and no dodot writes one without it.
pub fn parse_script_generation(script: &str) -> Option<u64> {
    let prefix = format!("export {INIT_GEN_ENV}=");
    script
        .lines()
        .find_map(|line| line.trim().strip_prefix(&prefix))
        .and_then(parse_generation)
}

/// Extract the version from init-script text — the value of the single
/// `export DODOT_INIT_VERSION=<v>` line the generator emits, absent in
/// a script written before the field existed.
///
/// The pair with [`parse_script_generation`], so a script on disk can
/// be identified by the same two fields the environment stamp and the
/// heartbeat carry — and judged by the same rules.
pub fn parse_script_version(script: &str) -> Option<String> {
    let prefix = format!("export {INIT_VERSION_ENV}=");
    script
        .lines()
        .find_map(|line| line.trim().strip_prefix(&prefix))
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(str::to_string)
}

/// Signal 1: what the calling shell's environment says.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StampState {
    /// Stamped at or after the reference generation — this shell is live.
    Current,
    /// Stamped, but older than the reference generation — this shell
    /// sourced an init script that has since been regenerated.
    Stale,
    /// No stamp: this process was not started by a shell that sourced
    /// init (or no shell ever has).
    Absent,
}

/// Signal 2: what the heartbeat says about *any* shell on this machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeartbeatState {
    /// Some shell activated at or after the reference generation.
    Fresh,
    /// Some shell activated once, but not since the reference generation.
    Old,
    /// No shell has ever activated here.
    Absent,
}

/// The user-facing activation state (spec §5, plus [`ShellNotLoaded`]
/// from #279).
///
/// All but [`VerifiedBroken`] are decided on evidence alone — the two
/// generation signals plus, for a caller that supplies it, the
/// session's tty-attachment. [`VerifiedBroken`] is the one state only
/// a measurement can reach — see [`crate::shell::probe`] — and it
/// takes precedence over the others when the probe has run: with the
/// generation signals alone, a hookup that used to work and then
/// broke looks like a stale shell, so the user gets "open a new
/// shell" for a problem no new shell will fix. [`ShellNotLoaded`] is
/// the evidence path's answer to that same broken hookup for callers
/// that may not measure (`status`), built from the tty signal and the
/// static rc scan.
///
/// [`VerifiedBroken`]: ActivationState::VerifiedBroken
/// [`ShellNotLoaded`]: ActivationState::ShellNotLoaded
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivationState {
    /// Evidence says shells are loading dodot at the current
    /// generation, from the binary now running.
    Healthy,
    /// Shells are loading dodot, but a *different* dodot than the one
    /// running (`shell-hookup-ergonomics.lex` §2.3). The state the
    /// version field exists to make visible: a hookup can be wired,
    /// sourced on every shell start, and still dead, and every other
    /// state would call this one healthy or stale and send the user to
    /// a fix that cannot work.
    VersionSkew,
    /// Shells are loading dodot and the script they load deploys no
    /// packs — after `down`, in a repository where every pack is
    /// ignored, or after a first `up` that deployed nothing. A healthy
    /// hookup line here is technically true and practically
    /// misleading.
    ///
    /// The claim is about *packs*, not about the file: the measurement
    /// behind it ([`crate::shell::script_has_contributions`]) counts
    /// pack contributions, and on a Homebrew host the script still
    /// carries the bootstrap block that rewrites `PATH`, so "the init
    /// script is empty" would be flatly false there.
    EmptyScript,
    /// Something activated, but not at the current generation — most
    /// often the terminal the user is typing in, which predates their
    /// last `up`.
    StaleShell,
    /// No evidence of any activation, ever. The new-user failure story.
    NeverActivated,
    /// dodot is attached to a terminal but inherited no stamp: the
    /// shell the user is typing in demonstrably did not load dodot,
    /// whatever the heartbeat claims about some past session (#279).
    /// The literal truth and nothing more — an IDE task shell with a
    /// pty legitimately reads no rc file, so the notice never asserts
    /// "your hookup is broken"; the rc scan supplies the next step.
    ShellNotLoaded,
    /// The probe spawned a shell and it did not load dodot. Measured,
    /// not inferred.
    VerifiedBroken,
}

impl ActivationState {
    /// Stable identifier for the state, used as the serialized
    /// discriminator and in tests.
    pub fn as_str(self) -> &'static str {
        match self {
            ActivationState::Healthy => "healthy",
            ActivationState::VersionSkew => "version-skew",
            ActivationState::EmptyScript => "empty-script",
            ActivationState::StaleShell => "stale-shell",
            ActivationState::NeverActivated => "never-activated",
            ActivationState::ShellNotLoaded => "shell-not-loaded",
            ActivationState::VerifiedBroken => "verified-broken",
        }
    }
}

/// Classify the environment stamp against the reference generation.
///
/// A `None` reference means no generation is known (no init script on
/// disk yet), so there is nothing to be stale against: a stamp that
/// exists at all counts as current.
pub fn classify_stamp(stamp: Option<u64>, reference: Option<u64>) -> StampState {
    match (stamp, reference) {
        (None, _) => StampState::Absent,
        (Some(_), None) => StampState::Current,
        (Some(s), Some(r)) => {
            if s >= r {
                StampState::Current
            } else {
                StampState::Stale
            }
        }
    }
}

/// Classify the heartbeat against the reference generation. Same
/// no-reference rule as [`classify_stamp`].
pub fn classify_heartbeat(heartbeat: Option<u64>, reference: Option<u64>) -> HeartbeatState {
    match (heartbeat, reference) {
        (None, _) => HeartbeatState::Absent,
        (Some(_), None) => HeartbeatState::Fresh,
        (Some(h), Some(r)) => {
            if h >= r {
                HeartbeatState::Fresh
            } else {
                HeartbeatState::Old
            }
        }
    }
}

/// Fold the two signals, plus the session's tty-attachment, into one
/// state.
///
/// The stamp wins when present: it is direct evidence about the shell
/// the user is typing in, which is the one they can act on. A current
/// stamp is healthy even with no heartbeat (this shell activated; the
/// marker write is best-effort), and a stale stamp is a stale shell
/// even when some *other* shell is current — "open a new shell" is
/// still the fix.
///
/// With no stamp, `tty` breaks the tie the heartbeat cannot (#279).
/// The heartbeat is a high-water mark: it proves some shell activated
/// once, never that shells still do — a hookup that breaks after a
/// heartbeat keeps its old certificate indefinitely. But a
/// tty-attached process with no stamp *is* direct evidence: the
/// session in front of the user did not load dodot, and that outranks
/// the heartbeat's claim about a past session — whether the heartbeat
/// is fresh (the dead-hookup case the epic was built to catch) or old
/// (where blind "open a new shell" advice may be wrong; the rc scan
/// decides).
///
/// Detached and stampless (a cron job, an editor's task runner — the
/// callers this arm exists for), the heartbeat still decides: fresh
/// means shells are activating and this process just isn't one of
/// them; old means nothing has activated since the last regeneration;
/// absent means nothing ever has.
/// Apply the two rules that override the generation ladder.
///
/// The ladder ([`evaluate`]) only ever answers "did a shell load, and
/// how recently". Two facts outrank that answer, and they outrank it
/// the same way no matter who established the ladder's verdict — the
/// two cheap signals, or a shell dodot actually spawned:
///
/// - **Version skew** replaces `Healthy` and `StaleShell`. Both say
///   the hookup is working; a version mismatch says the working
///   hookup is running the wrong dodot, which is strictly more
///   specific and is the one thing "open a new shell" may not fix.
///   It never fires on staleness alone — a mismatch is required —
///   and it never overrides a state that already says a shell
///   loaded nothing, where the missing hookup is the news and the
///   version still shows on line two.
/// - **An empty script** replaces `Healthy` only: the hookup is
///   sound and the script it sources deploys no packs, which is the
///   one claim a healthy line would get wrong. A hookup that is
///   *also* broken has a bigger problem to report first.
///
/// This is a free function rather than a method on [`Evidence`]
/// because [`crate::shell::probe`] has to reach it too. A measurement
/// establishes the *ladder's* rung with certainty and learns nothing
/// about these two rules, so a measured verdict that skipped them
/// would report `Healthy` for a deployment of nothing — the shared
/// classifier is shared precisely so the two paths cannot answer
/// differently.
pub fn refine(
    ladder: ActivationState,
    skewed: bool,
    script_has_contributions: bool,
) -> ActivationState {
    match ladder {
        ActivationState::Healthy | ActivationState::StaleShell if skewed => {
            ActivationState::VersionSkew
        }
        ActivationState::Healthy if !script_has_contributions => ActivationState::EmptyScript,
        other => other,
    }
}

pub fn evaluate(stamp: StampState, heartbeat: HeartbeatState, tty: bool) -> ActivationState {
    match (stamp, heartbeat) {
        (StampState::Current, _) => ActivationState::Healthy,
        (StampState::Stale, _) => ActivationState::StaleShell,
        (StampState::Absent, HeartbeatState::Absent) => ActivationState::NeverActivated,
        (StampState::Absent, _) if tty => ActivationState::ShellNotLoaded,
        (StampState::Absent, HeartbeatState::Fresh) => ActivationState::Healthy,
        (StampState::Absent, HeartbeatState::Old) => ActivationState::StaleShell,
    }
}

// ── The evidence, gathered ───────────────────────────────────────────

/// Everything the footer is computed from: both raw signals, the
/// generation they are judged against, and the facts about this
/// process they are compared to — its version, its session, its clock.
///
/// Reading this is the only IO on the path ([`Evidence::collect`]);
/// [`Evidence::state`] and [`Evidence::footer`] are pure functions of
/// it, so every state and every rendered string can be tested by
/// building one of these by hand.
#[derive(Debug, Clone)]
pub struct Evidence {
    /// What the calling shell's environment reports.
    pub stamp: EnvStamp,
    /// What the last shell to source init left behind, if any.
    pub heartbeat: Option<Heartbeat>,
    /// The generation a healthy shell would be running — see the
    /// module docs on who supplies it.
    pub reference: Option<u64>,
    /// Whether the generated init script contributes anything at all
    /// (`crate::shell::script_has_contributions`).
    pub script_has_contributions: bool,
    /// Whether this process is attached to a terminal — the session
    /// evidence that breaks the stampless tie (#279).
    pub tty: bool,
    /// The version of the binary rendering this footer.
    pub running_version: String,
    /// "Now", for the relative time on line two. An input so the
    /// rendered strings are assertable.
    pub now: SystemTime,
}

impl Evidence {
    /// Read both signals and the init script off disk.
    ///
    /// `stamp` and `tty` are passed in rather than read here so
    /// evaluation stays a function of its inputs: production snapshots
    /// both once into
    /// [`ExecutionContext`](crate::packs::orchestration::ExecutionContext)
    /// (via [`EnvStamp::read`] and an `isatty` check), tests hand over
    /// values. Callers that judge on the classic two-signal ladder
    /// alone (`up`, `install`) pass `tty: false`; only the callers that
    /// cannot measure supply real session evidence — see
    /// [`notice_for`].
    ///
    /// `None` when nothing has ever been deployed: with no init script
    /// on disk there is no hookup to have.
    pub fn collect(
        fs: &dyn Fs,
        paths: &dyn Pather,
        stamp: EnvStamp,
        reference: Option<u64>,
        tty: bool,
    ) -> Option<Evidence> {
        let script = read_script(fs, paths)?;
        Some(Evidence {
            stamp,
            heartbeat: read_heartbeat(fs, paths),
            reference,
            script_has_contributions: crate::shell::script_has_contributions(&script),
            tty,
            running_version: running_version().to_string(),
            now: SystemTime::now(),
        })
    }

    /// The version the *state* is decided on, or `None` when the
    /// evidence says nothing loaded.
    ///
    /// The stamp wins over the heartbeat for the same reason it wins in
    /// [`evaluate`]: it is direct evidence about the shell the user is
    /// typing in, where the heartbeat speaks for some past session. That
    /// makes this the right input to [`Evidence::skewed`] and the wrong
    /// input to line two — picking a winner between two signals is
    /// exactly what [`Evidence::evidence_line`] must not do, because the
    /// losing signal is where the timestamp comes from.
    ///
    /// Both arms answer `None` for evidence that did not parse — a
    /// version field is only ever read alongside a generation that read
    /// back ([`EnvStamp::evidence_version`], [`read_heartbeat`]), so
    /// garbage never renders as a loaded version.
    pub fn loaded_version(&self) -> Option<EvidenceVersion> {
        self.stamp
            .evidence_version()
            .or_else(|| self.heartbeat.as_ref().map(Heartbeat::evidence_version))
    }

    /// Whether the loaded version differs from the running one.
    ///
    /// Never true on an absent signal: with nothing loaded there is
    /// nothing to differ. Version-less evidence counts as a difference
    /// for every release after [`PRE_VERSION_RELEASE`] — see
    /// [`EvidenceVersion::is`] for why the bound release itself is the
    /// one exception.
    fn skewed(&self) -> bool {
        is_skewed(self.loaded_version().as_ref(), &self.running_version)
    }

    /// Fold everything into one state: the generation ladder
    /// ([`evaluate`]), then the two overrides ([`refine`]).
    pub fn state(&self) -> ActivationState {
        let ladder = evaluate(
            classify_stamp(self.stamp.generation, self.reference),
            classify_heartbeat(
                self.heartbeat.as_ref().map(|h| h.generation),
                self.reference,
            ),
            self.tty,
        );
        refine(ladder, self.skewed(), self.script_has_contributions)
    }

    /// Line two: when a shell last loaded dodot, and which dodot.
    ///
    /// Both halves of that sentence must describe the *same*
    /// activation. The two signals can describe two different ones —
    /// the stamp says which dodot the invoking shell loaded, the
    /// heartbeat's mtime says when *some* shell last loaded one — so
    /// pairing one's time with the other's version states a fact that
    /// never happened. The time therefore only ever appears beside the
    /// version of the event it belongs to:
    ///
    /// - one signal, or two naming the same dodot → one sentence,
    ///   carrying the mtime and that version;
    /// - a stamp with no heartbeat → the unknown-time sentence: a stamp
    ///   records which dodot this shell loaded, never when;
    /// - two signals naming different dodots → both, as two events,
    ///   with the time attached to the heartbeat's.
    ///
    /// The time itself is always the heartbeat file's mtime — a
    /// property of the last shell that *sourced* the script — never the
    /// generation written inside it, which is a property of the `up`
    /// that *wrote* it. Under the file-source hook those diverge
    /// permanently (spec §2.2).
    pub fn evidence_line(&self) -> String {
        let tail = if self.state() == ActivationState::VersionSkew {
            format!(" — you are running {}.", self.running_version)
        } else {
            ".".to_string()
        };
        let when = self.elapsed_since_last_run().map(relative);
        let beat = self.heartbeat.as_ref().map(Heartbeat::evidence_version);
        match (self.stamp.evidence_version(), beat) {
            (None, None) => "Never loaded.".to_string(),
            // A stamp with no heartbeat carries no time of its own, so
            // `when` is `None` here by construction.
            (Some(v), None) | (None, Some(v)) => one_event(when, &v, &tail),
            (Some(stamp), Some(beat)) if stamp == beat => one_event(when, &beat, &tail),
            (Some(stamp), Some(beat)) => two_events(when, &stamp, &beat, &tail),
        }
    }

    /// How long ago a shell last sourced the init script, from the
    /// heartbeat's mtime. `None` when there is no heartbeat, the
    /// filesystem would not say, or the mtime is in the future (a
    /// clock change, which is not a duration worth rendering).
    ///
    /// "No heartbeat" includes a corrupt one: an mtime is readable from
    /// a file whose contents are garbage, and reporting one would put a
    /// confident "last loaded 4 minutes ago" on evidence that says
    /// nothing loaded. [`read_heartbeat`] is where that is ruled out.
    fn elapsed_since_last_run(&self) -> Option<Duration> {
        let last_run = self.heartbeat.as_ref()?.last_run?;
        self.now.duration_since(last_run).ok()
    }

    /// Render the two-line footer.
    ///
    /// `scan` is the static rc scan ([`rc::scan_expected_rc`]), which
    /// only [`ActivationState::ShellNotLoaded`] consults for its next
    /// step; `hook_line` the manual line every "wire it up" hint
    /// carries.
    pub fn footer(
        &self,
        hook_line: &str,
        scan: Option<(HookPresence, String)>,
    ) -> ActivationNotice {
        ActivationNotice::for_state(self.state(), hook_line, scan, self.evidence_line())
    }
}

/// Line two when a single activation accounts for both fields.
fn one_event(when: Option<String>, version: &EvidenceVersion, tail: &str) -> String {
    match when {
        Some(ago) => format!("Last loaded {ago} by dodot {version}{tail}"),
        None => format!("Last loaded at an unknown time by dodot {version}{tail}"),
    }
}

/// Line two when the two signals describe two different activations.
///
/// Each clause keeps its own event's fields: the stamp names the dodot
/// the invoking shell loaded and nothing about when, the heartbeat's
/// version and mtime both belong to whichever shell wrote it last.
fn two_events(
    when: Option<String>,
    stamp: &EvidenceVersion,
    beat: &EvidenceVersion,
    tail: &str,
) -> String {
    match when {
        Some(ago) => format!(
            "This shell loaded dodot {stamp}; the last shell to load ran dodot {beat}, {ago}{tail}"
        ),
        None => {
            format!(
                "This shell loaded dodot {stamp}; the last shell to load ran dodot {beat}{tail}"
            )
        }
    }
}

/// Render a duration the way the footer says it: "4 minutes ago".
///
/// `timeago` with default features off, so no date-time crate enters
/// the tree and the edge cases of hand-rolled arithmetic stay someone
/// else's maintained problem. The one override is the below-a-minute
/// case, which reads better as "just now" than as the crate's "now" in
/// the sentence this lands in.
fn relative(elapsed: Duration) -> String {
    let mut formatter = timeago::Formatter::new();
    formatter.too_low("just now");
    formatter.convert(elapsed)
}

/// Evaluate the evidence and render the two-line footer, or `None` when
/// nothing has ever been deployed.
///
/// Silent before a first deploy: with no init script on disk there is
/// no hookup to have, and "no shell has loaded dodot yet" on a machine
/// where `dodot up` has never run is a warning about a non-problem.
///
/// `tty` is the session evidence (see [`evaluate`]); `shell_env` is
/// only consulted when the state is
/// [`ActivationState::ShellNotLoaded`], whose next step comes from the
/// static rc scan ([`rc::scan_expected_rc`]) — file reads, never a
/// shell spawn, so `status` stays inside spec §9.
pub fn notice_for(
    fs: &dyn Fs,
    paths: &dyn Pather,
    stamp: EnvStamp,
    reference: Option<u64>,
    tty: bool,
    shell_env: &rc::ShellEnv,
) -> Option<ActivationNotice> {
    let evidence = Evidence::collect(fs, paths, stamp, reference, tty)?;
    let hook_line = hook_line(&paths.init_script_path(), paths.home_dir());
    let scan = (evidence.state() == ActivationState::ShellNotLoaded)
        .then(|| rc::scan_expected_rc(fs, paths.home_dir(), shell_env, None))
        .flatten();
    Some(evidence.footer(&hook_line, scan))
}

/// The rendered two-line footer every `pack-status` render ends with.
///
/// `severity` names the presentation, not a failure level: `ok` for a
/// working hookup, `info` for one the next shell fixes, `warning` for
/// one no new shell will fix, `error` for one a measurement found
/// broken.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ActivationNotice {
    /// [`ActivationState::as_str`] — lets JSON consumers switch on the
    /// state without parsing the prose.
    pub state: String,
    /// `"ok"` | `"info"` | `"warning"` | `"error"`.
    pub severity: String,
    /// Line one: whether dodot is sourced in new shells.
    pub message: String,
    /// The fix, when line one reports a hookup that needs one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
    /// Line two: when a shell last loaded dodot, and which dodot —
    /// or, when the two signals disagree about which dodot, both
    /// activations rather than one blended from the pair
    /// ([`Evidence::evidence_line`]).
    pub evidence: String,
}

impl ActivationNotice {
    /// Build the footer for a state.
    ///
    /// The line-one strings are pinned here — the user documentation
    /// quotes them verbatim, and the tests assert them as exact
    /// strings for the same reason. `hook_line` is the rc-file line
    /// every "wire it up" hint offers (see [`hook_line`]); `scan` is
    /// the rc scan only [`ActivationState::ShellNotLoaded`] consults;
    /// `evidence` is line two, from [`Evidence::evidence_line`].
    pub fn for_state(
        state: ActivationState,
        hook_line: &str,
        scan: Option<(HookPresence, String)>,
        evidence: String,
    ) -> ActivationNotice {
        let (severity, message, hint): (&str, &str, Option<String>) = match state {
            ActivationState::Healthy => ("ok", HEALTHY_MESSAGE, None),
            ActivationState::VersionSkew => (
                "warning",
                "Shell hookup: your shells load a different dodot.",
                Some(
                    "Open a new shell. If that changes nothing, the `dodot` your shells run is \
                     a different install than the one you just ran — check which one your PATH \
                     finds first."
                        .into(),
                ),
            ),
            ActivationState::EmptyScript => (
                "info",
                "Shell hookup: wired, but no packs are deployed.",
                Some("Run `dodot up` to deploy your packs.".into()),
            ),
            ActivationState::StaleShell => (
                "info",
                "Shell hookup: this shell predates your last `dodot up`.",
                Some("Open a new shell to pick up the current deployment.".into()),
            ),
            ActivationState::NeverActivated => (
                "warning",
                "Shell hookup: no shell has loaded dodot yet.",
                Some(format!(
                    "Run `dodot install --write` to wire it up, or add this to your shell rc \
                     file yourself: {hook_line}"
                )),
            ),
            // The next step is whatever the rc scan found; the
            // headline states only what the evidence proves.
            ActivationState::ShellNotLoaded => (
                shell_not_loaded_severity(&scan),
                "Shell hookup: this shell did not load dodot.",
                Some(shell_not_loaded_hint(scan, hook_line)),
            ),
            // Only the probe reaches this state, and it renders its own
            // diagnosis (`probe::Verdict::notice`). This arm is the
            // answer for a caller that folds the state through the
            // evidence path anyway: same headline, generic next step.
            ActivationState::VerifiedBroken => (
                "error",
                VERIFIED_BROKEN_MESSAGE,
                Some(format!(
                    "Run `dodot install --write` to wire the hook, or add this to your shell rc \
                     file yourself: {hook_line}"
                )),
            ),
        };
        ActivationNotice {
            state: state.as_str().into(),
            severity: severity.into(),
            message: message.into(),
            hint,
            evidence,
        }
    }
}

/// How loudly to report [`ActivationState::ShellNotLoaded`].
///
/// A tty is not proof of a shell session — an IDE task runner
/// allocates one too, and legitimately reads no rc — so this is a
/// warning only when the rc scan found no hook, which is the one shape
/// no new shell will fix.
fn shell_not_loaded_severity(scan: &Option<(HookPresence, String)>) -> &'static str {
    match scan {
        Some((HookPresence::Absent, _)) => "warning",
        _ => "info",
    }
}

/// The next step for [`ActivationState::ShellNotLoaded`], from the
/// static scan of the rc the user's shell should be reading (`scan`:
/// presence + display path, as returned by [`rc::scan_expected_rc`]):
///
/// - hook absent → the rc has no hook, so no new shell will fix it;
///   name the file and the fix.
/// - hook present → this is most plausibly a shell opened before the
///   hook landed; open a new one, escalate to `dodot up` (which may
///   probe) if that changes nothing.
/// - `None` (unsupported shell, no rc to name) → just the manual line.
fn shell_not_loaded_hint(scan: Option<(HookPresence, String)>, hook_line: &str) -> String {
    match scan {
        Some((HookPresence::Absent, rc_path)) => format!(
            "{rc_path} doesn't have the dodot hook. Run `dodot install --write` to \
             wire it up, or add this line yourself: {hook_line}"
        ),
        Some((_, rc_path)) => format!(
            "The hook is in {rc_path}, so this shell probably predates it — open a \
             new shell. If that changes nothing, run `dodot up` to diagnose."
        ),
        None => format!(
            "dodot could not tell which rc file your shell reads. Make sure it has \
             this line: {hook_line}"
        ),
    }
}

/// Headline for a hookup the probe measured as broken. Shared so the
/// evidence path and [`crate::shell::probe`] can never drift into two
/// different phrasings of the same finding.
pub const VERIFIED_BROKEN_MESSAGE: &str = "Shell hookup: a new shell did not load dodot.";

/// Headline for a hookup the probe measured as working. The same
/// string [`ActivationState::Healthy`] renders: the probe's
/// contribution is that the claim is measured rather than inferred,
/// not a different claim.
pub const HEALTHY_MESSAGE: &str = "Shell hookup: dodot is sourced in new shells.";

/// The rc-file line that wires a shell up to the generated init
/// script, with the home prefix written back as `$HOME`.
///
/// `$HOME` rather than `~` because the path is quoted: a shell expands
/// `$HOME` inside double quotes but not `~`, so a tilde would make the
/// line source a literal `~` path once pasted. Quoting itself is not
/// optional — it is what keeps a home directory with spaces working.
///
/// The single source of truth for the hook line. `dodot install
/// --write` writes exactly this string inside its marked block
/// ([`crate::shell::rc`]), and every message that offers a manual
/// alternative prints it — one line, one definition.
pub fn hook_line(init_script_path: &Path, home: &Path) -> String {
    let shown = match init_script_path.strip_prefix(home) {
        Ok(rel) => format!("$HOME/{}", rel.display()),
        Err(_) => init_script_path.display().to_string(),
    };
    format!("[ -f \"{shown}\" ] && . \"{shown}\"")
}

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

    /// A fixed "now" for the pure footer tests, so every relative time
    /// they assert is a constant rather than a race with the clock.
    const NOW: SystemTime = SystemTime::UNIX_EPOCH;

    /// A `SystemTime` `seconds` before [`NOW`].
    fn ago(seconds: u64) -> Option<SystemTime> {
        Some(NOW - Duration::from_secs(seconds))
    }

    /// A heartbeat from its raw file contents and mtime. `raw` must
    /// parse: a corrupt file is not a `Heartbeat` at all, and the tests
    /// that cover that case say so with `heartbeat: None` or by
    /// planting the file and going through [`read_heartbeat`].
    fn heartbeat(raw: &str, last_run: Option<SystemTime>) -> Heartbeat {
        let (generation, version) = parse_heartbeat(raw);
        Heartbeat {
            generation: generation.expect("heartbeat fixture must carry a parseable generation"),
            version,
            last_run,
        }
    }

    /// The baseline the footer tests vary one field of: a shell running
    /// the current generation and the current version, four minutes
    /// ago, against a script that deploys something. Every state below
    /// is one deliberate deviation from this.
    fn skewless() -> Evidence {
        Evidence {
            stamp: EnvStamp::default(),
            heartbeat: Some(heartbeat("100 5.6.0", ago(4 * 60))),
            reference: Some(100),
            script_has_contributions: true,
            tty: false,
            running_version: "5.6.0".into(),
            now: NOW,
        }
    }

    /// Deploy one shell source so the generated init script carries a
    /// pack contribution — otherwise every script is the empty one and
    /// every healthy reading comes back as
    /// [`ActivationState::EmptyScript`].
    fn deploy_a_shell_source(env: &TempEnvironment) {
        let shell_dir = env.paths.handler_data_dir("vim", "shell");
        env.fs.mkdir_all(&shell_dir).unwrap();
        let target = env.home.join("aliases.sh");
        env.fs.write_file(&target, b"alias v=vim").unwrap();
        env.fs
            .symlink(&target, &shell_dir.join("aliases.sh"))
            .unwrap();
    }

    /// Write heartbeat contents, creating the directory the generated
    /// script's redirect would have relied on.
    fn plant_heartbeat(env: &TempEnvironment, contents: &str) {
        env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
        env.fs
            .write_file(&env.paths.hookup_heartbeat_path(), contents.as_bytes())
            .unwrap();
    }

    // ── The signal ladder, exhaustively ──────────────────────────────

    #[test]
    fn stamp_classification_covers_generation_comparisons() {
        let cases = [
            // (stamp, reference, expected)
            (None, Some(10), StampState::Absent),
            (None, None, StampState::Absent),
            (Some(10), Some(10), StampState::Current),
            (Some(11), Some(10), StampState::Current),
            (Some(9), Some(10), StampState::Stale),
            (Some(0), Some(10), StampState::Stale),
            // No script on disk: nothing to be stale against.
            (Some(9), None, StampState::Current),
        ];
        for (stamp, reference, expected) in cases {
            assert_eq!(
                classify_stamp(stamp, reference),
                expected,
                "stamp={stamp:?} reference={reference:?}"
            );
        }
    }

    #[test]
    fn heartbeat_classification_covers_generation_comparisons() {
        let cases = [
            (None, Some(10), HeartbeatState::Absent),
            (None, None, HeartbeatState::Absent),
            (Some(10), Some(10), HeartbeatState::Fresh),
            (Some(11), Some(10), HeartbeatState::Fresh),
            (Some(9), Some(10), HeartbeatState::Old),
            (Some(9), None, HeartbeatState::Fresh),
        ];
        for (heartbeat, reference, expected) in cases {
            assert_eq!(
                classify_heartbeat(heartbeat, reference),
                expected,
                "heartbeat={heartbeat:?} reference={reference:?}"
            );
        }
    }

    #[test]
    fn evaluation_covers_the_full_stamp_by_heartbeat_by_tty_matrix() {
        use ActivationState::*;
        use HeartbeatState as H;
        use StampState as S;
        // (stamp, heartbeat, detached expectation, tty expectation).
        // A stamp — direct evidence either way — makes tty irrelevant;
        // it only breaks the stampless tie (#279).
        let matrix = [
            (S::Current, H::Fresh, Healthy, Healthy),
            (S::Current, H::Old, Healthy, Healthy),
            (S::Current, H::Absent, Healthy, Healthy),
            (S::Stale, H::Fresh, StaleShell, StaleShell),
            (S::Stale, H::Old, StaleShell, StaleShell),
            (S::Stale, H::Absent, StaleShell, StaleShell),
            // The dead-hookup case: the heartbeat's old certificate
            // loses to the session in front of the user.
            (S::Absent, H::Fresh, Healthy, ShellNotLoaded),
            (S::Absent, H::Old, StaleShell, ShellNotLoaded),
            (S::Absent, H::Absent, NeverActivated, NeverActivated),
        ];
        for (stamp, heartbeat, detached, tty) in matrix {
            assert_eq!(
                evaluate(stamp, heartbeat, false),
                detached,
                "detached: stamp={stamp:?} heartbeat={heartbeat:?}"
            );
            assert_eq!(
                evaluate(stamp, heartbeat, true),
                tty,
                "tty: stamp={stamp:?} heartbeat={heartbeat:?}"
            );
        }
    }

    #[test]
    fn end_to_end_generation_matrix_through_collected_evidence() {
        // Same matrix, driven by raw generations through the IO entry
        // point: reference 100, stamps/heartbeats above and below it.
        // Every signal here carries the running version, so the
        // version-skew rule stays out of the way of the ladder.
        let cases = [
            (Some(100), Some(100), false, ActivationState::Healthy),
            (Some(99), Some(100), false, ActivationState::StaleShell),
            (None, Some(100), false, ActivationState::Healthy),
            (None, Some(99), false, ActivationState::StaleShell),
            (None, None, false, ActivationState::NeverActivated),
            (Some(100), None, false, ActivationState::Healthy),
            (Some(99), None, false, ActivationState::StaleShell),
            // Attached to a terminal, no stamp: the heartbeat cannot
            // certify this session, fresh or old.
            (None, Some(100), true, ActivationState::ShellNotLoaded),
            (None, Some(99), true, ActivationState::ShellNotLoaded),
            (None, None, true, ActivationState::NeverActivated),
            (Some(100), None, true, ActivationState::Healthy),
        ];
        for (stamp, heartbeat, tty, expected) in cases {
            let env = TempEnvironment::builder().build();
            deploy_a_shell_source(&env);
            crate::shell::write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None)
                .unwrap();
            if let Some(h) = heartbeat {
                plant_heartbeat(&env, &format!("{h} {}", running_version()));
            }
            let stamp = EnvStamp {
                generation: stamp,
                version: Some(running_version().into()),
            };
            let evidence =
                Evidence::collect(env.fs.as_ref(), env.paths.as_ref(), stamp, Some(100), tty)
                    .expect("a script is on disk");
            assert_eq!(
                evidence.state(),
                expected,
                "stamp={:?} heartbeat={heartbeat:?} tty={tty}",
                evidence.stamp.generation
            );
        }
    }

    // ── Parsing ──────────────────────────────────────────────────────

    #[test]
    fn unparseable_signals_read_as_absent_not_zero() {
        assert_eq!(parse_generation("  42\n"), Some(42));
        assert_eq!(parse_generation(""), None);
        assert_eq!(parse_generation("not-a-number"), None);
        assert_eq!(parse_generation("-1"), None);

        let env = TempEnvironment::builder().build();
        plant_heartbeat(&env, "garbage");
        assert_eq!(read_heartbeat(env.fs.as_ref(), env.paths.as_ref()), None);

        // The same rule on the version axis, both signals: a version
        // is only ever evidence alongside a generation that read back,
        // so an unparseable generation takes the version down with it
        // rather than leaving a version nobody can attribute to a run.
        assert_eq!(
            EnvStamp {
                generation: None,
                version: Some("9.9.9".into()),
            }
            .evidence_version(),
            None
        );
    }

    #[test]
    fn a_heartbeat_splits_into_generation_and_version() {
        assert_eq!(
            parse_heartbeat("1786830419 5.6.0\n"),
            (Some(1_786_830_419), Some("5.6.0".into()))
        );
        // The pre-RCS01 shape: generation only.
        assert_eq!(parse_heartbeat("1786830419"), (Some(1_786_830_419), None));
        assert_eq!(parse_heartbeat(""), (None, None));
    }

    #[test]
    fn script_generation_is_read_back_from_the_export_line() {
        assert_eq!(
            parse_script_generation("#!/bin/sh\nexport DODOT_INIT_GEN=1755200000\n"),
            Some(1_755_200_000)
        );
        assert_eq!(parse_script_generation("#!/bin/sh\nexport PATH=x\n"), None);

        let env = TempEnvironment::builder().build();
        // Missing script: no generation known.
        assert_eq!(
            read_script_generation(env.fs.as_ref(), env.paths.as_ref()),
            None
        );
    }

    // ── Version skew ─────────────────────────────────────────────────

    #[test]
    fn a_version_less_signal_renders_as_the_bound_not_as_unknown() {
        assert_eq!(EvidenceVersion::from_field(None).to_string(), "≤5.5.1");
        assert_eq!(
            EvidenceVersion::from_field(Some("  ")).to_string(),
            "≤5.5.1"
        );
        assert_eq!(
            EvidenceVersion::from_field(Some("5.6.0")).to_string(),
            "5.6.0"
        );
        assert!(!EvidenceVersion::PreVersion.is("5.6.0"));
        assert!(EvidenceVersion::Known("5.6.0".into()).is("5.6.0"));
        // The bound matches only the release it bounds: a binary that
        // is itself 5.5.1 cannot tell its own version-less evidence
        // apart from an older dodot's, so it claims no skew.
        assert!(EvidenceVersion::PreVersion.is(PRE_VERSION_RELEASE));
    }

    /// A pre-RCS01 heartbeat — generation, no version — is the shape
    /// every upgrading user has on disk. It must read as a bounded
    /// version, name both sides, and never panic.
    #[test]
    fn a_version_less_heartbeat_reports_skew_against_the_bound() {
        let evidence = Evidence {
            stamp: EnvStamp::default(),
            heartbeat: Some(heartbeat("1786830419", ago(4 * 60))),
            reference: Some(1_786_830_419),
            script_has_contributions: true,
            tty: false,
            running_version: "5.6.0".into(),
            now: NOW,
        };
        assert_eq!(evidence.state(), ActivationState::VersionSkew);
        assert_eq!(
            evidence.evidence_line(),
            "Last loaded 4 minutes ago by dodot ≤5.5.1 — you are running 5.6.0."
        );
    }

    #[test]
    fn skew_is_a_version_mismatch_never_staleness_alone() {
        // Stale generation, matching version: the existing stale-shell
        // state, whose fix (a new shell) is the right one.
        let stale = Evidence {
            stamp: EnvStamp {
                generation: Some(99),
                version: Some("5.6.0".into()),
            },
            ..skewless()
        };
        assert_eq!(stale.state(), ActivationState::StaleShell);

        // Current generation, different version: the failure the epic
        // exists to catch — wired, sourced, and running the wrong dodot.
        // Both signals name that same wrong dodot, so line two is the
        // one-event sentence the docs tabulate.
        let skewed = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.0.0".into()),
            },
            heartbeat: Some(heartbeat("100 5.0.0", ago(4 * 60))),
            ..skewless()
        };
        assert_eq!(skewed.state(), ActivationState::VersionSkew);
        assert_eq!(
            skewed.evidence_line(),
            "Last loaded 4 minutes ago by dodot 5.0.0 — you are running 5.6.0."
        );

        // Nothing loaded at all: no version to differ from.
        let never = Evidence {
            stamp: EnvStamp::default(),
            heartbeat: None,
            ..skewless()
        };
        assert_eq!(never.state(), ActivationState::NeverActivated);
        assert_eq!(never.evidence_line(), "Never loaded.");
    }

    /// The stamp speaks for the shell the user is typing in, so it
    /// decides the *state* the same way it decides the generation —
    /// but deciding the state is the whole of its authority. The
    /// timestamp belongs to the heartbeat's activation, so a stamp that
    /// outranks the heartbeat cannot take the heartbeat's time with it:
    /// "Last loaded 4 minutes ago by dodot 5.6.0" would report a moment
    /// at which no 5.6.0 shell loaded anything.
    #[test]
    fn a_disagreement_reports_two_events_never_one_blended_from_both() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.6.0".into()),
            },
            heartbeat: Some(heartbeat("100 5.0.0", ago(4 * 60))),
            ..skewless()
        };
        // The stamp still decides: this shell runs the current dodot.
        assert_eq!(evidence.state(), ActivationState::Healthy);
        let line = evidence.evidence_line();
        assert_eq!(
            line,
            "This shell loaded dodot 5.6.0; the last shell to load ran dodot 5.0.0, 4 minutes ago."
        );
        // The failure this replaces: one signal's time beside the
        // other's version.
        assert!(!line.contains("4 minutes ago by dodot 5.6.0"), "{line}");
    }

    /// The mirror case — the stamp is the *older* dodot, which is the
    /// skew story — and the same rule: the time stays with the
    /// heartbeat's version, and the "you are running" tail names the
    /// binary rendering the footer.
    #[test]
    fn a_skewed_stamp_beside_a_newer_heartbeat_keeps_both_events_intact() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.0.0".into()),
            },
            heartbeat: Some(heartbeat("100 5.6.0", ago(4 * 60))),
            ..skewless()
        };
        assert_eq!(evidence.state(), ActivationState::VersionSkew);
        assert_eq!(
            evidence.evidence_line(),
            "This shell loaded dodot 5.0.0; the last shell to load ran dodot 5.6.0, 4 minutes ago \
             — you are running 5.6.0."
        );
    }

    /// Two signals that name the same dodot describe one activation as
    /// far as the sentence is concerned, so it stays one sentence.
    #[test]
    fn agreeing_signals_render_a_single_event() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.6.0".into()),
            },
            heartbeat: Some(heartbeat("100 5.6.0", ago(4 * 60))),
            ..skewless()
        };
        assert_eq!(
            evidence.evidence_line(),
            "Last loaded 4 minutes ago by dodot 5.6.0."
        );
    }

    /// A stamp with no heartbeat has no time in it at all: the shell
    /// loaded *some* time before now, and the footer says exactly that
    /// rather than borrowing a timestamp from nowhere.
    #[test]
    fn a_stamp_without_a_heartbeat_reports_an_unknown_time() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.6.0".into()),
            },
            heartbeat: None,
            ..skewless()
        };
        assert_eq!(
            evidence.evidence_line(),
            "Last loaded at an unknown time by dodot 5.6.0."
        );
    }

    /// A disagreement with no readable mtime drops the time clause
    /// rather than the second event.
    #[test]
    fn a_disagreement_without_an_mtime_still_names_both_dodots() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.6.0".into()),
            },
            heartbeat: Some(heartbeat("100 5.0.0", None)),
            ..skewless()
        };
        assert_eq!(
            evidence.evidence_line(),
            "This shell loaded dodot 5.6.0; the last shell to load ran dodot 5.0.0."
        );
    }

    // ── Run time is not generation time ──────────────────────────────

    /// The measured divergence from the spec (§2.2): heartbeat content
    /// written when `up` generated the script, mtime written when a
    /// shell last sourced it. The footer must report the mtime.
    #[test]
    fn last_loaded_comes_from_the_heartbeat_mtime_not_its_contents() {
        let env = TempEnvironment::builder().build();
        deploy_a_shell_source(&env);
        crate::shell::write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
        // Content says 18:46 (when `up` wrote the script); the file was
        // last touched 45 minutes later, by the shell that sourced it.
        plant_heartbeat(&env, &format!("1786830419 {}", running_version()));
        let touched = SystemTime::now() - Duration::from_secs(45 * 60);
        env.fs
            .set_modified(&env.paths.hookup_heartbeat_path(), touched)
            .unwrap();

        let evidence = Evidence::collect(
            env.fs.as_ref(),
            env.paths.as_ref(),
            EnvStamp::default(),
            Some(1_786_830_419),
            false,
        )
        .unwrap();

        assert_eq!(
            evidence.evidence_line(),
            format!("Last loaded 45 minutes ago by dodot {}.", running_version()),
            "the generation inside the file is a write time, not a run time"
        );
    }

    /// Existence is not activation. A corrupt heartbeat offers two
    /// things that look like evidence and are not — a readable mtime
    /// and a present second field — and the footer must decline both:
    /// a file full of garbage proves no shell ever loaded dodot, so
    /// line two is "Never loaded.", not "Last loaded 45 minutes ago by
    /// dodot 5.6.0". This is the generation contract
    /// ([`parse_generation`]) held on the version and run-time axes too.
    #[test]
    fn a_corrupt_heartbeat_reads_as_no_activation_on_every_axis() {
        let env = TempEnvironment::builder().build();
        deploy_a_shell_source(&env);
        crate::shell::write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
        // Garbage where the generation belongs, and a field after it
        // shaped exactly like a version — the most tempting corruption.
        plant_heartbeat(&env, "not-a-generation 5.6.0");
        let touched = SystemTime::now() - Duration::from_secs(45 * 60);
        env.fs
            .set_modified(&env.paths.hookup_heartbeat_path(), touched)
            .unwrap();

        let evidence = Evidence::collect(
            env.fs.as_ref(),
            env.paths.as_ref(),
            EnvStamp::default(),
            Some(1_786_830_419),
            false,
        )
        .unwrap();

        assert_eq!(evidence.loaded_version(), None);
        assert_eq!(evidence.state(), ActivationState::NeverActivated);
        assert_eq!(
            evidence.evidence_line(),
            "Never loaded.",
            "neither the mtime nor the trailing field is evidence a shell loaded dodot"
        );
    }

    /// The version-less shape of the same corruption: no second field
    /// to mistake for a version, and the `≤` bound must not be reached
    /// for either — a corrupt file is not a pre-RCS01 activation.
    #[test]
    fn a_corrupt_heartbeat_is_not_a_pre_version_activation() {
        let evidence = Evidence {
            heartbeat: read_heartbeat_from(""),
            ..skewless()
        };
        assert_eq!(evidence.loaded_version(), None);
        assert_eq!(evidence.evidence_line(), "Never loaded.");
    }

    /// Plant `contents` as the heartbeat and read it back through the
    /// production path, so the corruption tests exercise the real gate
    /// rather than a hand-built `Heartbeat`.
    fn read_heartbeat_from(contents: &str) -> Option<Heartbeat> {
        let env = TempEnvironment::builder().build();
        plant_heartbeat(&env, contents);
        read_heartbeat(env.fs.as_ref(), env.paths.as_ref())
    }

    #[test]
    fn an_unreadable_mtime_says_so_instead_of_guessing() {
        let evidence = Evidence {
            heartbeat: Some(heartbeat("100 5.6.0", None)),
            ..skewless()
        };
        assert_eq!(
            evidence.evidence_line(),
            "Last loaded at an unknown time by dodot 5.6.0."
        );
    }

    // ── Footer strings, per state ────────────────────────────────────

    /// WS04's documentation quotes these, so they are pinned as exact
    /// strings — a reworded line is a doc bug, not a cosmetic change.
    #[test]
    fn every_state_renders_its_pinned_two_lines() {
        let cases = [
            (
                ActivationState::Healthy,
                "ok",
                "Shell hookup: dodot is sourced in new shells.",
            ),
            (
                ActivationState::VersionSkew,
                "warning",
                "Shell hookup: your shells load a different dodot.",
            ),
            (
                ActivationState::EmptyScript,
                "info",
                "Shell hookup: wired, but no packs are deployed.",
            ),
            (
                ActivationState::StaleShell,
                "info",
                "Shell hookup: this shell predates your last `dodot up`.",
            ),
            (
                ActivationState::NeverActivated,
                "warning",
                "Shell hookup: no shell has loaded dodot yet.",
            ),
            (
                ActivationState::ShellNotLoaded,
                "info",
                "Shell hookup: this shell did not load dodot.",
            ),
            (
                ActivationState::VerifiedBroken,
                "error",
                "Shell hookup: a new shell did not load dodot.",
            ),
        ];
        for (state, severity, message) in cases {
            let notice = ActivationNotice::for_state(state, "HOOK", None, "Never loaded.".into());
            assert_eq!(notice.state, state.as_str());
            assert_eq!(notice.severity, severity, "{state:?}");
            assert_eq!(notice.message, message, "{state:?}");
            assert_eq!(notice.evidence, "Never loaded.", "{state:?}");
        }
    }

    #[test]
    fn the_healthy_footer_reads_as_the_spec_tabulates_it() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.6.0".into()),
            },
            ..skewless()
        };
        let notice = evidence.footer("HOOK", None);
        assert_eq!(
            notice.message,
            "Shell hookup: dodot is sourced in new shells."
        );
        assert_eq!(notice.evidence, "Last loaded 4 minutes ago by dodot 5.6.0.");
        assert_eq!(notice.hint, None);
    }

    /// One rule, three occasions: after `down`, with every pack
    /// ignored, and after a first `up` that deployed nothing. All three
    /// reach here as "the script carries no contributions".
    #[test]
    fn a_contribution_less_script_says_so_instead_of_claiming_health() {
        let evidence = Evidence {
            stamp: EnvStamp {
                generation: Some(100),
                version: Some("5.6.0".into()),
            },
            script_has_contributions: false,
            ..skewless()
        };
        assert_eq!(evidence.state(), ActivationState::EmptyScript);

        // A hookup that is *also* broken has a bigger problem to report.
        let broken = Evidence {
            stamp: EnvStamp::default(),
            heartbeat: None,
            script_has_contributions: false,
            ..skewless()
        };
        assert_eq!(broken.state(), ActivationState::NeverActivated);
    }

    // ── Notices ──────────────────────────────────────────────────────

    #[test]
    fn never_activated_is_prominent_and_names_the_manual_hook() {
        let hook = hook_line(
            Path::new("/home/u/.local/share/dodot/shell/dodot-init.sh"),
            Path::new("/home/u"),
        );
        // `$HOME`, not `~`: the paths are quoted, and a quoted tilde
        // stays literal when the user pastes the line into their rc.
        assert_eq!(
            hook,
            "[ -f \"$HOME/.local/share/dodot/shell/dodot-init.sh\" ] && . \"$HOME/.local/share/dodot/shell/dodot-init.sh\""
        );

        let notice = ActivationNotice::for_state(
            ActivationState::NeverActivated,
            &hook,
            None,
            "Never loaded.".into(),
        );
        assert_eq!(notice.severity, "warning");
        assert!(notice.message.contains("no shell has loaded dodot yet"));
        let hint = notice.hint.unwrap();
        assert!(
            hint.contains(&hook),
            "hint should carry the hook line: {hint}"
        );
        // WS02 ships `dodot install`, so the hint now leads with the
        // command that does this for you — the manual line stays for
        // users who would rather wire it themselves.
        assert!(hint.contains("dodot install --write"), "hint: {hint}");
    }

    #[test]
    fn shell_not_loaded_lets_the_rc_scan_pick_the_next_step() {
        let not_loaded = |scan| {
            ActivationNotice::for_state(
                ActivationState::ShellNotLoaded,
                "HOOK",
                scan,
                "Never loaded.".into(),
            )
        };

        // Hook absent: no new shell fixes it — name the file and the fix.
        let absent = not_loaded(Some((HookPresence::Absent, "~/.zshrc".into())));
        assert_eq!(absent.state, "shell-not-loaded");
        assert_eq!(absent.severity, "warning");
        assert_eq!(
            absent.message,
            "Shell hookup: this shell did not load dodot."
        );
        let hint = absent.hint.unwrap();
        assert!(hint.contains("~/.zshrc") && hint.contains("dodot install --write"));
        assert!(!hint.contains("new shell"), "{hint}");

        // Hook present (managed or manual): an old shell is the likely
        // story — advise a new one, escalate to `up` after that.
        for presence in [HookPresence::ManagedBlock, HookPresence::Manual] {
            let present = not_loaded(Some((presence, "~/.zshrc".into())));
            assert_eq!(present.severity, "info");
            let hint = present.hint.unwrap();
            assert!(
                hint.contains("new shell") && hint.contains("dodot up"),
                "{hint}"
            );
        }

        // No rc to name (unsupported shell): just the manual line.
        let unknown = not_loaded(None);
        assert_eq!(unknown.severity, "info");
        assert!(unknown.hint.unwrap().contains("HOOK"));
    }

    #[test]
    fn stale_shell_says_open_a_new_shell() {
        let notice = ActivationNotice::for_state(
            ActivationState::StaleShell,
            "hook",
            None,
            "Never loaded.".into(),
        );
        assert_eq!(notice.severity, "info");
        assert!(notice.hint.unwrap().contains("Open a new shell"));
    }

    #[test]
    fn hook_line_outside_home_stays_absolute() {
        let hook = hook_line(
            Path::new("/opt/dodot/shell/dodot-init.sh"),
            Path::new("/home/u"),
        );
        assert!(hook.contains("/opt/dodot/shell/dodot-init.sh"), "{hook}");
        assert!(!hook.contains("$HOME"), "{hook}");
    }
}