pitboard-core 0.14.0

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

use crate::doctor::{Level, renewal_due};
use crate::host::{Os, WINDOWS_FLOOR, token};
use crate::usage::pace::{Pace, Standing};
use crate::usage::whole;
use std::fmt::Write as _;

const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const WEEK: i64 = 7 * DAY;

/// A length of time to the precision a person reads: "6d 4h", "2h 05m", "47m", "<1m".
///
/// Minutes beside hours take two digits, so a column of them lines up as they tick. Under a
/// minute is "<1m": "0m" reads as though nothing were left when the difference is seconds
/// either way.
pub fn span(seconds: i64) -> String {
    let s = seconds.max(0);
    let (days, hours, minutes) = (s / DAY, s % DAY / HOUR, s % HOUR / MINUTE);
    match (days, hours) {
        (0, 0) if minutes == 0 => "<1m".into(),
        (0, 0) => format!("{minutes}m"),
        (0, h) => format!("{h}h {minutes:02}m"),
        (d, h) => format!("{d}d {h}h"),
    }
}

/// A limit in the column form, beside its bar: "5h", "week", "day", "3d", "30m", "45s", and
/// "week · Fable" for one scoped to a model.
///
/// By how long it runs, where that is known, and otherwise by its kind. The length is what
/// makes two tools' limits comparable: OpenAI times every window and names none, Anthropic
/// names every window and times none, and Pitboard knows the length of each either way. A
/// kind is only the fallback, for a reading remembered from before the length was kept.
pub fn limit_column(kind: &str, length_seconds: Option<i64>, scope: Option<&str>) -> String {
    let base = match length_seconds.filter(|s| *s > 0) {
        Some(WEEK) => "week".into(),
        Some(DAY) => "day".into(),
        Some(s) if s % DAY == 0 => format!("{}d", s / DAY),
        Some(s) if s % HOUR == 0 => format!("{}h", s / HOUR),
        Some(s) if s % MINUTE == 0 => format!("{}m", s / MINUTE),
        Some(s) => format!("{s}s"),
        None => match kind {
            "session" | "five_hour" => "5h".into(),
            "weekly_all" | "seven_day" | "weekly_scoped" => "week".into(),
            other => other.into(),
        },
    };
    match scope {
        Some(scope) => format!("{base} · {scope}"),
        None => base,
    }
}

/// A limit in the sentence form: "5-hour", "weekly", "daily", "3-day", "30-minute".
///
/// From its length where that is known, as [`limit_column`] is: "session" means nothing to
/// somebody reading about a Codex account. Without its scope, which the sentence places
/// itself, as in "weekly Fable 98%".
pub fn limit_name(kind: &str, length_seconds: Option<i64>) -> String {
    match length_seconds.filter(|s| *s > 0) {
        Some(WEEK) => "weekly".into(),
        Some(DAY) => "daily".into(),
        Some(s) if s % DAY == 0 => format!("{}-day", s / DAY),
        Some(s) if s % HOUR == 0 => format!("{}-hour", s / HOUR),
        Some(s) if s % MINUTE == 0 => format!("{}-minute", s / MINUTE),
        Some(s) => format!("{s}-second"),
        None => match kind {
            "session" | "five_hour" => "5-hour".into(),
            "seven_day" => "weekly".into(),
            weekly if weekly.starts_with("weekly") => "weekly".into(),
            other => other.replace('_', " "),
        },
    }
}

/// How much of a limit an account has used, in a sentence about an automatic switch:
/// "96% of its 5-hour limit", or "97% of its weekly Opus limit" for one limit of a model.
pub fn share_of_limit(limit: &crate::usage::Window) -> String {
    let name = scoped_limit_name(&limit.kind, limit.length_seconds, limit.scope.as_deref());
    format!("{}% of its {name} limit", whole(limit.percent))
}

/// What the automatic switch watches where it has nothing to do: "Watching work: 62% of its
/// 5-hour limit, as of 16:40." `used` is [`share_of_limit`] of its fullest limit, and the
/// clock times come as each front end writes them: `as_of`, when that was read, and
/// `held_until`, until when Anthropic holds Pitboard off asking about the account.
pub fn watching(
    account: &str,
    used: Option<&str>,
    as_of: Option<&str>,
    held_until: Option<&str>,
) -> String {
    let mut said = format!("Watching {account}");
    if let Some(used) = used {
        let _ = write!(said, ": {used}");
    }
    if let Some(as_of) = as_of {
        let _ = write!(said, ", as of {as_of}");
    }
    said.push('.');
    if let Some(until) = held_until {
        let _ = write!(
            said,
            " Anthropic holds Pitboard off asking again until {until}."
        );
    }
    said
}

/// Why Pitboard does not switch Claude Code away from a limit at `threshold`, as a clause
/// that says what to do about it where anything can be done. It names no command, and no
/// time, which each front end says in its own way: the app says it too.
pub fn not_switching(
    why: &crate::duty::autoswitch::Skip,
    threshold: crate::duty::autoswitch::Threshold,
) -> String {
    use crate::duty::autoswitch::{SETTLING_SECONDS, Skip};
    use crate::duty::cadence::ATTEMPTS;
    match why {
        Skip::NoRoom { unread } => {
            let room = format!(
                "no other Claude Code account has room below {}% in every limit",
                threshold.percent()
            );
            if unread.is_empty() {
                room
            } else {
                format!(
                    "{room}, and there is no reading of {} from Anthropic yet",
                    listed(unread.clone())
                )
            }
        }
        Skip::AlreadyLeft => {
            "this limit was switched away from once already, and is not again before it resets"
                .into()
        }
        Skip::GaveUp => format!(
            "{ATTEMPTS} attempts to switch away from this limit failed, and none is made again \
             before it resets"
        ),
        Skip::Settling { .. } => format!(
            "the account was put in use less than {} minutes ago",
            SETTLING_SECONDS / 60
        ),
        Skip::Overridden(names) => format!(
            "Claude Code signs in another way, set by {}, so a switch would change nothing its \
             sessions use",
            names.join(", ")
        ),
    }
}

const INTERRUPTED: &str = "a switch was interrupted, and the next change you make finishes it";
const CUSTOM_OAUTH: &str =
    "Claude Code uses a custom OAuth endpoint, and Pitboard does not act on Claude Code then";

/// Why Pitboard cannot judge whether to switch Claude Code by itself, as a clause that says
/// what to do about it where anything can be done. It names no command, and no time, which
/// each front end says in its own way.
pub fn not_watching(why: &crate::duty::autoswitch::Blind) -> String {
    use crate::duty::autoswitch::Blind;
    match why {
        Blind::SwitchInterrupted => INTERRUPTED.into(),
        Blind::CustomOauth => CUSTOM_OAUTH.into(),
        Blind::NothingSignedIn => "Claude Code has no login stored".into(),
        Blind::NotEnrolled { email } => {
            format!("Claude Code has {email}'s login stored, and that account is not enrolled")
        }
        Blind::Unidentified { detail, .. } => {
            format!("whose login Claude Code has stored could not be told ({detail})")
        }
        Blind::NoReading { account } => {
            format!("there is no reading of {account} from Anthropic yet")
        }
    }
}

/// Why Pitboard warms up no account on this machine for now, as a clause.
pub fn not_warming(why: &crate::duty::warm::Halt) -> String {
    use crate::duty::warm::Halt;
    match why {
        Halt::Unbuilt => "Pitboard does not warm up accounts on this system yet".into(),
        Halt::SwitchInterrupted => INTERRUPTED.into(),
        Halt::CustomOauth => CUSTOM_OAUTH.into(),
        Halt::Overridden(names) => format!(
            "Claude Code's managed settings make it sign in another way, set by {}",
            names.join(", ")
        ),
        Halt::ProgramMissing => "Claude Code is not installed where Pitboard looks".into(),
    }
}

/// Why a warm-up started nothing, as a clause.
pub(crate) fn warm_failed(why: &crate::duty::warm::Failure) -> String {
    use crate::duty::warm::Failure;
    match why {
        Failure::LimitReached => {
            "a limit of it is used up, and Anthropic refused the request".into()
        }
        Failure::LoginRefused => "Anthropic refused its login; sign in to it again".into(),
        Failure::NotStarted => {
            "the request was answered, and Anthropic still has no period running".into()
        }
        Failure::Unreachable => "Anthropic could not be reached".into(),
        Failure::TimedOut => "Claude Code did not finish in time".into(),
        Failure::Failed(said) => format!("Claude Code failed ({said})"),
    }
}

/// Where an account turned on for warm-ups stands, as what follows its name: "work warms up
/// again at 22:50", "work has its weekly limit used up until Oct 15 03:00". Each moment is
/// written as `moment` writes it, the command line's way or the app's.
pub fn warm_standing(
    standing: &crate::duty::warm::Standing,
    moment: &dyn Fn(i64) -> String,
) -> String {
    use crate::duty::warm::{Hold, Standing};
    match standing {
        Standing::Due => "warms up now".into(),
        Standing::Running {
            until,
            next: Some(next),
        } if next == until => format!("warms up again at {}", moment(*next)),
        Standing::Running {
            until,
            next: Some(next),
        } => format!(
            "has its 5-hour period running until {}, and warms up again at {}",
            moment(*until),
            moment(*next)
        ),
        Standing::Running { until, next: None } => {
            format!("has its 5-hour period running until {}", moment(*until))
        }
        Standing::Resting { at } => format!("warms up at {}", moment(*at)),
        Standing::Waiting { until } => format!(
            "tries again at {}, after a warm-up came to nothing",
            moment(*until)
        ),
        Standing::Held(Hold::NoReading) => "waits for a reading from Anthropic".into(),
        Standing::Held(Hold::NothingToStart) => "has no 5-hour limit to start".into(),
        Standing::Held(Hold::LimitUsedUp { limit }) => {
            let name = scoped_limit_name(&limit.kind, limit.length_seconds, limit.scope.as_deref());
            match limit.resets_at {
                Some(at) => format!("has its {name} limit used up until {}", moment(at)),
                None => format!("has its {name} limit used up"),
            }
        }
        Standing::Held(Hold::GaveUp { until }) => format!(
            "tries again at {}, after {} warm-ups came to nothing today",
            moment(*until),
            crate::duty::cadence::ATTEMPTS
        ),
        Standing::Held(Hold::LoginExpired) => {
            "needs signing in again: its parked login has expired".into()
        }
        Standing::Held(Hold::NothingParked) => {
            "has no login to warm up with: it is not in use, and nothing is parked".into()
        }
    }
}

/// What sessions already running do after a switch while a file sits behind the store they
/// read, as the register's `fallback_file_pins_session_login` reads Claude Code 2.1.294. The
/// rest of the sentence, and its subject, are each place's own.
pub fn kept_until_renewed() -> &'static str {
    "keep the account they are on until their login is next renewed, or until they are \
     started again"
}

/// What is said where nothing is left behind the store Claude Code keeps its login in, as
/// `pitboard stow` and the app's sheet say it. On Linux the file is that store.
pub fn nothing_left() -> &'static str {
    "Nothing is left behind the store Claude Code keeps its login in, so there is nothing to \
     put away."
}

/// What `pitboard stow` and the app's sheet say of the file behind Claude Code's store before
/// it is put away, a paragraph each: what it holds and what putting it away would do with
/// that, the keys that would go with it, and what becomes of Claude Code's sessions once it is
/// gone. Of a login of an account nobody enrolled, whose it is and what to do first.
pub fn left_lines(left: &crate::switch::Left) -> Vec<String> {
    use crate::switch::{Foreseen, Kept};
    if let Foreseen::NotEnrolled(_) = left.login {
        return vec![left_login(left)];
    }
    let held_a_login = left.login != Foreseen::Kept(Kept::NoLogin);
    std::iter::once(left_login(left))
        .chain(dropped_keys(&left.dropped, false))
        .chain(std::iter::once(once_stowed(held_a_login)))
        .collect()
}

/// What they say once it is put away, a paragraph each.
pub fn stowed_lines(stowed: &crate::switch::Stowed) -> Vec<String> {
    let held_a_login = stowed.kept != crate::switch::Kept::NoLogin;
    std::iter::once(stowed_login(stowed))
        .chain(dropped_keys(&stowed.dropped, true))
        .chain(std::iter::once(once_stowed(held_a_login)))
        .collect()
}

/// What the file holds, and what putting it away would do with the login in it.
fn left_login(left: &crate::switch::Left) -> String {
    use crate::switch::{Foreseen, Kept};
    let path = left.path.display();
    match &left.login {
        Foreseen::Kept(Kept::NoLogin) => {
            format!("{path} holds no Claude Code login. Putting it away deletes it.")
        }
        Foreseen::Kept(Kept::Refused) => format!(
            "{path} holds a Claude Code login Anthropic no longer accepts. Putting it away \
             deletes it."
        ),
        Foreseen::Kept(Kept::Stored { label }) => format!(
            "{path} holds the login Claude Code has stored{}. Putting it away deletes the file, \
             and that login stays where it is.",
            of_account(label.as_deref())
        ),
        Foreseen::Kept(Kept::AlreadyParked { label }) => format!(
            "{path} holds the login Pitboard keeps parked for `{label}`. Putting it away deletes \
             the file, and the parked login stays."
        ),
        Foreseen::Kept(Kept::SecondSignIn { label }) => format!(
            "{path} holds another login of `{label}`, the account in use, whose own login Claude \
             Code has stored. Putting it away drops this second sign-in with the file."
        ),
        Foreseen::Kept(Kept::ParkedNow { label }) => format!(
            "{path} holds `{label}`'s login, and Pitboard holds no parked login of `{label}` to \
             keep in its place. Putting it away parks this one for `{label}`, then deletes the \
             file."
        ),
        Foreseen::Kept(Kept::ParkKept { label }) => format!(
            "{path} holds another login of `{label}`, which keeps the login Pitboard has parked \
             for it. Putting it away deletes the file."
        ),
        Foreseen::NotEnrolled(owner) => format!(
            "{path} holds {}'s login{}, an account not enrolled here, so Pitboard cannot put it \
             away. Enrol that account first, with `pitboard enroll <label> --sign-in`, or with \
             `pitboard enroll <label>` while Claude Code is signed in to it.",
            owner.email,
            in_organisation(&owner.organization_uuid)
        ),
        Foreseen::Untold => format!(
            "{path} holds a Claude Code login whose access token has expired. Putting it away \
             renews it and writes it back to the file first, then asks Anthropic whose it is, \
             and goes on only for an account enrolled here."
        ),
    }
}

/// What putting the file away did with the login it held.
fn stowed_login(stowed: &crate::switch::Stowed) -> String {
    use crate::switch::Kept;
    let path = stowed.path.display();
    match &stowed.kept {
        Kept::NoLogin => format!("Deleted {path}, which held no Claude Code login."),
        Kept::Refused => format!(
            "Deleted {path}: Anthropic no longer accepts the login it held, so that login was \
             no longer valid."
        ),
        Kept::Stored { label } => format!(
            "Deleted {path}, which held the login Claude Code has stored{}. That login stays \
             where it is.",
            of_account(label.as_deref())
        ),
        Kept::AlreadyParked { label } => {
            format!("Deleted {path}, which held the login Pitboard keeps parked for `{label}`.")
        }
        Kept::SecondSignIn { label } => format!(
            "Deleted {path}, which held a second sign-in of `{label}`, the account in use. That \
             login was dropped; the one Claude Code has stored stays."
        ),
        Kept::ParkedNow { label } => {
            format!("Parked the login {path} held for `{label}`, then deleted the file.")
        }
        Kept::ParkKept { label } => format!(
            "Deleted {path}, which held another login of `{label}`. `{label}` keeps the login \
             Pitboard had parked for it."
        ),
    }
}

/// The other keys the file holds, which go with it unmoved, by name and how many: `None`
/// where it holds none. `gone` once the file has gone.
fn dropped_keys(keys: &[String], gone: bool) -> Option<String> {
    let one = match keys.len() {
        0 => return None,
        n => n == 1,
    };
    let names = listed(keys.iter().map(|key| format!("`{key}`")).collect());
    let (holds, goes) = match (gone, one) {
        (false, true) => ("holds", "goes"),
        (false, false) => ("holds", "go"),
        (true, _) => ("held", "went"),
    };
    let (count, them) = if one {
        ("1 other key".to_string(), "it")
    } else {
        (format!("{} other keys", keys.len()), "them")
    };
    Some(format!(
        "It also {holds} {count}, {names}, which {goes} with the file: Pitboard does not move \
         {them}, and Claude Code's document in the keychain keeps its own."
    ))
}

/// What becomes of Claude Code's sessions once the file is gone: each takes the login stored
/// again, and one that signed in with a login in the file, as one over SSH does, is signed
/// out.
fn once_stowed(held_a_login: bool) -> String {
    let follow = format!(
        "Once the file is gone, Claude Code sessions already running follow a switch within \
         {} seconds again",
        crate::switch::ADOPTION_CEILING_SECONDS
    );
    if held_a_login {
        format!(
            "{follow}, and one that signed in with its login, such as one over SSH, is signed \
             out."
        )
    } else {
        format!("{follow}.")
    }
}

/// `label`'s, after a login that is an enrolled account's, where it is one.
fn of_account(label: Option<&str>) -> String {
    label.map_or_else(String::new, |label| format!(", `{label}`'s"))
}

/// The organisation a Claude Code login is of, as a sentence names it after its email: none
/// for an account outside one, which Anthropic reports as an empty id.
pub(crate) fn in_organisation(organization: &str) -> String {
    if organization.is_empty() {
        String::new()
    } else {
        format!(", in organisation {organization}")
    }
}

/// How much of a limit is used, in three steps. The command line's colours and the app's
/// tints change where these do, and the words always say the number itself, because not
/// everybody sees a colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageLevel {
    /// Under 70%.
    Plenty,
    /// From 70%.
    Low,
    /// From 90%, and past 100%.
    Out,
}

/// The step a limit is at, from the share of it used: `percent` as a reading gives it, which
/// passes 100 when a service reports more used than the limit. Taken as its figure is
/// drawn, so a limit drawn at 90% is in the colour of 90%.
pub fn usage_level(percent: f64) -> UsageLevel {
    match whole(percent) {
        90.. => UsageLevel::Out,
        70.. => UsageLevel::Low,
        _ => UsageLevel::Plenty,
    }
}

/// When a limit resets, beside its bar: "resets in 2h 05m", and "resetting now" once that
/// moment has come. Whether it did reset is the next reading's to say, and until then the
/// figure beside it is the last one measured.
pub fn resets(at: i64, now: i64) -> String {
    if at <= now {
        "resetting now".into()
    } else {
        format!("resets in {}", span(at - now))
    }
}

/// How far a limit is from an even pace, beside its bar: "27% over pace", "13% under
/// pace", "on pace". Over and under rather than ahead and behind, since ahead reads as good
/// news and is the warning.
pub fn pace_column(pace: &Pace) -> String {
    let points = whole(pace.delta.abs());
    match pace.standing {
        Standing::Over { .. } => format!("{points}% over pace"),
        Standing::Under => format!("{points}% under pace"),
        Standing::Even => "on pace".into(),
    }
}

/// A limit's name in a sentence with its model, where it is scoped to one: "weekly",
/// "weekly Fable", "5-hour".
pub fn scoped_limit_name(kind: &str, length_seconds: Option<i64>, scope: Option<&str>) -> String {
    let name = limit_name(kind, length_seconds);
    match scope {
        Some(scope) => format!("{name} {scope}"),
        None => name,
    }
}

/// The limit of an account that runs out first at its pace, named as a sentence names it:
/// "weekly limit runs out in 19h 05m at this pace". Under a minute it is "about to run
/// out", which reads more plainly than "<1m".
pub fn runs_out(limit: &str, in_seconds: i64) -> String {
    if in_seconds < MINUTE {
        format!("{limit} limit is about to run out")
    } else {
        format!(
            "{limit} limit runs out in {} at this pace",
            span(in_seconds)
        )
    }
}

/// How long a parked login stays usable, in the sentence form: a note under an account not
/// in use, counted in whole days. Without it nothing says a switch to the account is about
/// to stop working. Nothing when the login says nothing about it.
pub fn parked_life(refresh_expires_at: Option<i64>, now: i64) -> Option<String> {
    let left = refresh_expires_at? - now;
    Some(match left / DAY {
        _ if left <= 0 => "Its parked login has expired".into(),
        0 => "Parked login good for under a day".into(),
        1 => "Parked login good for 1 more day".into(),
        days => format!("Parked login good for {days} more days"),
    })
}

/// How long a parked login stays usable, in the column form `pitboard status` puts after
/// "ready" and doctor's parked login check after words of its own: "good for 20d 0h";
/// "expires in 2d 4h" once it is due to be renewed, as [`renewal_due`] decides; and
/// "expired" once it has, where doctor adds when and `pitboard status` says "login expired"
/// in place of "ready".
pub fn parked_life_column(refresh_expires_at: i64, now: i64) -> String {
    match refresh_expires_at - now {
        left if left <= 0 => "expired".into(),
        left if renewal_due(refresh_expires_at, now) => format!("expires in {}", span(left)),
        left => format!("good for {}", span(left)),
    }
}

/// What a renewal run did: how many parked logins were due, and how many of those were
/// renewed. Everything due was going to expire, so nothing due is the good answer, and it
/// has to read like one rather than like a failure to do anything. The sentence does not say
/// why the rest were not renewed, and they fare differently: one whose service could not be
/// reached or asked for less traffic is tried again on the next run, one the service refused
/// has been dropped and its account needs a sign-in, and one that failed is named by its
/// error code in `pitboard renew --json`.
pub fn renewal_note(due: usize, renewed: usize) -> String {
    match (due, renewed) {
        (0, _) => "No parked login was due.".into(),
        (due, 0) => format!("{due} due; none could be renewed this time."),
        (1, 1) => "Renewed one.".into(),
        (due, done) if due == done => format!("Renewed all {done}."),
        (due, done) => format!("Renewed {done} of {due}; the rest are tried again next time."),
    }
}

/// A login as a message names it: by its email, and as another organisation's, or for
/// Codex another workspace's, where the account it is told apart from has that email too.
pub(crate) fn login(tool: crate::provider::ProviderId, email: &str, shares_email: bool) -> String {
    use crate::provider::ProviderId;
    let group = match tool {
        ProviderId::Claude => "organisation",
        ProviderId::Codex => "workspace",
        // Nobody tells Pitboard a Claude Desktop account's email.
        ProviderId::Desktop if email.is_empty() => return "another Claude Desktop account".into(),
        ProviderId::Desktop => "account",
    };
    if !shares_email {
        return email.to_owned();
    }
    format!("{email} in another {group}")
}

// What signs a tool in, to start a sentence (`starts`) or to go on with one.
pub(crate) fn sign_in_yourself(tool: crate::provider::ProviderId, starts: bool) -> String {
    use crate::provider::ProviderId;
    let said = match tool {
        ProviderId::Claude => "Run `claude`",
        ProviderId::Codex => "Run `codex login`",
        ProviderId::Desktop => "Open Claude",
    };
    starting(said, starts)
}

// What runs a tool once, as its sessions do.
pub(crate) fn run_it(tool: crate::provider::ProviderId, starts: bool) -> String {
    use crate::provider::ProviderId;
    let said = match tool {
        ProviderId::Claude => "Run `claude`",
        ProviderId::Codex => "Run `codex`",
        ProviderId::Desktop => "Open Claude",
    };
    starting(said, starts)
}

// What reads a tool's login from where it was, where this run reads another place.
pub(crate) fn pointing_where_it_did(tool: crate::provider::ProviderId) -> String {
    use crate::provider::ProviderId;
    match tool {
        ProviderId::Claude => "Run Pitboard with CLAUDE_CONFIG_DIR pointing where it did",
        ProviderId::Codex => "Run Pitboard with CODEX_HOME pointing where it did",
        ProviderId::Desktop => "Run Pitboard as the account whose Claude that is",
    }
    .into()
}

fn starting(said: &str, starts: bool) -> String {
    if starts {
        return said.into();
    }
    let mut chars = said.chars();
    chars
        .next()
        .map(|first| first.to_lowercase().chain(chars).collect())
        .unwrap_or_default()
}

pub(crate) fn nothing_signed_in(tool: crate::provider::ProviderId, label: Option<&str>) -> String {
    use crate::provider::ProviderId;
    match tool {
        ProviderId::Claude | ProviderId::Codex => format!(
            "nothing is signed in to {} right now. {}, sign in, then try again.",
            tool.name(),
            sign_in_yourself(tool, true)
        ),
        ProviderId::Desktop => format!(
            "Claude is not signed in. Run `pitboard enroll {} --sign-in`, then sign in inside \
             Claude and quit it: Pitboard waits for that, and sets aside any account name \
             Claude kept first.",
            label.unwrap_or("desktop/<label>")
        ),
    }
}

// What a sign-in inside Claude says while Pitboard waits. Labels are as typed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WaitLine<'a> {
    Parked { label: &'a str },
    Add { label: &'a str },
    Again { label: &'a str },
    NoLogOut,
    Waiting { put_back: Option<&'a str> },
    Open,
    QuitSignedOut,
    QuitUnfinished,
}

// One line, ending in a newline.
pub(crate) fn awaited(line: WaitLine<'_>) -> String {
    let said = match line {
        WaitLine::Parked { label } => format!(
            "Parked `{label}`. Claude is signed out, and nothing was signed out at claude.ai."
        ),
        WaitLine::Add { label } => format!(
            "Open Claude and sign in to the account to add as `{label}`. When Claude shows \
             your chats, quit it with Command-Q."
        ),
        WaitLine::Again { label } => format!(
            "Open Claude and sign in to `{label}` again. When Claude shows your chats, quit it \
             with Command-Q."
        ),
        WaitLine::NoLogOut => "Do not choose Log out in Claude: it ends the login Claude holds \
                              at claude.ai, and Pitboard cannot bring it back."
            .into(),
        WaitLine::Waiting {
            put_back: Some(label),
        } => format!(
            "Waiting for Claude to quit. Control-C stops waiting and keeps `{label}` parked; \
             `pitboard use {label}` puts it back."
        ),
        WaitLine::Waiting { put_back: None } => {
            "Waiting for Claude to quit. Control-C stops waiting.".into()
        }
        WaitLine::Open => "Claude is open.".into(),
        WaitLine::QuitSignedOut => "Claude quit with nobody signed in. Still waiting.".into(),
        WaitLine::QuitUnfinished => "Claude quit before it finished signing in. Open it, wait \
                                    until it shows your chats, then quit it. Still waiting."
            .into(),
    };
    format!("{said}\n")
}

// `label`, `put_back` as typed.
pub(crate) fn sign_in_awaited(label: &str, put_back: &str, again: bool) -> String {
    let whom = if again {
        format!("`{label}` again")
    } else {
        "another account".to_string()
    };
    format!(
        "Claude is signed out so you can sign in to {whom}, and `{put_back}` is parked. Sign in \
         inside Claude, quit it, then run `pitboard enroll {label}`. To go back, run `pitboard \
         use {put_back}`."
    )
}

pub(crate) fn move_waiting(label: &str) -> String {
    format!(
        "A move of `{label}`'s login was interrupted. Keep Claude closed: the next change to a \
         Claude Desktop account settles it first."
    )
}

// Why a waiting move was left as it was, and what to do, for a refusal `abandon` and `repair`
// both meet. `command` is the one to run again.
fn move_left(why: &crate::error::Error, command: &str) -> Option<(String, String)> {
    use crate::error::Error;
    let again = format!("run `{command}` again");
    Some(match why {
        Error::AppOpen { app, still } if still.is_empty() => (
            format!("{app} is open"),
            format!("Quit {app} with Command-Q, then {again}."),
        ),
        Error::AppOpen { app, still } => (
            format!("{} from {app} is still running", listed(still.clone())),
            wait_for_still(&again),
        ),
        Error::AppStateUnknown { app, why } => (
            format!("it could not tell whether {app} is open ({why})"),
            format!("Run `{command}` again."),
        ),
        Error::LoginFolderBusy { app } => (
            format!("another Pitboard is changing {app}'s login"),
            format!("Run `{command}` again once it has finished."),
        ),
        Error::LoginFolderUnreadable { tool, detail } => (
            format!(
                "it could not read {}'s cookie store ({detail})",
                tool.program()
            ),
            format!(
                "Open {program}, quit it with Command-Q, then {again}.",
                program = tool.program()
            ),
        ),
        Error::AppOpenedMidway { app, .. } => (
            format!("{app} was opened meanwhile"),
            format!("Nothing is lost. Quit {app} with Command-Q, then {again}."),
        ),
        // A park Pitboard could not look at, as `store::folder::unread` says it.
        Error::Store(crate::store::Error::Unreadable(detail)) => (
            detail.clone(),
            format!("Make that path, and each folder it is in, readable to you, then {again}."),
        ),
        _ => return None,
    })
}

// After `abandon` gave up a switch, so never that nothing has changed.
pub(crate) fn move_not_given_up(label: &str, why: &crate::error::Error) -> String {
    use crate::error::Error;
    let (because, then) = match (move_left(why, "pitboard abandon"), why) {
        (Some((because, then)), _) => (Some(because), then),
        (None, Error::MoveMixed { places, .. }) => (
            Some(format!(
                "giving it up would leave parts of it beside another login in Claude's folder: \
                 {}",
                places.join(", ")
            )),
            mixed_way_out(label),
        ),
        (None, other) => (
            None,
            format!(
                "It stopped at `{}`: run `pitboard abandon` again to see why.",
                other.code()
            ),
        ),
    };
    match because {
        Some(because) => format!(
            "Pitboard did not give up the interrupted move of `{label}`'s login, because \
             {because}. {then}"
        ),
        None => {
            format!("Pitboard did not give up the interrupted move of `{label}`'s login. {then}")
        }
    }
}

// For `repair`, which goes on past the move, so never that nothing has changed.
pub(crate) fn move_not_settled(label: &str, why: &crate::error::Error) -> String {
    use crate::error::Error;
    let (because, then) = match (move_left(why, "pitboard repair"), why) {
        (Some(left), _) => left,
        (None, Error::RecoveryUndetermined { detail, .. }) => (
            format!("it cannot tell where it stopped ({detail})"),
            undetermined_way_out(),
        ),
        (None, Error::RecoveryStopped { detail, .. }) => (
            format!("it stopped partway ({detail})"),
            "Nothing is lost. Keep Claude closed and run `pitboard repair` again. If it stops \
             again, `pitboard abandon` keeps every folder and moves on."
                .into(),
        ),
        (None, Error::RecoveryElsewhere { tool, slot, .. }) => (
            format!(
                "{} kept its data in {slot} when the move was interrupted, and this run finds \
                 it somewhere else",
                tool.program()
            ),
            elsewhere_way_out(*tool),
        ),
        (None, other) => (
            format!("it stopped at `{}`", other.code()),
            "The next change to a Claude Desktop account settles it first, or says why it \
             cannot."
                .into(),
        ),
    };
    format!(
        "Pitboard did not settle the interrupted move of `{label}`'s login, because {because}. \
         {then}"
    )
}

// For a move Pitboard cannot tell where it stopped.
pub(crate) fn undetermined_way_out() -> String {
    "Open Claude to see whether it is signed in and to which account, then quit it with \
     Command-Q. If it is signed in, run `pitboard enroll desktop/<label>` for the account it \
     shows. If it is signed out, `pitboard abandon` keeps every folder and moves on."
        .into()
}

// For a move interrupted while the app kept its data somewhere else.
pub(crate) fn elsewhere_way_out(tool: crate::provider::ProviderId) -> String {
    format!(
        "{}, or `pitboard abandon` to keep every folder and move on.",
        pointing_where_it_did(tool)
    )
}

pub(crate) fn wait_for_still(then: &str) -> String {
    format!("Wait until it ends, or quit it in Activity Monitor, then {then}.")
}

pub(crate) fn mixed_way_out(label: &str) -> String {
    format!(
        "Open Claude, check which account it shows, quit it with Command-Q, then run `pitboard \
         enroll desktop/<label>` under that account's name: Pitboard sets the parts of \
         `{label}` aside first."
    )
}

pub(crate) fn move_settled(
    label: &str,
    half: crate::state::Half,
    ended: crate::switch::Ended,
) -> String {
    use crate::state::Half;
    use crate::switch::Ended;
    let came_to = match (half, ended) {
        (Half::Leave, Ended::Undone) => format!("Pitboard undid it, and `{label}` is in use"),
        (Half::Leave, Ended::Finished) => {
            "Pitboard finished parking it, and Claude is signed out".to_string()
        }
        (Half::Arrive, Ended::Undone) => {
            format!("Pitboard took it back out, `{label}` is parked and Claude is signed out")
        }
        (Half::Arrive, Ended::Finished) => {
            "it had in fact finished, and Pitboard has recorded that".to_string()
        }
    };
    format!("an earlier move of `{label}`'s login was interrupted; {came_to}")
}

pub(crate) fn item_gone(label: &str, item: &str) -> String {
    format!(
        "`{item}` of `{label}`'s login was in neither place after the move, so Pitboard went on \
         without it. Claude makes it again."
    )
}

pub(crate) fn given_up(given_up: &crate::switch::GivenUp) -> String {
    let label = crate::state::Key::new(given_up.tool, &given_up.label).typed();
    let enroll = crate::state::Key::new(given_up.tool, "<label>").typed();
    let mut said = format!(
        "Pitboard gave up the move of `{label}`'s login and kept every folder: {} parked, {} \
         set aside.",
        given_up.parked, given_up.set_aside
    );
    if let Some(slot) = &given_up.slot {
        said.push_str(&format!(
            " The move was in {slot}, which this run did not read."
        ));
    }
    let way_on = format!(
        "`pitboard enroll {enroll}` for the account Claude shows, or `pitboard use {enroll}` \
         where Claude is signed out."
    );
    if given_up.left.is_empty() {
        said.push_str(&format!(" Run {way_on}"));
    } else {
        let left = listed(
            given_up
                .left
                .iter()
                .map(|item| format!("`{item}`"))
                .collect(),
        );
        let stay = if given_up.left.len() == 1 {
            "stays"
        } else {
            "stay"
        };
        said.push_str(&format!(
            " {left} {stay} in Claude's folder. Open Claude once and quit it with Command-Q, \
             then run {way_on}"
        ));
    }
    said
}

// Claude's settings name `named` since Pitboard last read `last` in use: no service is asked.
pub(crate) fn desktop_named(
    named: &crate::service::Stored,
    last: &crate::service::Stored,
) -> String {
    use crate::service::Stored;
    let since = match last {
        Stored::Account(_) | Stored::Unenrolled(_) => format!(
            "since Pitboard last read {} in use, so Claude may hold another login",
            last.said("nobody")
        ),
        Stored::Nothing | Stored::Unknown => {
            "since Pitboard last read Claude signed out, so somebody may have signed in".into()
        }
    };
    format!(
        "Claude's settings have named {} {since}",
        named.said("nobody")
    )
}

// By the date the session cookie carries, hedged: claude.ai may end it sooner or keep it.
pub fn session_life(session_expires_at: Option<i64>, now: i64) -> Option<String> {
    let left = session_expires_at? - now;
    Some(match left / DAY {
        _ if left <= 0 => "Its sign-in may have lapsed".into(),
        0 => "Its sign-in may lapse in under a day".into(),
        1 => "Its sign-in may lapse in 1 day".into(),
        days => format!("Its sign-in may lapse in {days} days"),
    })
}

// Nobody tells Pitboard a Claude Desktop account's email, so its name stands alone.
pub fn with_email(name: &str, email: &str) -> String {
    if email.is_empty() {
        name.into()
    } else {
        format!("{name} ({email})")
    }
}

// Hedged, as `session_life` is: the date is the cookie's, and claude.ai may end it sooner.
pub fn session_life_column(session_expires_at: i64, now: i64) -> String {
    match session_expires_at - now {
        left if left <= 0 => "sign-in may have lapsed".into(),
        left => format!("sign-in may lapse in {}", span(left)),
    }
}

pub fn enrolled_lines(
    tool: crate::provider::ProviderId,
    enrolled: &crate::switch::Enrolled,
    name: &str,
    to_use: &str,
    another: &str,
) -> String {
    use crate::provider::ProviderId;
    use crate::switch::Enrolled;
    let in_its_app = match (tool, enrolled) {
        (ProviderId::Claude | ProviderId::Codex, _) => None,
        (ProviderId::Desktop, Enrolled::Current { .. }) => Some(format!(
            "Enrolled {name}, the account Claude is signed in to now. Check that Claude shows \
             that account: Pitboard cannot ask claude.ai whose it is.\nAdd another: pitboard \
             enroll {another} --sign-in\n"
        )),
        (ProviderId::Desktop, Enrolled::InUse { again: false, .. }) => Some(format!(
            "Enrolled {name}, the account Claude is signed in to now.\n"
        )),
        (ProviderId::Desktop, Enrolled::InUse { again: true, .. }) => Some(format!(
            "Signed in to {name} again. Claude is signed in to it now.\n"
        )),
        // `folder::enroll` makes neither.
        (ProviderId::Desktop, Enrolled::SignedIn { .. } | Enrolled::Renewed { .. }) => None,
    };
    if let Some(said) = in_its_app {
        return said;
    }
    match enrolled {
        Enrolled::Current { email } => format!(
            "Enrolled {}, the account signed in now.\nAdd another without signing out of it: \
             pitboard enroll {another} --sign-in\n",
            with_email(name, email)
        ),
        Enrolled::SignedIn { email } => format!(
            "Enrolled {}. Switch to it with: pitboard use {to_use}\n",
            with_email(name, email)
        ),
        Enrolled::Renewed { email } => format!(
            "Renewed {}: its parked login is a fresh one.\n",
            with_email(name, email)
        ),
        Enrolled::InUse { email, again: true } => format!(
            "Signed in to {} again. Its new login is the one in use now.\n",
            with_email(name, email)
        ),
        Enrolled::InUse {
            email,
            again: false,
        } => format!(
            "Enrolled {}, the account signed in now. Its new login is the one in use.\n",
            with_email(name, email)
        ),
    }
}

// `tool` is None for a label that names no account.
pub fn forget_question(tool: Option<crate::provider::ProviderId>, label: &str) -> String {
    use crate::provider::ProviderId;
    match tool {
        None | Some(ProviderId::Claude | ProviderId::Codex) => format!(
            "Forget {label} and delete its parked login? Adding it again needs a browser \
             sign-in. [y/N] "
        ),
        Some(ProviderId::Desktop) => format!(
            "Forget {label} and delete its parked login and the folders set aside for it? \
             Adding it again needs a sign-in in Claude. [y/N] "
        ),
    }
}

// What `adopt` says of the Claude Desktop logins it set aside, each account as typed.
pub fn adopted_folders(labels: &[String]) -> String {
    let named = listed(labels.iter().map(|label| format!("`{label}`")).collect());
    format!(
        "Pitboard set aside the parked Claude Desktop logins of {named}. They were parked on \
         another Mac, and Claude cannot read them here where its key differs. `pitboard \
         forget` of an account deletes its own."
    )
}

/// The line under doctor's checks, or over them in the app. Checks that only warn are
/// counted as worth looking at. One that fails outweighs every warning: the line then
/// counts what is broken and says not to switch accounts, since a check fails only when
/// something Pitboard relies on does not hold.
pub fn doctor_summary(levels: impl IntoIterator<Item = Level>) -> String {
    let (mut broken, mut worth_a_look) = (0, 0);
    for level in levels {
        match level {
            Level::Fail => broken += 1,
            Level::Warn => worth_a_look += 1,
            Level::Ok => {}
        }
    }
    match (broken, worth_a_look) {
        (0, 0) => "Everything Pitboard checks is in order.".into(),
        (0, 1) => "One thing is worth looking at.".into(),
        (0, n) => format!("{n} things are worth looking at."),
        (n, _) => format!("{n} broken: do not switch accounts until fixed."),
    }
}

pub(crate) struct ChangesNothing {
    pub(crate) refusal: String,
    // No full stop: a read's warning goes on from it with ", so it changes nothing".
    pub(crate) because: String,
    pub(crate) way_out: &'static str,
    pub(crate) to_ask_again: &'static str,
}

// On macOS and Linux root and sudo share one refusal, since the way out is the same.
pub(crate) fn elevated(os: Os, why: Option<&'static str>) -> ChangesNothing {
    let because = |cannot_tell: &str| match why {
        Some(why) => format!("Pitboard runs {why}"),
        None => format!("Pitboard cannot tell whether it runs {cannot_tell}"),
    };
    match os {
        Os::MacOs | Os::Linux => ChangesNothing {
            refusal: match why {
                Some(_) => "Pitboard changes nothing when it runs as root or with sudo. Run it as \
                            yourself."
                    .into(),
                None => "Pitboard changes nothing when it cannot tell whether it runs as root or \
                         with sudo. Run it as yourself."
                    .into(),
            },
            because: because("as root or with sudo"),
            way_out: "Run it as yourself.",
            to_ask_again: "Run it as yourself to ask again.",
        },
        Os::Windows => {
            let (way_out, to_ask_again) = match why {
                Some(token::IN_EVERY_PROGRAM) => (
                    "That is so with User Account Control off and in the built-in Administrator \
                     account: run it from a standard account, or turn User Account Control on.",
                    "Run it from a standard account, or with User Account Control on, to ask \
                     again.",
                ),
                _ => (
                    "Run it from a terminal that is not elevated (not Run as administrator).",
                    "Run it from a terminal that is not elevated to ask again.",
                ),
            };
            ChangesNothing {
                refusal: match why {
                    Some(why) => format!("Pitboard changes nothing when it runs {why}. {way_out}"),
                    None => format!(
                        "Pitboard changes nothing when it cannot tell whether it runs elevated. \
                         {way_out}"
                    ),
                },
                because: because("elevated"),
                way_out,
                to_ask_again,
            }
        }
    }
}

pub(crate) fn too_old(build: Option<u32>) -> ChangesNothing {
    let way_out = "It changes things on Windows 11 24H2 and later, and on Windows Server 2025: \
                   update Windows to use it here.";
    let to_ask_again = "Update Windows to ask again.";
    match build {
        Some(build) => ChangesNothing {
            refusal: format!("Pitboard changes nothing on Windows build {build}. {way_out}"),
            because: format!(
                "Pitboard runs on Windows build {build}, older than Windows 11 24H2 (build \
                 {WINDOWS_FLOOR})"
            ),
            way_out,
            to_ask_again,
        },
        None => ChangesNothing {
            refusal: format!(
                "Pitboard changes nothing when it cannot tell which build of Windows it runs \
                 on. {way_out}"
            ),
            because: "Pitboard cannot tell which build of Windows it runs on".into(),
            way_out,
            to_ask_again,
        },
    }
}

/// Things one after another, as a sentence lists them: "a", "a and b", "a, b and c".
pub fn listed(mut items: Vec<String>) -> String {
    match items.len() {
        0 => String::new(),
        1 => items.remove(0),
        _ => {
            let last = items.pop().unwrap_or_default();
            format!("{} and {last}", items.join(", "))
        }
    }
}

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

    #[test]
    fn claude_desktops_words_hedge_what_nobody_measured() {
        use crate::provider::ProviderId;
        let now = 1_791_000_000;
        let life = |left: i64| session_life(Some(now + left), now);
        assert_eq!(
            life(25 * DAY + 60).as_deref(),
            Some("Its sign-in may lapse in 25 days")
        );
        assert_eq!(life(DAY).as_deref(), Some("Its sign-in may lapse in 1 day"));
        assert_eq!(
            life(60).as_deref(),
            Some("Its sign-in may lapse in under a day")
        );
        assert_eq!(life(0).as_deref(), Some("Its sign-in may have lapsed"));
        assert_eq!(session_life(None, now), None);
        assert_eq!(
            adopted_folders(&["desktop/work".into(), "desktop/home".into()]),
            "Pitboard set aside the parked Claude Desktop logins of `desktop/work` and \
             `desktop/home`. They were parked on another Mac, and Claude cannot read them here \
             where its key differs. `pitboard forget` of an account deletes its own."
        );
        assert_eq!(
            login(ProviderId::Desktop, "", false),
            "another Claude Desktop account"
        );
        assert_eq!(sign_in_yourself(ProviderId::Desktop, false), "open Claude");
        assert_eq!(
            pointing_where_it_did(ProviderId::Desktop),
            "Run Pitboard as the account whose Claude that is"
        );
    }

    #[test]
    fn a_name_with_no_email_has_no_brackets() {
        assert_eq!(with_email("work", "a@example.com"), "work (a@example.com)");
        assert_eq!(with_email("desktop/work", ""), "desktop/work");
    }

    #[test]
    fn enrolled_lines_say_what_main_said_for_claude_code_and_codex() {
        use crate::provider::ProviderId;
        use crate::switch::Enrolled;
        let email = || "a@example.com".to_string();
        for tool in [ProviderId::Claude, ProviderId::Codex] {
            let said = |enrolled: Enrolled| {
                enrolled_lines(tool, &enrolled, "work", "codex/work", "codex/<label>")
            };
            assert_eq!(
                said(Enrolled::Current { email: email() }),
                "Enrolled work (a@example.com), the account signed in now.\n\
                 Add another without signing out of it: pitboard enroll codex/<label> \
                 --sign-in\n"
            );
            assert_eq!(
                said(Enrolled::SignedIn { email: email() }),
                "Enrolled work (a@example.com). Switch to it with: pitboard use codex/work\n"
            );
            assert_eq!(
                said(Enrolled::Renewed { email: email() }),
                "Renewed work (a@example.com): its parked login is a fresh one.\n"
            );
            assert_eq!(
                said(Enrolled::InUse {
                    email: email(),
                    again: true
                }),
                "Signed in to work (a@example.com) again. Its new login is the one in use now.\n"
            );
            assert_eq!(
                said(Enrolled::InUse {
                    email: email(),
                    again: false
                }),
                "Enrolled work (a@example.com), the account signed in now. Its new login is the \
                 one in use.\n"
            );
        }
    }

    #[test]
    fn enrolled_lines_for_claude_desktop_name_the_account_claude_is_signed_in_to() {
        use crate::provider::ProviderId;
        use crate::switch::Enrolled;
        let said = |enrolled: Enrolled| {
            enrolled_lines(
                ProviderId::Desktop,
                &enrolled,
                "desktop/work",
                "desktop/work",
                "desktop/<label>",
            )
        };
        assert_eq!(
            said(Enrolled::Current {
                email: String::new()
            }),
            "Enrolled desktop/work, the account Claude is signed in to now. Check that Claude \
             shows that account: Pitboard cannot ask claude.ai whose it is.\n\
             Add another: pitboard enroll desktop/<label> --sign-in\n"
        );
        assert_eq!(
            said(Enrolled::InUse {
                email: String::new(),
                again: false
            }),
            "Enrolled desktop/work, the account Claude is signed in to now.\n"
        );
        assert_eq!(
            said(Enrolled::InUse {
                email: String::new(),
                again: true
            }),
            "Signed in to desktop/work again. Claude is signed in to it now.\n"
        );
        assert_eq!(
            said(Enrolled::SignedIn {
                email: String::new()
            }),
            "Enrolled desktop/work. Switch to it with: pitboard use desktop/work\n"
        );
        assert!(
            !said(Enrolled::Renewed {
                email: String::new()
            })
            .contains("()")
        );
    }

    #[test]
    fn the_forget_question_says_a_claude_desktop_account_signs_in_again_in_claude() {
        use crate::provider::ProviderId;
        let main = "Forget work and delete its parked login? Adding it again needs a browser \
                    sign-in. [y/N] ";
        for tool in [None, Some(ProviderId::Claude), Some(ProviderId::Codex)] {
            assert_eq!(forget_question(tool, "work"), main, "{tool:?}");
        }
        assert_eq!(
            forget_question(Some(ProviderId::Desktop), "desktop/home"),
            "Forget desktop/home and delete its parked login and the folders set aside for it? \
             Adding it again needs a sign-in in Claude. [y/N] "
        );
    }

    #[test]
    fn a_session_life_is_hedged_and_never_says_expired() {
        const NOW: i64 = 1_789_935_000;
        let column = |left: i64| session_life_column(NOW + left, NOW);
        assert_eq!(column(25 * DAY + 3 * HOUR), "sign-in may lapse in 25d 3h");
        assert_eq!(column(2 * HOUR + 5 * MINUTE), "sign-in may lapse in 2h 05m");
        assert_eq!(column(30), "sign-in may lapse in <1m");
        assert_eq!(column(0), "sign-in may have lapsed");
        assert_eq!(column(-DAY), "sign-in may have lapsed");
        for left in [-DAY, 0, 30, DAY, 40 * DAY] {
            let said = format!(
                "{} {}",
                column(left),
                session_life(Some(NOW + left), NOW).unwrap_or_default()
            );
            assert!(!said.contains("expire"), "{said}");
        }
    }

    /// Why Pitboard did not switch by itself is said in the app as well as in a terminal,
    /// so it names no command: the app gives up on an interrupted switch with a button.
    #[test]
    fn why_it_did_not_switch_names_no_command() {
        use crate::duty::autoswitch::{Blind, Skip, Threshold};
        for why in [
            Skip::NoRoom {
                unread: vec!["spare".into()],
            },
            Skip::AlreadyLeft,
            Skip::GaveUp,
            Skip::Settling { until: 0 },
            Skip::Overridden(vec!["apiKeyHelper".into()]),
        ] {
            let said = not_switching(&why, Threshold::DEFAULT);
            assert!(!said.contains("pitboard "), "{why:?}");
        }
        for why in [
            Blind::SwitchInterrupted,
            Blind::CustomOauth,
            Blind::NothingSignedIn,
            Blind::NotEnrolled {
                email: "me@example.com".into(),
            },
            Blind::Unidentified {
                detail: "could not reach Anthropic: no route to host".into(),
                until: 0,
            },
            Blind::NoReading {
                account: "work".into(),
            },
        ] {
            assert!(!not_watching(&why).contains("pitboard "), "{why:?}");
        }
    }

    #[test]
    fn root_and_sudo_are_told_to_run_it_as_themselves() {
        for os in [Os::MacOs, Os::Linux] {
            for why in ["as root", "with sudo"] {
                let said = elevated(os, Some(why));
                assert_eq!(
                    said.refusal,
                    "Pitboard changes nothing when it runs as root or with sudo. Run it as \
                     yourself."
                );
                assert_eq!(said.because, format!("Pitboard runs {why}"));
                assert_eq!(said.way_out, "Run it as yourself.");
                assert_eq!(said.to_ask_again, "Run it as yourself to ask again.");
            }
            let unknown = elevated(os, None);
            assert_eq!(
                unknown.refusal,
                "Pitboard changes nothing when it cannot tell whether it runs as root or with \
                 sudo. Run it as yourself."
            );
            assert_eq!(
                unknown.because,
                "Pitboard cannot tell whether it runs as root or with sudo"
            );
        }
    }

    #[test]
    fn an_elevated_windows_run_is_told_which_terminal_to_use() {
        let terminal = "Run it from a terminal that is not elevated (not Run as administrator).";
        for (why, refusal) in [
            (
                Some(token::AS_ADMINISTRATOR),
                "Pitboard changes nothing when it runs as administrator.",
            ),
            (
                Some(token::AS_A_SERVICE_ACCOUNT),
                "Pitboard changes nothing when it runs as a service account.",
            ),
            (
                None,
                "Pitboard changes nothing when it cannot tell whether it runs elevated.",
            ),
        ] {
            let said = elevated(Os::Windows, why);
            assert_eq!(said.refusal, format!("{refusal} {terminal}"), "{why:?}");
            assert_eq!(said.way_out, terminal, "{why:?}");
            assert_eq!(
                said.to_ask_again,
                "Run it from a terminal that is not elevated to ask again."
            );
        }
        assert_eq!(
            elevated(Os::Windows, Some(token::AS_ADMINISTRATOR)).because,
            "Pitboard runs as administrator"
        );
        assert_eq!(
            elevated(Os::Windows, None).because,
            "Pitboard cannot tell whether it runs elevated"
        );

        let always = elevated(Os::Windows, Some(token::IN_EVERY_PROGRAM));
        assert_eq!(
            always.refusal,
            "Pitboard changes nothing when it runs elevated, as every program this Windows \
             account starts does. That is so with User Account Control off and in the \
             built-in Administrator account: run it from a standard account, or turn User \
             Account Control on."
        );
        assert_eq!(
            always.because,
            "Pitboard runs elevated, as every program this Windows account starts does"
        );
        assert_eq!(
            always.to_ask_again,
            "Run it from a standard account, or with User Account Control on, to ask again."
        );
    }

    #[test]
    fn an_old_windows_is_told_which_it_needs() {
        let old = too_old(Some(22631));
        assert_eq!(
            old.refusal,
            "Pitboard changes nothing on Windows build 22631. It changes things on Windows 11 \
             24H2 and later, and on Windows Server 2025: update Windows to use it here."
        );
        assert_eq!(
            old.because,
            "Pitboard runs on Windows build 22631, older than Windows 11 24H2 (build 26100)"
        );
        assert_eq!(old.to_ask_again, "Update Windows to ask again.");
        let unknown = too_old(None);
        assert_eq!(
            unknown.refusal,
            "Pitboard changes nothing when it cannot tell which build of Windows it runs on. \
             It changes things on Windows 11 24H2 and later, and on Windows Server 2025: update \
             Windows to use it here."
        );
        assert_eq!(
            unknown.because,
            "Pitboard cannot tell which build of Windows it runs on"
        );
    }

    #[test]
    fn spans_read_at_a_glance() {
        assert_eq!(span(-5), "<1m");
        assert_eq!(span(59), "<1m");
        assert_eq!(span(60 * 47), "47m");
        assert_eq!(span(3600 * 2 + 60 * 5), "2h 05m");
        assert_eq!(span(86_400 * 6 + 3600 * 4 + 59), "6d 4h");
    }

    /// A limit is named by how long it runs, which both services agree on, so a Codex limit
    /// reads the way a Claude Code one does, in a sentence and in a column alike.
    #[test]
    fn a_limit_is_named_for_its_length() {
        for (length, column, sentence) in [
            (18_000, "5h", "5-hour"),
            (604_800, "week", "weekly"),
            (86_400, "day", "daily"),
            (10_800, "3h", "3-hour"),
            (172_800, "2d", "2-day"),
            (5_400, "90m", "90-minute"),
            (1_800, "30m", "30-minute"),
            (45, "45s", "45-second"),
        ] {
            assert_eq!(limit_column("any", Some(length), None), column, "{length}");
            assert_eq!(limit_name("any", Some(length)), sentence, "{length}");
        }
    }

    /// A reading taken before the length was kept names its limit the way it always did,
    /// and so does one whose length makes no sense.
    #[test]
    fn a_limit_of_unknown_length_is_named_from_its_kind() {
        assert_eq!(limit_name("session", None), "5-hour");
        assert_eq!(limit_name("five_hour", Some(0)), "5-hour");
        assert_eq!(limit_name("weekly_all", None), "weekly");
        assert_eq!(limit_name("seven_day", None), "weekly");
        assert_eq!(limit_name("primary_window", None), "primary window");
        assert_eq!(limit_column("session", None, None), "5h");
        assert_eq!(limit_column("seven_day", Some(-1), None), "week");
        assert_eq!(limit_column("primary", None, None), "primary");
        assert_eq!(
            limit_column("weekly_scoped", None, Some("Fable")),
            "week · Fable"
        );
        assert_eq!(
            limit_column("weekly_scoped", Some(604_800), Some("Fable")),
            "week · Fable"
        );
    }

    /// A limit changes colour at 70% and at 90% as its figure says them, and stays red past
    /// 100%. The steps are where its colour changes, so each side of each is checked.
    #[test]
    fn a_limits_level_changes_at_seventy_and_at_ninety() {
        assert_eq!(usage_level(0.0), UsageLevel::Plenty);
        assert_eq!(usage_level(69.4), UsageLevel::Plenty);
        assert_eq!(usage_level(69.5), UsageLevel::Low);
        assert_eq!(usage_level(89.4), UsageLevel::Low);
        assert_eq!(usage_level(89.5), UsageLevel::Out);
        assert_eq!(usage_level(100.0), UsageLevel::Out);
        assert_eq!(usage_level(130.0), UsageLevel::Out);
    }

    /// The column beside a bar says how long until a limit resets in the largest units that
    /// stay true, and once that moment has come, that it is resetting.
    #[test]
    fn a_reset_is_said_in_the_largest_units_that_fit() {
        const NOW: i64 = 1_789_935_000;
        let resets = |left: i64| resets(NOW + left, NOW);
        assert_eq!(resets(-5), "resetting now");
        assert_eq!(resets(0), "resetting now");
        assert_eq!(resets(1), "resets in <1m");
        assert_eq!(resets(59), "resets in <1m");
        assert_eq!(resets(125), "resets in 2m");
        assert_eq!(resets(3599), "resets in 59m");
        assert_eq!(resets(3600), "resets in 1h 00m");
        assert_eq!(resets(3600 + 5 * 60), "resets in 1h 05m");
        assert_eq!(resets(3 * 3600 + 25 * 60), "resets in 3h 25m");
        assert_eq!(resets(86_399), "resets in 23h 59m");
        assert_eq!(resets(86_400), "resets in 1d 0h");
        assert_eq!(resets(2 * 86_400 + 4 * 3600 + 59 * 60), "resets in 2d 4h");
    }

    /// A limit in a sentence about an automatic switch has the figure it is drawn with.
    #[test]
    fn a_share_of_a_limit_is_said_as_it_is_drawn() {
        let limit = |percent: f64| crate::usage::Window {
            kind: "session".into(),
            scope: None,
            percent,
            resets_at: None,
            is_active: true,
            severity: None,
            length_seconds: Some(5 * HOUR),
        };
        assert_eq!(share_of_limit(&limit(94.5)), "95% of its 5-hour limit");
        assert_eq!(share_of_limit(&limit(96.4)), "96% of its 5-hour limit");
    }

    /// A pace beside its bar is how far from even it is, said so that over reads as the
    /// warning it is: "ahead" reads as good news.
    #[test]
    fn a_pace_is_said_as_over_under_or_on() {
        let pace = |delta: f64, standing| Pace {
            expected: 40.0,
            delta,
            standing,
        };
        use crate::usage::pace::Standing;
        assert_eq!(
            pace_column(&pace(27.4, Standing::Over { runs_out_in: 9_000 })),
            "27% over pace"
        );
        assert_eq!(pace_column(&pace(-12.6, Standing::Under)), "13% under pace");
        assert_eq!(pace_column(&pace(3.0, Standing::Even)), "on pace");
        assert_eq!(pace_column(&pace(-4.0, Standing::Even)), "on pace");
    }

    /// The limit that runs out first is named in the sentence, with its model where it has
    /// one, and under a minute the sentence says it is about to.
    #[test]
    fn a_limit_running_out_is_named_with_when() {
        assert_eq!(
            runs_out("weekly", 19 * 3600 + 5 * 60),
            "weekly limit runs out in 19h 05m at this pace"
        );
        assert_eq!(
            runs_out(
                &scoped_limit_name("weekly_scoped", Some(WEEK), Some("Fable")),
                2 * DAY
            ),
            "weekly Fable limit runs out in 2d 0h at this pace"
        );
        assert_eq!(runs_out("5-hour", 30), "5-hour limit is about to run out");
        assert_eq!(runs_out("5-hour", 0), "5-hour limit is about to run out");
    }

    /// When a parked login stops working decides whether a switch to it will. The sentence
    /// form says nothing with nothing to go on, says a login past it has expired, and
    /// otherwise counts whole days left, one day in the singular.
    #[test]
    fn a_parked_logins_life_in_a_sentence_is_counted_in_whole_days() {
        const NOW: i64 = 1_789_935_000;
        let life = |left: i64| parked_life(Some(NOW + left), NOW);
        assert_eq!(parked_life(None, NOW), None);
        assert_eq!(life(-60).as_deref(), Some("Its parked login has expired"));
        assert_eq!(life(0).as_deref(), Some("Its parked login has expired"));
        assert_eq!(
            life(1).as_deref(),
            Some("Parked login good for under a day")
        );
        assert_eq!(
            life(86_399).as_deref(),
            Some("Parked login good for under a day")
        );
        assert_eq!(
            life(86_400).as_deref(),
            Some("Parked login good for 1 more day")
        );
        assert_eq!(
            life(2 * 86_400 - 1).as_deref(),
            Some("Parked login good for 1 more day")
        );
        assert_eq!(
            life(2 * 86_400).as_deref(),
            Some("Parked login good for 2 more days")
        );
        assert_eq!(
            life(30 * 86_400).as_deref(),
            Some("Parked login good for 30 more days")
        );
    }

    /// The column form beside "ready" in `pitboard status`: what is left of a parked login
    /// to the minute, and from the moment it is due to be renewed, that it expires.
    #[test]
    fn a_parked_logins_life_in_a_column_says_when_it_expires() {
        use crate::doctor::RENEW_WITHIN;
        const NOW: i64 = 1_789_935_000;
        let life = |left: i64| parked_life_column(NOW + left, NOW);
        assert_eq!(life(20 * 86_400), "good for 20d 0h");
        assert_eq!(life(RENEW_WITHIN), "good for 3d 0h");
        assert_eq!(life(RENEW_WITHIN - 1), "expires in 2d 23h");
        assert_eq!(life(3_900), "expires in 1h 05m");
        assert_eq!(life(30), "expires in <1m");
        assert_eq!(life(0), "expired");
        assert_eq!(life(-60), "expired");
    }

    /// Nothing due is the ordinary case, and it has to read as ordinary rather than as a
    /// failure to do anything. Otherwise the sentence counts what was renewed of what was
    /// due.
    #[test]
    fn a_renewal_run_says_what_it_did() {
        assert_eq!(renewal_note(0, 0), "No parked login was due.");
        assert_eq!(renewal_note(1, 1), "Renewed one.");
        assert_eq!(renewal_note(2, 2), "Renewed all 2.");
        assert_eq!(
            renewal_note(2, 1),
            "Renewed 1 of 2; the rest are tried again next time."
        );
        assert_eq!(
            renewal_note(1, 0),
            "1 due; none could be renewed this time."
        );
    }

    /// Warnings are counted as things worth looking at. A check that fails outweighs every
    /// warning: the sentence then counts only what is broken, and says not to switch.
    #[test]
    fn the_doctor_summary_says_not_to_switch_while_a_check_fails() {
        assert_eq!(
            doctor_summary([Level::Ok, Level::Ok]),
            "Everything Pitboard checks is in order."
        );
        assert_eq!(
            doctor_summary([]),
            "Everything Pitboard checks is in order."
        );
        assert_eq!(
            doctor_summary([Level::Ok, Level::Warn]),
            "One thing is worth looking at."
        );
        assert_eq!(
            doctor_summary([Level::Warn, Level::Ok, Level::Warn]),
            "2 things are worth looking at."
        );
        assert_eq!(
            doctor_summary([Level::Fail]),
            "1 broken: do not switch accounts until fixed."
        );
        assert_eq!(
            doctor_summary([Level::Warn, Level::Ok, Level::Fail]),
            "1 broken: do not switch accounts until fixed."
        );
        assert_eq!(
            doctor_summary([Level::Fail, Level::Warn, Level::Fail]),
            "2 broken: do not switch accounts until fixed."
        );
    }

    // Said once `abandon` has given up a switch, where "nothing has changed" is false.
    #[test]
    fn a_move_not_given_up_says_why_and_never_that_nothing_changed() {
        use crate::error::Error;
        use crate::provider::ProviderId;
        let tool = ProviderId::Desktop;
        let app = "Claude";
        let said = |why| move_not_given_up("desktop/work", &why);
        assert_eq!(
            said(Error::AppOpen {
                app,
                still: Vec::new()
            }),
            "Pitboard did not give up the interrupted move of `desktop/work`'s login, because \
             Claude is open. Quit Claude with Command-Q, then run `pitboard abandon` again."
        );
        assert_eq!(
            said(Error::MachineUnknown),
            "Pitboard did not give up the interrupted move of `desktop/work`'s login. It \
             stopped at `machine_unknown`: run `pitboard abandon` again to see why."
        );
        assert_eq!(
            said(crate::store::folder::unread(
                "desktop/work",
                std::path::Path::new("/Users/dana/.pitboard/parks/p/items/Cookies"),
                "a test held the jar"
            )),
            "Pitboard did not give up the interrupted move of `desktop/work`'s login, because \
             `desktop/work`'s parked login at /Users/dana/.pitboard/parks/p/items/Cookies could \
             not be looked at (a test held the jar). Make that path, and each folder it is in, \
             readable to you, then run `pitboard abandon` again."
        );
        for why in [
            Error::AppOpen {
                app,
                still: vec!["ShipIt (pid 812)".into()],
            },
            Error::AppStateUnknown {
                app,
                why: "ps failed".into(),
            },
            Error::LoginFolderBusy { app },
            Error::LoginFolderUnreadable {
                tool,
                detail: "a test held the jar".into(),
            },
            Error::AppOpenedMidway {
                app,
                label: Some("desktop/work".into()),
            },
            Error::MoveMixed {
                label: "desktop/work".into(),
                places: vec!["`Session Storage` in Claude's folder".into()],
            },
        ] {
            let sentence = said(why);
            assert!(sentence.contains(", because "), "{sentence}");
            assert!(
                !sentence.to_lowercase().contains("nothing has changed"),
                "{sentence}"
            );
        }
    }

    // Said by `repair`, which goes on past the move, so never that nothing has changed.
    #[test]
    fn a_move_not_settled_says_why_and_never_that_nothing_changed() {
        use crate::error::{Error, Then};
        use crate::provider::ProviderId;
        let tool = ProviderId::Desktop;
        let app = "Claude";
        let label = || "desktop/work".to_string();
        let said = |why| move_not_settled("desktop/work", &why);
        assert_eq!(
            said(Error::RecoveryUndetermined {
                tool,
                from: label(),
                to: label(),
                detail: "a settings file that is not valid JSON".into(),
            }),
            "Pitboard did not settle the interrupted move of `desktop/work`'s login, because it \
             cannot tell where it stopped (a settings file that is not valid JSON). Open Claude \
             to see whether it is signed in and to which account, then quit it with Command-Q. \
             If it is signed in, run `pitboard enroll desktop/<label>` for the account it shows. \
             If it is signed out, `pitboard abandon` keeps every folder and moves on."
        );
        assert_eq!(
            said(Error::MachineUnknown),
            "Pitboard did not settle the interrupted move of `desktop/work`'s login, because it \
             stopped at `machine_unknown`. The next change to a Claude Desktop account settles \
             it first, or says why it cannot."
        );
        for why in [
            Error::AppOpen {
                app,
                still: Vec::new(),
            },
            Error::LoginFolderBusy { app },
            Error::RecoveryStopped {
                label: label(),
                detail: "`WebStorage` could not be moved".into(),
            },
            Error::RecoveryElsewhere {
                tool,
                from: label(),
                to: label(),
                slot: "/Volumes/Old/Claude".into(),
            },
            Error::LoginFolderUnknown {
                tool,
                what: "does not exist yet".into(),
                then: Then::OpenAndQuit,
            },
            Error::Store(crate::store::Error::Unreadable("denied".into())),
        ] {
            let sentence = said(why);
            assert!(sentence.contains(", because "), "{sentence}");
            assert!(sentence.ends_with('.'), "{sentence}");
            let lower = sentence.to_lowercase();
            assert!(
                !lower.contains("nothing has changed") && !lower.contains("nothing was changed"),
                "{sentence}"
            );
        }
    }
}