scrybe 2.1.0

Local-first meeting recording, transcription, and notes for macOS.
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
// Copyright 2026 Mathews Tom
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//     https://www.apache.org/licenses/LICENSE-2.0

//! `scrybe rec` — start a session (explicit-flags entry point).
//!
//! v1.0.5+ split: this module is the explicit-flags entry point used
//! by CI, scripts, and advanced users. The new `scrybe record <title>`
//! ergonomic command (see `commands::record`) wraps this with config-
//! default resolution and macOS Launch-Services auto-launch so end
//! users typically never invoke `rec` directly. The module name is
//! `rec` (renamed from `record` at v1.0.5) to free up the `record`
//! word for the user-facing entry point.
//!
//! v1.0.1+ closes the v0.1 mic-only path (`.docs/development-plan.md`
//! §7.2). Three opt-in flags surface real audio capture and real
//! Whisper transcription:
//!
//! - `--source mic` consumes frames from the default input device via
//!   `scrybe-capture-mic` (cpal). Requires the binary to be built
//!   with `--features mic-capture`; absent that feature the call
//!   returns `CaptureError::PermissionDenied`.
//! - `--source mic+system` (v1.0.3+) layers `scrybe-capture-mac`
//!   Core Audio Taps on top of the mic adapter so the meeting
//!   counterparty's audio also flows through the pipeline. Frames
//!   from each source carry their own `FrameSource` tag, so the
//!   `BinaryChannelDiarizer` can attribute them to `Me:` (mic) and
//!   `Them:` (system) in `transcript.md`. Requires the binary to be
//!   built with `--features mic-capture,system-capture-mac` and
//!   macOS 14.4+ with the Audio Capture TCC permission granted.
//! - `--whisper-model <PATH>` swaps the stub STT provider for
//!   `WhisperLocalProvider` against the supplied `.bin` / `.gguf`
//!   weights. Requires the binary to be built with
//!   `--features whisper-local`; absent that feature the flag errors
//!   at start time rather than silently falling back to the stub.
//!
//! Without any flag the recorder runs the deterministic synthetic
//! pipeline (440 Hz sine + canned transcripts) so CI smoke tests stay
//! hermetic.

use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result};

use chrono::Utc;
use clap::{Args as ClapArgs, ValueEnum};
use futures::stream::{self, Stream, StreamExt};
use scrybe_application::recording::{
    CaptureCapability, CaptureRegistry, CaptureSource, CaptureSupport, NotesBackend,
    RecordingController, RecordingOverrides, RecordingPlan, RecordingSnapshot, RecordingState,
    StopAcceptance, StopSource, SystemBackend, TranscriptionModel,
};
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
use scrybe_capture_mac::{input_devices, InputDevice, MacCapture, NativeMicCapture, SckCapture};
#[cfg(all(feature = "mic-capture", not(feature = "system-capture-mac")))]
use scrybe_capture_mic::MicCapture;
// AudioCapture is the registry's common bound whenever microphone capture is
// compiled into the binary.
#[cfg(feature = "mic-capture")]
use scrybe_core::capture::AudioCapture;

use scrybe_core::error::CaptureError;
use scrybe_core::session::SessionProgress;
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
use scrybe_core::storage::session_folder_name;
use scrybe_core::types::{AudioFrame, ConsentMode, SessionId, SpeakerLabel};
use tokio::sync::watch;

use crate::prompter::TtyPrompter;
use crate::runtime::{application, config_service};

#[derive(ClapArgs, Clone, Debug)]
pub struct Args {
    /// Session title for the folder name and notes.
    #[arg(long)]
    pub title: Option<String>,

    /// Override the storage root from config.
    #[arg(long)]
    pub root: Option<PathBuf>,

    /// Skip the consent prompt — for headless smoke tests on a
    /// developer workstation. Equivalent to setting the
    /// `SCRYBE_CONSENT_AUTO_ACCEPT=1` environment variable; either
    /// alone is sufficient because both are interactive overrides
    /// the user can audit in the surrounding shell history.
    #[arg(long, default_value_t = false)]
    pub yes: bool,

    /// Consent mode. Omitted means use `[consent].default_mode`.
    #[arg(long, value_enum)]
    pub consent: Option<ConsentModeArg>,

    /// Synthetic-source duration in seconds. The default `--source
    /// synthetic` records from a deterministic in-process generator
    /// (440 Hz sine sweep) so the full pipeline is exercisable
    /// without microphone hardware. Ignored when `--source mic`.
    #[arg(long, default_value_t = 5)]
    pub synthetic_secs: u64,

    /// Capture source. `synthetic` (default) plays a deterministic
    /// 440 Hz sine through the pipeline so CI smoke tests stay
    /// hermetic. `mic` opens the host's default input device via
    /// cpal — requires the binary to be built with
    /// `--features mic-capture`. `mic+system` additionally captures
    /// system audio (the meeting counterparty) on macOS via Core
    /// Audio Taps — requires both `mic-capture` and
    /// `system-capture-mac` features and the Audio Capture TCC
    /// permission grant. Absent the relevant feature, the call
    /// returns `CaptureError::PermissionDenied` at start time.
    #[arg(long, value_enum)]
    pub source: Option<CaptureSourceArg>,

    /// Exact macOS Core Audio input-device UID from `scrybe devices`.
    /// Display names are deliberately not accepted as selectors.
    #[arg(long)]
    pub input_device: Option<String>,

    /// System-audio adapter for `--source mic+system`. `sck` is the
    /// macOS 13+ default; `tap` selects the macOS 14.4+ Core Audio
    /// Tap path.
    #[arg(long, value_enum)]
    pub system_backend: Option<SystemBackendArg>,

    /// Path to a whisper.cpp model (`.bin` or `.gguf`). When set and
    /// `whisper-local` is compiled, transcription uses `WhisperLocalProvider`.
    /// An explicit path without that feature errors at start time rather than
    /// silently falling back to the stub.
    #[arg(long, conflicts_with = "sherpa_model")]
    pub whisper_model: Option<PathBuf>,

    /// Directory containing the pinned streaming Zipformer Sherpa-ONNX model.
    /// Requires `stt-sherpa`; without it, an explicit path errors at start
    /// time rather than silently falling back to the stub.
    #[arg(long, conflicts_with = "whisper_model")]
    pub sherpa_model: Option<PathBuf>,

    /// Language-model backend for the `notes.md` summary step. `stub`
    /// (default) returns a fixed templated body so CI smoke tests stay
    /// hermetic. `openai-compat` constructs `OpenAiCompatLlmProvider`
    /// from the `[llm]` config block (defaults to Ollama at
    /// `http://localhost:11434/v1`); requires the binary to be built
    /// with `--features llm-openai-compat`. Without that feature, an
    /// explicit `--llm openai-compat` errors at start time rather
    /// than silently falling back to the stub.
    #[arg(long, value_enum)]
    pub llm: Option<LlmBackendArg>,

    /// Show native recording indicators and accept tray, floating-window,
    /// and global-hotkey stop requests. The shell is opt-in; without this
    /// flag the recorder remains headless and stops on SIGINT or when the
    /// synthetic stream completes. Requires the `cli-shell` build feature.
    #[arg(long, default_value_t = false)]
    pub shell: bool,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum ConsentModeArg {
    Quick,
    Notify,
    Announce,
}

#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum CaptureSourceArg {
    #[default]
    Synthetic,
    Mic,
    /// Mic + macOS system audio. Surfaced to clap as the literal
    /// `mic+system` token so the CLI matches the user-facing
    /// documentation (`docs/system-overview.md` §3 channel-split path).
    #[value(name = "mic+system")]
    MicSystem,
}

#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum SystemBackendArg {
    #[default]
    Sck,
    Tap,
}

#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum LlmBackendArg {
    #[default]
    Stub,
    /// `OpenAiCompatLlmProvider` against any `/chat/completions`
    /// endpoint configured under `[llm]` — Ollama, vLLM, `OpenAI`,
    /// Groq, Together. Surfaced to clap as the literal `openai-compat`
    /// token to match `docs/system-design.md` §4.3.
    #[value(name = "openai-compat")]
    OpenAiCompat,
}

#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
enum SystemCapture {
    Sck(SckCapture),
    Tap(MacCapture),
}

#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
impl SystemCapture {
    fn new(backend: SystemBackend) -> Self {
        match backend {
            SystemBackend::Sck => Self::Sck(SckCapture::new()),
            SystemBackend::Tap => Self::Tap(MacCapture::new()),
        }
    }

    fn start(&mut self) -> Result<()> {
        match self {
            Self::Sck(capture) => capture.start().map_err(Into::into),
            Self::Tap(capture) => capture.start().map_err(Into::into),
        }
    }

    fn frames(&self) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
        match self {
            Self::Sck(capture) => Box::pin(capture.frames()),
            Self::Tap(capture) => Box::pin(capture.frames()),
        }
    }

    fn stop(&mut self) -> Result<()> {
        match self {
            Self::Sck(capture) => capture.stop().map_err(Into::into),
            Self::Tap(capture) => capture.stop().map_err(Into::into),
        }
    }
}

#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
const TAP_STARTUP_ACTIVITY_WINDOW: Duration = Duration::from_millis(1_500);

#[cfg(any(test, feature = "mic-capture"))]
use scrybe_application::recording::CaptureFrames as CaptureFrameStream;

#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
async fn start_system_capture(
    selected: SystemBackend,
) -> Result<(SystemCapture, CaptureFrameStream, Option<&'static str>)> {
    let mut capture = SystemCapture::new(selected);
    if let Err(error) = capture.start() {
        let Some(backend) = fallback_backend(selected) else {
            return Err(error);
        };
        let mut fallback = SystemCapture::new(backend);
        fallback.start().context(
            "Core Audio Tap failed to start and ScreenCaptureKit could not start either",
        )?;
        let frames = fallback.frames();
        return Ok((
            fallback,
            frames,
            Some("system capture switched from tap to sck after tap start failure"),
        ));
    }
    let frames = capture.frames();
    if fallback_backend(selected).is_some() {
        let (active, frames) = tap_produces_nonzero_frames(frames).await;
        if !active {
            capture
                .stop()
                .context("stopping silent Core Audio Tap before fallback")?;
            let mut fallback = SystemCapture::new(SystemBackend::Sck);
            fallback.start().context(
                "Core Audio Tap had no startup activity and ScreenCaptureKit could not start",
            )?;
            let frames = fallback.frames();
            return Ok((
                fallback,
                frames,
                Some("system capture switched from tap to sck after no tap startup activity"),
            ));
        }
        return Ok((capture, frames, None));
    }
    Ok((capture, frames, None))
}

#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
async fn tap_produces_nonzero_frames(mut frames: CaptureFrameStream) -> (bool, CaptureFrameStream) {
    let deadline = tokio::time::Instant::now() + TAP_STARTUP_ACTIVITY_WINDOW;
    let mut buffered = Vec::new();
    let mut active = false;
    loop {
        match tokio::time::timeout_at(deadline, frames.next()).await {
            Ok(Some(Ok(frame))) => {
                active |= frame.samples.iter().any(|sample| *sample != 0.0);
                buffered.push(Ok(frame));
                if active {
                    break;
                }
            }
            Ok(Some(Err(error))) => {
                buffered.push(Err(error));
                break;
            }
            Ok(None) | Err(_) => break,
        }
    }
    (
        active,
        Box::pin(futures::stream::iter(buffered).chain(frames)),
    )
}

#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
const fn fallback_backend(selected: SystemBackend) -> Option<SystemBackend> {
    match selected {
        SystemBackend::Tap => Some(SystemBackend::Sck),
        SystemBackend::Sck => None,
    }
}

impl From<CaptureSourceArg> for CaptureSource {
    fn from(value: CaptureSourceArg) -> Self {
        match value {
            CaptureSourceArg::Synthetic => Self::Synthetic,
            CaptureSourceArg::Mic => Self::Mic,
            CaptureSourceArg::MicSystem => Self::MicSystem,
        }
    }
}

impl From<SystemBackendArg> for SystemBackend {
    fn from(value: SystemBackendArg) -> Self {
        match value {
            SystemBackendArg::Sck => Self::Sck,
            SystemBackendArg::Tap => Self::Tap,
        }
    }
}

impl From<LlmBackendArg> for NotesBackend {
    fn from(value: LlmBackendArg) -> Self {
        match value {
            LlmBackendArg::Stub => Self::Stub,
            LlmBackendArg::OpenAiCompat => Self::OpenAiCompat,
        }
    }
}

impl From<ConsentModeArg> for ConsentMode {
    fn from(value: ConsentModeArg) -> Self {
        match value {
            ConsentModeArg::Quick => Self::Quick,
            ConsentModeArg::Notify => Self::Notify,
            ConsentModeArg::Announce => Self::Announce,
        }
    }
}

/// Record a session, stoppable by `Ctrl-C` or `SIGTERM`.
///
/// Drives the same process-wide recording controller the native shell
/// uses, so a signal stop and a tray stop are the same accepted
/// transition rather than two implementations of one idea.
///
/// # Errors
///
/// Propagates configuration, capture, provider, and storage failures
/// from the recording session.
/// What this invocation asks for, over the configuration file.
///
/// The CLI's flags are overrides and nothing else: resolution itself
/// belongs to `scrybe-application`, so a terminal and a desktop host
/// read the same file to the same answers.
#[must_use]
pub fn overrides_from(args: &Args) -> RecordingOverrides {
    RecordingOverrides {
        title: args.title.clone(),
        root: args.root.clone(),
        source: args.source.map(Into::into),
        system_backend: args.system_backend.map(Into::into),
        input_device: args.input_device.clone(),
        whisper_model: args.whisper_model.clone(),
        sherpa_model: args.sherpa_model.clone(),
        notes: args.llm.map(Into::into),
        consent: args.consent.map(Into::into),
    }
}

/// What this binary was compiled to be able to open.
///
/// Derived from the same `cfg!` conditions the capture construction in
/// `run_with_stop` is written under, so preflight cannot report a
/// capability the code below then refuses to provide.
#[must_use]
pub const fn build_support() -> CaptureSupport {
    CaptureSupport {
        capture: if cfg!(all(feature = "mic-capture", feature = "system-capture-mac")) {
            CaptureCapability::MicrophoneAndSystemAudio
        } else if cfg!(feature = "mic-capture") {
            CaptureCapability::Microphone
        } else {
            CaptureCapability::SyntheticOnly
        },
        transcription_model: cfg!(any(feature = "whisper-local", feature = "stt-sherpa")),
        notes_provider: cfg!(feature = "llm-openai-compat"),
    }
}

/// The input-device UIDs this platform offers, or `None` when this
/// build cannot enumerate them.
///
/// `None` is not an empty list: a build with no Core Audio binding
/// knows nothing about the machine's devices, and preflight reports
/// that as unverified rather than as a device that is missing.
#[must_use]
#[allow(
    clippy::missing_const_for_fn,
    reason = "const under one feature selection only; the enumerating build allocates"
)]
pub fn available_devices() -> Option<Vec<String>> {
    #[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
    {
        input_devices().ok().map(|devices| {
            devices
                .into_iter()
                .map(|device| device.uid)
                .collect::<Vec<_>>()
        })
    }
    #[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
    {
        None
    }
}

/// Resolves and checks this invocation, leaving the controller
/// preparing.
///
/// The one preflight, shared with every other surface. Six of its seven
/// checks used to be written inline further down this file, where no
/// other frontend could reach them and where a failure was labelled as
/// a capture failure rather than a preflight one.
///
/// # Errors
///
/// The preflight refusal, with every blocking check named.
pub fn begin_recording(controller: &RecordingController, args: &Args) -> Result<RecordingPlan> {
    let devices = available_devices();
    scrybe_application::recording::begin(
        controller,
        &config_service()?,
        home_directory().as_deref(),
        build_support(),
        devices.as_deref(),
        &overrides_from(args),
    )
    .map_err(|refusal| {
        // Both hints, when both checks blocked: a reader missing two
        // features should learn both in one attempt.
        let hints: Vec<String> = [rebuild_hint(&refusal), model_rebuild_hint(&refusal)]
            .into_iter()
            .flatten()
            .collect();
        let mut error = anyhow::Error::from(refusal.error);
        for hint in hints {
            error = error.context(hint);
        }
        error
    })
}

/// How to rebuild this binary so a capture it refused would work.
///
/// The preflight refuses a source this build cannot open, but it cannot
/// say what to do about it: the Cargo features that decide it belong to
/// this package, and `scrybe-application` is shared with a desktop host
/// whose answer is a different build entirely. So the generic refusal
/// travels, and the package-specific remedy is attached here.
fn rebuild_hint(refusal: &scrybe_application::recording::Refusal) -> Option<String> {
    let blocked_on_capture = refusal
        .report
        .blocking()
        .iter()
        .any(|finding| finding.check == scrybe_application::recording::PreflightCheck::Capture);
    if !blocked_on_capture {
        return None;
    }
    match refusal.plan.as_ref()?.source {
        CaptureSource::Synthetic => None,
        CaptureSource::Mic => Some(
            "--source mic requires the binary to be built with --features mic-capture; \
             this binary was built without it"
                .to_string(),
        ),
        CaptureSource::MicSystem => Some(
            "--source mic+system requires the binary to be built with both \
             --features mic-capture and --features system-capture-mac; \
             this binary was built without one or both"
                .to_string(),
        ),
    }
}

/// How to rebuild this binary so a model it refused would load.
///
/// Same division as `rebuild_hint`: the service layer refuses a model
/// this build carries no runtime for, and the Cargo feature that would
/// supply one belongs to this package.
fn model_rebuild_hint(refusal: &scrybe_application::recording::Refusal) -> Option<String> {
    let blocked_on_model = refusal
        .report
        .blocking()
        .iter()
        .any(|finding| finding.check == scrybe_application::recording::PreflightCheck::Model);
    if !blocked_on_model {
        return None;
    }
    match &refusal.plan.as_ref()?.transcription {
        TranscriptionModel::Stub => None,
        TranscriptionModel::Whisper(_) => Some(
            "--whisper-model requires the binary to be built with \
             --features whisper-local; this binary was built without it"
                .to_string(),
        ),
        TranscriptionModel::Sherpa(_) => Some(
            "--sherpa-model requires the binary to be built with \
             --features stt-sherpa; this binary was built without it"
                .to_string(),
        ),
    }
}

fn home_directory() -> Option<PathBuf> {
    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
}

pub async fn run(args: Args) -> Result<()> {
    let (stop_tx, stop_rx) = watch::channel(false);
    // The controller comes from the composition root, never from a
    // fresh instance: the tray, the floating pill, the global hotkey,
    // `Ctrl-C`, and `SIGTERM` must all converge on one state model, and
    // a second instance would let two consumers give two different
    // answers to "is a recording running".
    let controller = Arc::clone(application(args.root.as_deref())?.recording());
    // Resolution and the seven checks, then `Preparing` — all of it in
    // `scrybe-application`, so this terminal and the desktop host reach
    // the same answers from the same file. A refusal here has written
    // nothing, which is why `RecordingFailureKind::Preflight` means
    // what it says: no journal exists to recover.
    //
    // The controller stays `Preparing` from here until the pipeline
    // publishes `SessionProgress::Recording`, which is the real capture
    // boundary. Marking recording earlier started the single monotonic
    // origin before capture existed, counting permission prompts and a
    // model load as recorded time.
    let plan = begin_recording(&controller, &args)?;

    let signal_controller = Arc::clone(&controller);
    let signal_handle = tokio::spawn(monitor_signals(move || {
        if signal_controller.request_stop(StopSource::Signal) == StopAcceptance::Accepted {
            let _ = stop_tx.send(true);
        }
    }));
    let result = run_with_stop(args, plan, stop_rx, Some(Arc::clone(&controller))).await;
    signal_handle.abort();
    settle(&controller, result.as_ref().err());
    result
}

/// The only summary a recording failure ever carries into a serialized
/// event. Fixed by construction, so no path, provider, device, or model
/// identity can reach a surface through it.
pub const RECORDING_FAILURE_SUMMARY: &str = "recording session failed";

/// Walks the controller to a terminal state and back to idle.
///
/// The controller labels a failure from the state it happened in, so
/// this never decides whether a failure was capture-side or
/// finalization-side.
fn settle(controller: &RecordingController, failure: Option<&anyhow::Error>) {
    if let Some(error) = failure {
        settle_failure(controller, error);
    } else {
        if controller.snapshot().state == RecordingState::Recording {
            if let Err(conflict) = controller.begin_saving() {
                tracing::debug!(%conflict, "recording controller could not enter saving");
            }
        }
        if let Err(conflict) = controller.complete() {
            tracing::debug!(%conflict, "recording controller could not complete");
        }
    }
    if let Err(conflict) = controller.acknowledge() {
        tracing::debug!(%conflict, "recording controller could not settle to idle");
    }
}

/// Records `error` against the controller and returns the snapshot a
/// surface would be handed, or `None` if the state could not fail.
///
/// The summary passed to `fail` is a fixed literal, never
/// `error.to_string()`. That string becomes `RecordingFailure::summary`,
/// a `Serialize` field of both `RecordingEvent` and
/// `RecordingSnapshot` — the event boundary the contract says carries
/// no path or provider content — and the outermost `anyhow` context on
/// this path frequently is not fixed: creating the storage root
/// interpolates an absolute path, the config load propagates its own
/// config-path error, the notes runtime can surface a model path and
/// provider name, and input-device resolution surfaces device identity.
/// The detailed error still reaches the caller through the returned
/// `Result` and the trace here, neither of which is an event surface.
fn settle_failure(
    controller: &RecordingController,
    error: &anyhow::Error,
) -> Option<RecordingSnapshot> {
    tracing::error!(%error, "recording session failed");
    match controller.fail(RECORDING_FAILURE_SUMMARY) {
        Ok(snapshot) => Some(snapshot),
        Err(conflict) => {
            tracing::debug!(%conflict, "recording failure arrived in a state that cannot fail");
            None
        }
    }
}

#[cfg(feature = "mic-capture")]
fn start_registered_capture<T>(registry: &CaptureRegistry, capture: T) -> Result<CaptureFrameStream>
where
    T: AudioCapture,
{
    let capture = registry.register(capture);
    let mut capture = capture
        .lock()
        .map_err(|_| anyhow::anyhow!("capture registry adapter mutex poisoned"))?;
    capture.start()?;
    Ok(Box::pin(capture.frames()))
}

/// Drive a session under an externally-supplied stop signal. The shell driver
/// in `scrybe-cli::shell` calls this directly, feeding stop into `stop_rx` from
/// tray and hotkey events; `run` above wraps it with signal handling.
#[allow(clippy::too_many_lines)]
pub async fn run_with_stop(
    args: Args,
    plan: RecordingPlan,
    stop_rx: watch::Receiver<bool>,
    controller: Option<Arc<RecordingController>>,
) -> Result<()> {
    let cfg = config_service()?.load()?;
    // Every value this function used to resolve for itself now arrives
    // in `plan`, resolved once by the shared orchestration. What is
    // still read from `cfg` here are the blocks the plan does not
    // decide: the language a model is loaded with, the notes runtime's
    // own settings, and the endpoint the notes provider talks to.
    let root = plan.root.clone();
    tokio::fs::create_dir_all(&root)
        .await
        .with_context(|| format!("creating storage root {}", root.display()))?;

    let auto_accept = args.yes || std::env::var("SCRYBE_CONSENT_AUTO_ACCEPT").as_deref() == Ok("1");
    let prompter = TtyPrompter::new(auto_accept);
    let source = plan.source;
    let system_backend = plan.system_backend;
    #[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
    let _ = system_backend;
    let id = SessionId::new();
    let user = std::env::var("USER").unwrap_or_else(|_| "scrybe-user".into());
    let started_at = Utc::now();

    let capture_registry = CaptureRegistry::default();

    #[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
    let selected_input = match source {
        CaptureSource::Synthetic => None,
        CaptureSource::Mic | CaptureSource::MicSystem => {
            let device = resolve_macos_input_device(plan.input_device.as_deref())?;
            eprintln!("scrybe: input: {} ({})", device.name, device.uid);
            Some(device)
        }
    };

    let registry_for_stop = capture_registry.clone();
    let stop_future = Box::pin(async move {
        wait_for_stop(stop_rx).await;
        if let Err(error) = registry_for_stop.stop_all() {
            tracing::error!(error = %error, "stopping registered capture failed");
        }
    });
    let stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> = match source
    {
        CaptureSource::Synthetic => {
            Box::pin(synthetic_capture_stream(args.synthetic_secs).take_until(stop_future))
        }
        CaptureSource::Mic => {
            #[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
            {
                let device = selected_input
                    .as_ref()
                    .context("resolved microphone missing for mic capture")?;
                let stream = start_registered_capture(
                    &capture_registry,
                    NativeMicCapture::new(device.uid.clone(), plan.aec),
                )
                .with_context(|| {
                    format!(
                        "opening selected Core Audio input {} ({})",
                        device.name, device.uid
                    )
                })?;
                Box::pin(stream.take_until(stop_future))
            }
            #[cfg(all(feature = "mic-capture", not(feature = "system-capture-mac")))]
            {
                if plan.input_device.is_some() {
                    anyhow::bail!(
                        "--input-device requires a macOS build with --features \
                         mic-capture,system-capture-mac"
                    );
                }
                let stream = start_registered_capture(&capture_registry, MicCapture::new())
                    .context(
                        "opening default input device (grant Microphone permission \
                         in System Settings → Privacy & Security if prompted)",
                    )?;
                Box::pin(stream.take_until(stop_future))
            }
            #[cfg(not(feature = "mic-capture"))]
            {
                anyhow::bail!(
                    "--source mic requires the binary to be built with --features mic-capture; \
                     this binary was built without it"
                );
            }
        }
        CaptureSource::MicSystem => {
            #[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
            {
                use futures::stream;

                let (mut system_capture, system_frames, fallback_note) =
                    start_system_capture(system_backend).await?;
                if let Some(note) = fallback_note {
                    tracing::warn!(system_backend = "sck", "{note}");
                    write_capture_diagnostic(&root, started_at, id, args.title.as_deref(), note)?;
                }
                capture_registry.register_stopper(move || {
                    system_capture.stop().map_err(|error| {
                        CaptureError::Platform(Box::new(std::io::Error::other(error.to_string())))
                    })
                });
                let device = selected_input
                    .as_ref()
                    .context("resolved microphone missing for mic+system capture")?;
                let mic_frames = start_registered_capture(
                    &capture_registry,
                    NativeMicCapture::new(device.uid.clone(), plan.aec),
                )
                .with_context(|| {
                    format!(
                        "opening selected Core Audio input {} ({})",
                        device.name, device.uid
                    )
                })?;
                Box::pin(stream::select(mic_frames, system_frames).take_until(stop_future))
            }
            #[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
            {
                anyhow::bail!(
                    "--source mic+system requires the binary to be built with both \
                     --features mic-capture and --features system-capture-mac; \
                     this binary was built without one or both"
                );
            }
        }
    };
    let stream = capture_liveness_watchdog(stream, capture_registry.clone());

    // Capture is already running: the adapters buffer their frames
    // while a model initializes, so the Tap liveness probe runs during
    // startup instead of delaying recording. Everything from here —
    // the providers the plan names, the consent step, the pipeline,
    // and the controller driving — is `scrybe-application`'s, so this
    // terminal and a desktop host produce the same session from the
    // same configuration. Printing each progress event is the only
    // part that is this frontend's.
    let outputs = scrybe_application::recording::run(
        scrybe_application::recording::RecordingRun {
            plan: &plan,
            config: &cfg,
            id,
            started_at,
            user,
            prompter: &prompter,
            controller,
            on_progress: None,
            on_session_event: Some(Arc::new(print_session_progress)),
        },
        stream,
    )
    .await
    .context("running session");
    // One teardown for every outcome. It used to be two — one after a
    // provider failed to load and one after the session returned —
    // and a failure between them would have left capture running.
    if let Err(error) = capture_registry.stop_all() {
        tracing::error!(error = %error, "stopping capture after the session ended failed");
    }
    let outputs = outputs?;

    println!(
        "scrybe record: session {} written to {}",
        id,
        outputs.folder.display()
    );
    println!("  transcript: {}", outputs.transcript_path.display());
    println!("  notes:      {}", outputs.notes_path.display());
    println!("  meta:       {}", outputs.meta_path.display());
    if outputs.audio_path.exists() {
        println!("  audio:      {}", outputs.audio_path.display());
    }
    let playback_path = outputs.folder.join("playback.opus");
    if playback_path.exists() {
        println!("  playback:   {}", playback_path.display());
    }
    Ok(())
}

fn print_session_progress(event: SessionProgress) {
    match event {
        SessionProgress::Recording => {
            eprintln!("scrybe: recording; press Ctrl-C to stop");
        }
        SessionProgress::TranscriptAccepted(attributed) => {
            let elapsed_secs = attributed.chunk.start_ms / 1_000;
            let minutes = elapsed_secs / 60;
            let seconds = elapsed_secs % 60;
            let speaker = match &attributed.speaker {
                SpeakerLabel::Me => "Me",
                SpeakerLabel::Them => "Them",
                SpeakerLabel::Named(name) => name,
                SpeakerLabel::Unknown => "Unknown",
            };
            let text = attributed.chunk.text.trim();
            if !text.is_empty() {
                println!("[{minutes:02}:{seconds:02}] {speaker}: {text}");
            }
        }
        SessionProgress::FinalizingTranscript { pending_chunks } => {
            eprintln!(
                "scrybe: finalizing transcript ({pending_chunks} pending chunk{})",
                if pending_chunks == 1 { "" } else { "s" }
            );
        }
        SessionProgress::EncodingAudio => {
            eprintln!("scrybe: encoding audio artifacts");
        }
        SessionProgress::GeneratingNotes { groups } => {
            eprintln!(
                "scrybe: generating notes ({groups} request group{})",
                if groups == 1 { "" } else { "s" }
            );
        }
        SessionProgress::WritingMetadata => {
            eprintln!("scrybe: writing session metadata");
        }
    }
}

const CAPTURE_LIVENESS_TIMEOUT: Duration = Duration::from_secs(30);

fn capture_liveness_watchdog(
    stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>,
    capture_registry: CaptureRegistry,
) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
    capture_liveness_watchdog_with_timeout(stream, capture_registry, CAPTURE_LIVENESS_TIMEOUT)
}

fn capture_liveness_watchdog_with_timeout(
    stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>,
    capture_registry: CaptureRegistry,
    timeout: Duration,
) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
    Box::pin(stream::unfold(
        (stream, capture_registry, false),
        move |(mut stream, capture_registry, stopped)| async move {
            if stopped {
                return None;
            }
            match tokio::time::timeout(timeout, stream.next()).await {
                Ok(Some(frame)) => Some((frame, (stream, capture_registry, false))),
                Ok(None) => None,
                Err(_) => {
                    if let Err(error) = capture_registry.stop_all() {
                        tracing::error!(error = %error, "stopping stalled capture failed");
                    }
                    let error = CaptureError::Platform(Box::new(std::io::Error::new(
                        std::io::ErrorKind::TimedOut,
                        "capture liveness watchdog expired after 30 seconds without a frame",
                    )));
                    Some((Err(error), (stream, capture_registry, true)))
                }
            }
        },
    ))
}

#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
fn write_capture_diagnostic(
    root: &std::path::Path,
    started_at: chrono::DateTime<Utc>,
    id: SessionId,
    title: Option<&str>,
    note: &str,
) -> Result<()> {
    let folder = root.join(session_folder_name(
        started_at,
        title.unwrap_or("untitled"),
        id,
    ));
    std::fs::create_dir_all(&folder)
        .with_context(|| format!("creating capture diagnostic folder {}", folder.display()))?;
    std::fs::write(folder.join("capture.log"), format!("{note}\n"))
        .context("writing system-capture fallback diagnostic")
}

#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
fn resolve_macos_input_device(requested_uid: Option<&str>) -> Result<InputDevice> {
    let devices = input_devices()
        .map_err(anyhow::Error::from)
        .context("enumerating macOS Core Audio input devices")?;
    if let Some(uid) = requested_uid {
        return devices
            .into_iter()
            .find(|device| device.uid == uid)
            .with_context(|| format!("configured Core Audio input device `{uid}` was not found"));
    }
    devices
        .into_iter()
        .find(|device| device.is_default)
        .context("macOS has no default Core Audio input device")
}

/// Future that completes the first time `stop_rx` flips to `true`,
/// or when every `Sender` has been dropped. Used as the `take_until`
/// argument so capture tears down deterministically when a shell
/// control requests Stop & save.
async fn wait_for_stop(mut stop_rx: watch::Receiver<bool>) {
    let _ = stop_rx.wait_for(|stopped| *stopped).await;
}

/// First `SIGINT` or `SIGTERM` requests ordered shutdown through
/// `request_graceful_stop`. A second signal terminates immediately, leaving the
/// independently-written journal for `scrybe repair`.
pub async fn monitor_signals<F>(mut request_graceful_stop: F)
where
    F: FnMut() + Send + 'static,
{
    #[cfg(unix)]
    let Ok(mut sigterm) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) else {
        tracing::error!("installing SIGTERM listener failed");
        return;
    };
    let mut graceful_requested = false;
    loop {
        #[cfg(unix)]
        tokio::select! {
            _ = tokio::signal::ctrl_c() => {}
            _ = sigterm.recv() => {}
        }
        #[cfg(not(unix))]
        if tokio::signal::ctrl_c().await.is_err() {
            return;
        }
        if graceful_requested {
            std::process::exit(130);
        }
        graceful_requested = true;
        request_graceful_stop();
    }
}

/// Synthetic in-process capture source.
///
/// Generates 16-kHz mono frames of a 440 Hz sine wave for `seconds`
/// seconds and emits silence after to drive the silence-after-speech
/// chunker boundary at session end.
///
/// Frames emit as fast as the pipeline can consume them (no real-time
/// pacing) unless `SCRYBE_TEST_SYNTHETIC_FRAME_DELAY_MS` is set, in
/// which case each frame is preceded by a sleep of that many
/// milliseconds. The env var exists solely so
/// `scrybe-cli/tests/repair_sigkill.rs` can spawn a real `scrybe rec`
/// subprocess that stays "recording" long enough to `SIGKILL` it
/// mid-stream and exercise `scrybe repair` deterministically; ordinary
/// invocations (including every other test in this module) leave the
/// variable unset and see the original sub-second, instant-emission
/// behavior.
#[allow(clippy::cast_precision_loss)]
/// The shared synthetic source, paced for this binary's tests.
///
/// The frames themselves come from `scrybe-application`, so a desktop
/// host generating the same source generates the same audio. What is
/// added here is the delay `SCRYBE_TEST_SYNTHETIC_FRAME_DELAY_MS`
/// asks for: `tests/repair_sigkill.rs` needs a recording that is still
/// running when it sends the signal, and an in-process generator with
/// no pacing finishes before the test can. Unset — which is every
/// invocation a user makes — the stream is unpaced, exactly as it was.
fn synthetic_capture_stream(
    seconds: u64,
) -> impl Stream<Item = Result<AudioFrame, CaptureError>> + Send + Unpin {
    let frame_delay = synthetic_frame_delay();
    Box::pin(
        scrybe_application::recording::synthetic_frames(seconds).then(move |frame| async move {
            if !frame_delay.is_zero() {
                tokio::time::sleep(frame_delay).await;
            }
            frame
        }),
    )
}

fn synthetic_frame_delay() -> Duration {
    std::env::var("SCRYBE_TEST_SYNTHETIC_FRAME_DELAY_MS")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .map_or(Duration::ZERO, Duration::from_millis)
}

/// CLI-local STT dispatch over the two providers `scrybe record` can
/// pick at runtime. Enum variants stay `Sized` so the existing
/// `SessionInputs<S: SttProvider>` generic does not need a `?Sized`
/// relaxation in `scrybe-core` for this v1.0.x patch.
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use scrybe_core::config::{Config, RecordConfig, RECORD_SOURCE_MIC, RECORD_SYSTEM_BACKEND_TAP};
    #[tokio::test]
    async fn test_capture_liveness_watchdog_stops_adapters_and_reports_timeout() {
        let registry = CaptureRegistry::default();
        let stops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let observed_stops = Arc::clone(&stops);
        registry.register_stopper(move || {
            observed_stops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            Ok(())
        });

        let stalled = Box::pin(stream::pending::<Result<AudioFrame, CaptureError>>());
        let mut watchdog =
            capture_liveness_watchdog_with_timeout(stalled, registry, Duration::from_millis(10));

        let error = watchdog
            .next()
            .await
            .expect("watchdog error")
            .expect_err("timeout error");
        assert_eq!(error.to_string(), "platform API error: capture liveness watchdog expired after 30 seconds without a frame");
        assert_eq!(stops.load(std::sync::atomic::Ordering::SeqCst), 1);
        assert!(watchdog.next().await.is_none());
    }

    #[test]
    fn test_consent_mode_arg_quick_maps_to_consent_mode_quick() {
        let mode: ConsentMode = ConsentModeArg::Quick.into();

        assert_eq!(mode, ConsentMode::Quick);
    }

    #[test]
    fn test_consent_mode_arg_notify_maps_to_consent_mode_notify() {
        let mode: ConsentMode = ConsentModeArg::Notify.into();

        assert_eq!(mode, ConsentMode::Notify);
    }

    #[test]
    fn test_consent_mode_arg_announce_maps_to_consent_mode_announce() {
        let mode: ConsentMode = ConsentModeArg::Announce.into();

        assert_eq!(mode, ConsentMode::Announce);
    }

    #[tokio::test]
    async fn test_run_writes_session_artifacts_for_synthetic_capture() {
        // Point config discovery at a tempdir so the test does not
        // pick up a malformed real config from the developer's home.
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
        let dir = tempfile::tempdir().unwrap();

        run(Args {
            title: Some("synthetic".into()),
            root: Some(dir.path().to_path_buf()),
            yes: true,
            consent: Some(ConsentModeArg::Quick),
            synthetic_secs: 1,
            shell: false,
            source: Some(CaptureSourceArg::Synthetic),
            system_backend: None,
            llm: Some(LlmBackendArg::Stub),
            input_device: None,
            whisper_model: None,
            sherpa_model: None,
        })
        .await
        .unwrap();

        let mut entries = std::fs::read_dir(dir.path()).unwrap();
        let session = entries
            .next()
            .expect("a session folder must exist")
            .unwrap();
        assert!(session.path().join("transcript.md").exists());
        assert!(session.path().join("notes.md").exists());
        assert!(session.path().join("meta.toml").exists());
    }

    #[tokio::test]
    async fn test_wait_for_stop_resolves_when_sender_flips_to_true() {
        let (tx, rx) = watch::channel(false);
        let fut = wait_for_stop(rx);
        tokio::pin!(fut);

        assert!(
            futures::poll!(&mut fut).is_pending(),
            "wait_for_stop must remain pending while the flag is false"
        );

        tx.send(true).unwrap();
        fut.await;
    }

    #[tokio::test]
    async fn test_wait_for_stop_returns_immediately_when_sender_already_true() {
        let (_tx, rx) = watch::channel(true);

        wait_for_stop(rx).await;
    }

    #[tokio::test]
    async fn test_wait_for_stop_resolves_when_sender_dropped() {
        let (tx, rx) = watch::channel(false);
        drop(tx);

        wait_for_stop(rx).await;
    }

    #[tokio::test]
    async fn test_run_auto_accepts_consent_via_env_var_when_yes_flag_is_false() {
        // Exercises the right-hand side of
        //   `let auto_accept = args.yes
        //       || std::env::var("SCRYBE_CONSENT_AUTO_ACCEPT").as_deref() == Ok("1");`
        // Other tests pass `yes: true`, which short-circuits the OR
        // before the env-var check; this is the only path that
        // covers the env-var arm. Setting the env var here is safe
        // because every other record test already auto-accepts via
        // `yes: true`, so this test cannot flip an unsuspecting
        // sibling into a different code path.
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
        std::env::set_var("SCRYBE_CONSENT_AUTO_ACCEPT", "1");
        let dir = tempfile::tempdir().unwrap();

        let result = run(Args {
            title: Some("env-consent".into()),
            root: Some(dir.path().to_path_buf()),
            yes: false,
            consent: Some(ConsentModeArg::Quick),
            synthetic_secs: 1,
            shell: false,
            source: Some(CaptureSourceArg::Synthetic),
            system_backend: None,
            llm: Some(LlmBackendArg::Stub),
            input_device: None,
            whisper_model: None,
            sherpa_model: None,
        })
        .await;

        std::env::remove_var("SCRYBE_CONSENT_AUTO_ACCEPT");
        result.unwrap();
    }

    #[tokio::test]
    async fn test_synthetic_capture_stream_emits_only_silence_for_zero_seconds() {
        // `synthetic_capture_stream(0)` short-circuits the speech-frame
        // branch in the closure; cover the silence-only iteration path.
        let stream = synthetic_capture_stream(0);
        let frames: Vec<_> = stream.collect().await;

        let speech_count = frames
            .iter()
            .filter(|f| {
                f.as_ref()
                    .is_ok_and(|frame| frame.samples.iter().any(|s| s.abs() > 0.01))
            })
            .count();
        assert_eq!(speech_count, 0);
    }

    /// E-5 from `.docs/development-plan.md` §7.3.3: cold-start latency.
    ///
    /// The §7.3.3 budget is 12 s, anchored to real Whisper warm-up
    /// (loading `large-v3-turbo` weights, JIT-compiling Metal shaders,
    /// running a silence buffer to prime the encoder). With the stub
    /// providers used here, actual elapsed is sub-second; the budget
    /// loosens to 10 s as a "pipeline didn't hang or pick up an
    /// unbounded retry loop" guard. The Whisper-warm-up assertion
    /// returns when `whisper-local` is enabled in CI — currently that
    /// feature isn't on the default build because `whisper-rs` needs a
    /// verified C++ toolchain on the macos-14 hosted runner per
    /// the `scrybe` package's `[package.metadata.dist]` block.
    ///
    /// 10 s is loose enough to absorb CI noise (Windows shared
    /// runners are the slowest cell in the matrix today; the macos-14
    /// build job's full pipeline takes ~50 s, of which test startup
    /// is a few hundred ms). If this test starts flaking, the right
    /// move is to investigate what's slowing the stub-provider path,
    /// not to bump the budget further.
    #[tokio::test]
    async fn test_run_completes_within_cold_start_budget_with_stub_providers() {
        const COLD_START_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);

        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
        let dir = tempfile::tempdir().unwrap();

        let started = std::time::Instant::now();
        run(Args {
            title: Some("cold-start".into()),
            root: Some(dir.path().to_path_buf()),
            yes: true,
            consent: Some(ConsentModeArg::Quick),
            synthetic_secs: 1,
            shell: false,
            source: Some(CaptureSourceArg::Synthetic),
            system_backend: None,
            llm: Some(LlmBackendArg::Stub),
            input_device: None,
            whisper_model: None,
            sherpa_model: None,
        })
        .await
        .unwrap();
        let elapsed = started.elapsed();

        assert!(
            elapsed < COLD_START_BUDGET,
            "cold-start exceeded {COLD_START_BUDGET:?}: actual {elapsed:?} \
             — the stub-provider path should complete sub-second; investigate \
             before bumping this budget"
        );
    }

    #[test]
    fn test_capture_source_arg_default_is_synthetic() {
        assert_eq!(CaptureSourceArg::default(), CaptureSourceArg::Synthetic);
    }

    #[test]
    fn test_capture_source_arg_parses_mic_plus_system_token() {
        // Clap's `ValueEnum::from_str` is the surface users hit when
        // they type `--source mic+system`. The literal `+` is preserved
        // through the `#[value(name = "mic+system")]` attribute on the
        // enum variant. Asserts the parser accepts the documented
        // token and produces the expected variant.
        use clap::ValueEnum;
        let arg = CaptureSourceArg::from_str("mic+system", false)
            .expect("`mic+system` must parse to MicSystem");
        assert_eq!(arg, CaptureSourceArg::MicSystem);
    }

    #[test]
    fn test_capture_source_arg_rejects_typo_variants() {
        use clap::ValueEnum;
        // Common typos that should NOT silently parse to a variant.
        for bad in ["mic-system", "mic_system", "system", "system+mic"] {
            let r = CaptureSourceArg::from_str(bad, false);
            assert!(r.is_err(), "{bad} must not parse to any variant; got {r:?}");
        }
    }

    /// Resolution moved to `scrybe-application`, but the CLI's own
    /// claim — that `--system-backend` beats the file — is about this
    /// binary's flags, so it stays asserted here, through the path the
    /// binary now takes.
    #[test]
    fn test_system_backend_flag_overrides_record_config() {
        let config = Config {
            record: RecordConfig {
                system_backend: RECORD_SYSTEM_BACKEND_TAP.to_string(),
                ..RecordConfig::default()
            },
            ..Config::default()
        };
        let args = Args {
            system_backend: Some(SystemBackendArg::Sck),
            ..bare_args()
        };

        let plan = RecordingPlan::resolve(&config, None, &overrides_from(&args)).unwrap();

        assert_eq!(plan.system_backend, SystemBackend::Sck);
    }

    #[test]
    fn test_system_backend_uses_valid_record_config_then_default() {
        let tap = Config {
            record: RecordConfig {
                system_backend: RECORD_SYSTEM_BACKEND_TAP.to_string(),
                ..RecordConfig::default()
            },
            ..Config::default()
        };
        let overrides = overrides_from(&bare_args());

        assert_eq!(
            RecordingPlan::resolve(&tap, None, &overrides)
                .unwrap()
                .system_backend,
            SystemBackend::Tap
        );
        assert_eq!(
            RecordingPlan::resolve(&Config::default(), None, &overrides)
                .unwrap()
                .system_backend,
            SystemBackend::Sck
        );
    }

    #[test]
    fn test_tap_fallback_is_single_hop_to_sck() {
        assert_eq!(
            fallback_backend(SystemBackend::Tap),
            Some(SystemBackend::Sck)
        );
        assert_eq!(fallback_backend(SystemBackend::Sck), None);
    }

    fn system_frame(samples: &[f32], timestamp_ns: u64) -> AudioFrame {
        AudioFrame::from_slice(
            samples,
            1,
            16_000,
            timestamp_ns,
            scrybe_core::types::FrameSource::System,
        )
    }

    #[tokio::test]
    async fn test_silent_tap_startup_falls_back_without_dropping_frames() {
        let input = vec![
            Ok(system_frame(&[0.0, 0.0], 0)),
            Ok(system_frame(&[0.0, 0.0], 125_000)),
        ];

        let (active, frames) =
            tap_produces_nonzero_frames(Box::pin(futures::stream::iter(input))).await;
        let observed: Vec<_> = frames
            .map(|frame| {
                let frame = frame.unwrap();
                (frame.timestamp_ns, frame.samples.to_vec())
            })
            .collect()
            .await;

        assert!(!active);
        assert_eq!(
            observed,
            vec![(0, vec![0.0, 0.0]), (125_000, vec![0.0, 0.0])]
        );
    }

    #[tokio::test]
    async fn test_active_tap_startup_preserves_buffered_and_remaining_frames() {
        let input = vec![
            Ok(system_frame(&[0.0, 0.0], 0)),
            Ok(system_frame(&[0.25, 0.0], 125_000)),
            Ok(system_frame(&[0.5, 0.0], 250_000)),
        ];

        let (active, frames) =
            tap_produces_nonzero_frames(Box::pin(futures::stream::iter(input))).await;
        let observed: Vec<_> = frames
            .map(|frame| {
                let frame = frame.unwrap();
                (frame.timestamp_ns, frame.samples.to_vec())
            })
            .collect()
            .await;

        assert!(active);
        assert_eq!(
            observed,
            vec![
                (0, vec![0.0, 0.0]),
                (125_000, vec![0.25, 0.0]),
                (250_000, vec![0.5, 0.0]),
            ]
        );
    }

    #[test]
    fn test_fallback_diagnostic_uses_initial_session_folder() {
        let root = tempfile::tempdir().unwrap();
        let started_at = Utc::now();
        let id = SessionId::new();

        write_capture_diagnostic(
            root.path(),
            started_at,
            id,
            Some("Initial title"),
            "system capture switched from tap to sck after no tap startup activity",
        )
        .unwrap();

        let folder = root
            .path()
            .join(session_folder_name(started_at, "Initial title", id));
        assert_eq!(
            std::fs::read_to_string(folder.join("capture.log")).unwrap(),
            "system capture switched from tap to sck after no tap startup activity\n"
        );
    }

    #[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
    #[tokio::test]
    async fn test_run_with_mic_system_source_errors_without_both_features() {
        // The MicSystem arm of the source match must hard-error when
        // either of the two underlying features is missing. Surfaces
        // as an `anyhow::Error` rooted in the bail! string so the user
        // sees the named features they need to rebuild with.
        std::env::set_var("SCRYBE_CONSENT_AUTO_ACCEPT", "1");
        let dir = tempfile::tempdir().unwrap();
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
        let result = run(Args {
            title: Some("ms-feature-gate".into()),
            root: Some(dir.path().to_path_buf()),
            yes: true,
            consent: Some(ConsentModeArg::Quick),
            synthetic_secs: 1,
            shell: false,
            source: Some(CaptureSourceArg::MicSystem),
            system_backend: None,
            llm: Some(LlmBackendArg::Stub),
            whisper_model: None,
            sherpa_model: None,
            input_device: None,
        })
        .await;
        let Err(err) = result else {
            panic!("MicSystem without both features must error");
        };
        let msg = format!("{err:?}");
        assert!(
            msg.contains("--source mic+system")
                && msg.contains("mic-capture")
                && msg.contains("system-capture-mac"),
            "error must name the source flag and both required features; got: {msg}"
        );
    }

    /// A model flag on a binary built without the runtime for it is
    /// refused, and the refusal still names the flag and the feature to
    /// rebuild with. The refusal itself moved to the shared preflight,
    /// which cannot name a Cargo feature of this package; the guidance
    /// is attached here, and this is what proves it still reaches the
    /// user.
    #[cfg(not(feature = "whisper-local"))]
    #[test]
    fn test_a_whisper_model_without_the_feature_is_refused_with_the_rebuild_guidance() {
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
        let directory = tempfile::tempdir().unwrap();
        let controller = app_controller(directory.path());

        let result = begin_recording(
            &controller,
            &Args {
                root: Some(directory.path().to_path_buf()),
                whisper_model: Some(directory.path().join("no-such-model.bin")),
                ..bare_args()
            },
        );

        let Err(error) = result else {
            panic!("a model without its runtime must be refused rather than silently stubbed");
        };
        let message = format!("{error:?}");
        assert!(
            message.contains("--whisper-model") && message.contains("--features whisper-local"),
            "the refusal must name both the flag and the missing feature; got: {message}"
        );
    }

    #[cfg(not(feature = "stt-sherpa"))]
    #[test]
    fn test_a_sherpa_model_without_the_feature_is_refused_with_the_rebuild_guidance() {
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
        let directory = tempfile::tempdir().unwrap();
        let controller = app_controller(directory.path());

        let result = begin_recording(
            &controller,
            &Args {
                root: Some(directory.path().to_path_buf()),
                sherpa_model: Some(directory.path().join("no-such-model")),
                ..bare_args()
            },
        );

        let Err(error) = result else {
            panic!("a model without its runtime must be refused rather than silently stubbed");
        };
        let message = format!("{error:?}");
        assert!(
            message.contains("--sherpa-model") && message.contains("--features stt-sherpa"),
            "the refusal must name both the flag and the missing feature; got: {message}"
        );
    }

    /// `--sherpa-model` beating a configured whisper path is a claim
    /// about this binary's flags, so it stays asserted here even though
    /// the ordering rule itself now lives in `scrybe-application`.
    #[test]
    fn test_explicit_sherpa_model_overrides_configured_whisper_model() {
        let config = Config {
            record: RecordConfig {
                source: RECORD_SOURCE_MIC.to_string(),
                whisper_model: Some(PathBuf::from("/models/whisper.bin")),
                ..RecordConfig::default()
            },
            ..Config::default()
        };
        let args = Args {
            sherpa_model: Some(PathBuf::from("/models/sherpa")),
            ..bare_args()
        };

        let plan = RecordingPlan::resolve(&config, None, &overrides_from(&args)).unwrap();

        assert_eq!(
            plan.transcription,
            TranscriptionModel::Sherpa(PathBuf::from("/models/sherpa"))
        );
    }

    /// The flags a test builds a plan from, with every override absent.
    fn bare_args() -> Args {
        Args {
            title: None,
            root: None,
            yes: false,
            consent: None,
            synthetic_secs: 5,
            source: None,
            input_device: None,
            system_backend: None,
            whisper_model: None,
            sherpa_model: None,
            llm: None,
            shell: false,
        }
    }

    /// An unfinished download is a file that exists, so preflight
    /// passes it and the load is what refuses. Construction moved to
    /// `scrybe-application`; what this asserts is that this binary
    /// still surfaces the refusal rather than transcribing with it.
    #[cfg(feature = "whisper-local")]
    #[test]
    fn test_a_partially_downloaded_whisper_model_is_refused_at_load() {
        let dir = tempfile::tempdir().unwrap();
        let partial = dir.path().join("ggml-tiny.bin.partial");
        std::fs::write(&partial, b"unfinished download").unwrap();
        let plan = RecordingPlan::resolve(
            &Config::default(),
            None,
            &overrides_from(&Args {
                whisper_model: Some(partial),
                ..bare_args()
            }),
        )
        .unwrap();

        let result = scrybe_application::recording::transcription(&plan, "en");

        let Err(error) = result else {
            panic!("an unfinished download must be rejected at construction");
        };
        let message = format!("{error:?}");
        assert!(
            message.contains("could not be loaded"),
            "the refusal must name the loading step; got: {message}"
        );
    }

    /// `--llm openai-compat` on a binary with no notes provider is
    /// refused rather than silently stubbed. The refusal is the shared
    /// preflight's `Provider` check now; what this asserts is that the
    /// user still learns it from the flag they typed.
    #[cfg(not(feature = "llm-openai-compat"))]
    #[test]
    fn test_openai_compat_without_the_feature_is_refused_rather_than_silently_stubbed() {
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
        let directory = tempfile::tempdir().unwrap();
        let controller = app_controller(directory.path());

        let result = begin_recording(
            &controller,
            &Args {
                root: Some(directory.path().to_path_buf()),
                llm: Some(LlmBackendArg::OpenAiCompat),
                ..bare_args()
            },
        );

        let Err(error) = result else {
            panic!("openai-compat without the feature must be refused, not stubbed");
        };
        let message = format!("{error:?}");
        assert!(
            message.contains("openai-compat") && message.contains("no notes provider"),
            "the refusal must name the backend and say the build carries none; got: {message}"
        );
    }

    #[cfg(feature = "llm-openai-compat")]
    #[test]
    fn test_build_llm_provider_constructs_openai_compat_when_feature_enabled() {
        // Construction does not exercise the network (the inner reqwest
        // Client is built but no request is dispatched). We assert the
        // returned variant reports a `<provider>:<model>` name so the
        // [llm] config block flowed through `from_config` correctly.
        let cfg = scrybe_core::config::LlmConfig {
            provider: "ollama".into(),
            model: "llama3.1:8b".into(),
            ..scrybe_core::config::LlmConfig::default()
        };

        let plan = RecordingPlan::resolve(
            &Config::default(),
            None,
            &overrides_from(&Args {
                llm: Some(LlmBackendArg::OpenAiCompat),
                ..bare_args()
            }),
        )
        .unwrap();

        let llm = scrybe_application::recording::notes(&plan, &cfg)
            .expect("openai-compat branch must succeed when feature is on");

        assert_eq!(
            scrybe_core::providers::LlmProvider::name(&llm),
            "ollama:llama3.1:8b"
        );
    }

    #[test]
    fn test_llm_backend_arg_default_is_stub() {
        assert_eq!(LlmBackendArg::default(), LlmBackendArg::Stub);
    }
    /// The process-wide controller, taken from the composition root
    /// exactly as `run` does. There is no other way to obtain one.
    fn app_controller(root: &std::path::Path) -> Arc<RecordingController> {
        Arc::clone(application(Some(root)).unwrap().recording())
    }

    /// `Args` for a synthetic one-second session under `root`.
    fn synthetic_args(root: PathBuf) -> Args {
        Args {
            title: Some("labelling".into()),
            root: Some(root),
            yes: true,
            consent: Some(ConsentModeArg::Quick),
            synthetic_secs: 1,
            shell: false,
            source: Some(CaptureSourceArg::Synthetic),
            system_backend: None,
            llm: Some(LlmBackendArg::Stub),
            input_device: None,
            whisper_model: None,
            sherpa_model: None,
        }
    }

    /// The preflight runs before anything is written, so a refusal is
    /// labelled `Preflight` — the kind whose documented meaning is that
    /// no journal exists to recover — and leaves the filesystem as it
    /// found it. Asserted on the filesystem, not on the returned error.
    #[test]
    fn test_a_preflight_failure_is_labelled_preflight_and_leaves_nothing_on_disk() {
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
        let dir = tempfile::tempdir().unwrap();
        // A regular file where the storage root's parent would have to
        // be a directory.
        let blocker = dir.path().join("not-a-directory");
        std::fs::write(&blocker, b"").unwrap();
        let root = blocker.join("sessions");

        let controller = app_controller(dir.path());
        let kinds = Arc::new(std::sync::Mutex::new(Vec::new()));
        let recorded = Arc::clone(&kinds);
        controller.subscribe(Arc::new(move |event| {
            if let Some(failure) = &event.failure {
                recorded.lock().unwrap().push(failure.kind);
            }
        }));

        let result = begin_recording(&controller, &synthetic_args(root.clone()));

        assert!(result.is_err());
        assert_eq!(
            *kinds.lock().unwrap(),
            vec![scrybe_application::recording::RecordingFailureKind::Preflight]
        );
        // Settled, so the next recording can start.
        assert_eq!(controller.snapshot().state, RecordingState::Idle);
        assert!(!root.exists(), "a failed preflight must write nothing");
        assert!(
            !blocker.is_dir(),
            "a failed preflight must not have replaced the blocker"
        );
    }

    #[tokio::test]
    async fn test_a_finalization_failure_after_a_natural_capture_end_is_not_labelled_capture() {
        let cfg_dir = tempfile::tempdir().unwrap();
        std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
        let dir = tempfile::tempdir().unwrap();

        let controller = app_controller(dir.path());
        let args = synthetic_args(dir.path().to_path_buf());
        let plan = begin_recording(&controller, &args).unwrap();
        let (_stop_tx, stop_rx) = watch::channel(false);
        // `--synthetic-secs` ends capture on its own, so nothing ever
        // requests a stop. The controller must still have entered
        // `Saving` by the time finalization runs; otherwise a failure
        // while merging, encoding, transcribing, or writing
        // `meta.toml` would be labelled `Capture` even though audio
        // exists and the session is repairable.
        run_with_stop(args, plan, stop_rx, Some(Arc::clone(&controller)))
            .await
            .unwrap();

        // Capture ended on its own — nothing requested a stop — yet
        // finalization must still have run in `Saving`. Otherwise a
        // failure while merging, encoding, transcribing, or writing
        // `meta.toml` would be labelled `Capture` even though audio
        // exists and the session is repairable.
        assert_eq!(controller.snapshot().state, RecordingState::Saving);
        assert_eq!(
            controller
                .fail(RECORDING_FAILURE_SUMMARY)
                .unwrap()
                .failure
                .map(|failure| failure.kind),
            Some(scrybe_application::recording::RecordingFailureKind::Finalization)
        );
    }

    #[tokio::test]
    async fn test_a_capture_side_failure_is_not_labelled_finalization() {
        let dir = tempfile::tempdir().unwrap();

        let controller = app_controller(dir.path());
        controller.begin_preparing().unwrap();
        controller.mark_recording().unwrap();

        // A capture-side error that surfaces before any finalization
        // event must not be labelled `Finalization`.
        let failed = controller.fail(RECORDING_FAILURE_SUMMARY).unwrap();

        assert_eq!(
            failed.failure.map(|failure| failure.kind),
            Some(scrybe_application::recording::RecordingFailureKind::Capture)
        );
    }

    #[test]
    fn test_no_serialized_failure_carries_a_path_or_provider_name() {
        let dir = tempfile::tempdir().unwrap();
        let controller = app_controller(dir.path());
        controller.begin_preparing().unwrap();

        // The shapes the outermost `anyhow` context really takes on
        // this path: an absolute storage root, a notes model path and
        // provider name, and an input-device identity.
        let error = anyhow::anyhow!("connection refused")
            .context("loading notes model /Users/someone/Library/scrybe/qwen3-8b.gguf")
            .context("resolving input device MacBook Pro Microphone (openai-compat)")
            .context("creating storage root /Users/someone/Meetings/scrybe");
        let snapshot = settle_failure(&controller, &error).expect("preparing can fail");
        let encoded = serde_json::to_string(&snapshot).unwrap();

        // Asserting on the key set alone could never catch this; the
        // leak was always in the values.
        assert!(!encoded.contains('/'), "a path reached an event: {encoded}");
        for secret in ["Users", "qwen3", "openai-compat", "MacBook", "gguf"] {
            assert!(
                !encoded.contains(secret),
                "{secret} reached an event: {encoded}"
            );
        }
        assert!(encoded.contains(RECORDING_FAILURE_SUMMARY));
    }
}