atomwrite 0.1.38

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
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
// SPDX-License-Identifier: MIT OR Apache-2.0

//! File reading with metadata, checksum, and optional content.
//! Workload: I/O-bound (file read + NDJSON output).
//! Parallelism: none — single file.

use std::fs;
use std::io::Write;
use std::path::Path;
use std::time::Instant;

use anyhow::{Context, Result};

use crate::binary_detect;
use crate::checksum;
use crate::cli::{GlobalArgs, OutputFormat, ReadArgs};
use crate::error::AtomwriteError;
use crate::ndjson_types::{LineRange, MediaInfo, ReadOutput};
use crate::output::NdjsonWriter;

/// Read a file and emit metadata, checksum, and optional content as NDJSON.
///
/// # Errors
///
/// Returns `AtomwriteError::NotFound` if the file does not exist.
/// Returns `AtomwriteError::StateDrift` if `--verify-checksum` fails.
///
/// `--format raw` writes the file's bytes, and refuses a binary target with a
/// hint instead of spending tokens on bytes that carry no text.
#[tracing::instrument(skip_all, fields(command = "read"))]
pub fn cmd_read(
    args: &ReadArgs,
    global: &GlobalArgs,
    writer: &mut NdjsonWriter<impl Write>,
    verb: &str,
) -> Result<()> {
    let start = Instant::now();
    // Removing comment lines without numbers would shift every later line, and
    // the `apply` that follows would target the wrong one.
    if args.no_comments && !numbering_requested(args) {
        return Err(AtomwriteError::InvalidInput {
            reason: format!(
                "{verb} --no-comments {} so every line keeps the file's number; add --number",
                crate::constants::NO_COMMENTS_NEEDS_NUMBER_REASON_MARKER
            ),
        }
        .into());
    }
    let workspace = global.resolve_workspace()?;
    let targets = resolve_targets(&args.paths, &workspace)?;

    // Raw mode IS the byte channel, so several operands concatenate right here.
    if matches!(args.format, OutputFormat::Raw) {
        return write_raw_targets(&targets, args, global, &workspace, writer, verb);
    }

    // Every other mode answers for ONE subject: a pattern that matched nothing
    // is the same failure a missing name is, and several targets are a mistake.
    if let Some(pattern) = targets.empty_patterns.first() {
        return Err(AtomwriteError::GlobNoMatches {
            pattern: pattern.clone(),
        }
        .into());
    }
    if targets.paths.len() != 1 {
        // The verb names itself (A13), and the wording stays clear of the
        // carving-flag marker: a call that named two files without `--head` or
        // `--lines` must not be told to drop a flag it never passed.
        return Err(AtomwriteError::InvalidInput {
            reason: format!(
                "{verb} takes one target per invocation, and {} were named",
                targets.paths.len()
            ),
        }
        .into());
    }
    let path = crate::path_safety::validate_path(&targets.paths[0], &workspace)?;

    if !path.exists() {
        return Err(AtomwriteError::NotFound { path }.into());
    }

    // A pattern that does not compile is refused up front, not ignored while filtering.
    if let Some(pattern) = args.grep.as_deref() {
        regex::Regex::new(pattern).map_err(|e| {
            crate::error::AtomwriteError::invalid(&format!("invalid --grep pattern '{pattern}'"), e)
        })?;
    }

    let metadata =
        fs::metadata(&path).with_context(|| format!("cannot stat {}", path.display()))?;
    let max_size = global.effective_max_filesize();

    // Every other mode needs the digest, the size and the first bytes only: a
    // 280 MB file used to be read whole just to answer `--stat`, and the peak
    // matched the file size. The digest comes from the mmap path for large
    // files, and the line pass below streams with a fixed buffer.
    let is_binary = detect_binary_prefix(&path)?;
    // Streaming, not mmap: a mapped file counts its pages in RSS, and this verb
    // promises a memory ceiling it cannot keep while the whole mapping is live.
    let (hash, bytes) = checksum::hash_file_streaming(&path, max_size)?;

    if let Some(ref expected) = args.verify_checksum
        && &hash != expected
    {
        return Err(AtomwriteError::ChecksumVerifyFailed {
            path,
            expected: expected.clone(),
        }
        .into());
    }

    // A picture or a PDF is not text, so the line machinery below would spend
    // the call on a header and answer nothing a model can use. The media path
    // owns the payload from here.
    if let Some(kind) = crate::media::classify(&path) {
        return read_media(kind, &path, args, writer, &metadata, &hash, bytes);
    }

    // `--ocr` reads pixels, and this file has characters. Taking the flag and
    // doing nothing would let a caller believe the text came from recognition.
    if args.ocr {
        return Err(AtomwriteError::InvalidInput {
            reason: format!(
                "read: {} carries text, so --ocr has nothing to recognize; it applies to a \
                 picture or to a PDF page",
                path.display()
            ),
        }
        .into());
    }

    // `--no-comments` needs a grammar that names comment markers. A plain text
    // target has none, and the filter still drops blank lines; saying so keeps
    // the flag from looking like it did nothing.
    if args.no_comments
        && !is_binary
        && crate::syntax_check::detect_language_name(&path, &[]).is_none()
    {
        crate::best_effort::best_effort(
            writeln!(
                std::io::stderr().lock(),
                "atomwrite: {} has no known language, so --no-comments drops blank lines and lines starting with //, #, --, /* or *",
                path.display()
            ),
            "warn that --no-comments has no comment syntax to drop",
        );
    }

    let permissions_str = format_permissions(&metadata);
    let modified_str = format_modified(&metadata);

    // `--stat` publishes metadata only; the line total still comes from a
    // streaming pass, so the field keeps its meaning without holding the file.
    let mut cut = None;
    let (content_str, lines_total) = if is_binary {
        (None, 0u64)
    } else if args.stat {
        (None, count_lines_streaming(&path)?)
    } else {
        let scanned = scan_lines_streaming(&path, args, max_size)?;
        cut = scanned.truncated.then(|| (true, scanned.next.clone()));
        (Some(scanned.content), scanned.total)
    };
    let (truncated, next) = cut.unwrap_or((false, None));

    let line_count = content_str
        .as_deref()
        .map(|s| s.lines().count() as u64)
        .unwrap_or(lines_total);
    let range = if is_binary {
        None
    } else {
        parse_line_range(args, lines_total)
    };

    // FIX GAP-2026-009: emit `mode` discriminator for downstream consumers
    let mode = if args.stat {
        "stat"
    } else if args.line.is_some() {
        "line"
    } else if !args.lines.is_empty() {
        "lines"
    } else if args.head.is_some() {
        "head"
    } else if args.tail.is_some() {
        "tail"
    } else if args.grep.is_some() {
        "grep"
    } else {
        "full"
    };

    let output = ReadOutput {
        r#type: "read",
        path: path.display().to_string(),
        content: content_str,
        lines: line_count,
        lines_total: if lines_total != line_count {
            Some(lines_total)
        } else {
            None
        },
        truncated: truncated.then_some(true),
        next,
        bytes,
        checksum: hash,
        permissions: permissions_str,
        modified: modified_str,
        kind: if is_binary {
            "binary".into()
        } else {
            "text".into()
        },
        binary: is_binary,
        range,
        verified: args.verify_checksum.as_ref().map(|_| true),
        mode: mode.to_string(),
        media: None,
    };

    writer.write_event(&output)?;

    tracing::debug!(path = %path.display(), elapsed_ms = start.elapsed().as_millis() as u64, "read complete");
    Ok(())
}
/// Answer a picture or a PDF, which the text path cannot carry.
///
/// A picture leaves as one `data:` URI line on stdout, because that is the only
/// shape the vision channel accepts. A PDF leaves as text per page, with the
/// pages that carry no text named, because silence about a scanned page is the
/// most expensive answer this verb can give.
fn read_media(
    kind: crate::media::MediaKind,
    path: &Path,
    args: &ReadArgs,
    writer: &mut NdjsonWriter<impl Write>,
    metadata: &fs::Metadata,
    hash: &str,
    bytes: u64,
) -> Result<()> {
    let request = media_request(args)?;
    match kind {
        crate::media::MediaKind::Image(format) => {
            let facts = crate::media::image_uri::facts(path, format, &request)?;
            if args.stat {
                let output = ReadOutput {
                    r#type: "read",
                    path: path.display().to_string(),
                    content: None,
                    lines: 0,
                    lines_total: None,
                    truncated: None,
                    next: None,
                    bytes,
                    checksum: hash.to_owned(),
                    permissions: format_permissions(metadata),
                    modified: format_modified(metadata),
                    kind: "image".into(),
                    binary: true,
                    range: None,
                    verified: None,
                    mode: "stat".into(),
                    media: Some(MediaInfo {
                        kind: "image".into(),
                        width: Some(facts.width),
                        height: Some(facts.height),
                        format: Some(facts.format.to_owned()),
                        visual_tokens: Some(facts.visual_tokens),
                        frames: facts.frames,
                        orientation: facts.orientation,
                        pages: None,
                        needs_vision: None,
                    }),
                };
                writer.write_event(&output)?;
                return Ok(());
            }
            let file = FileFacts {
                path,
                metadata,
                hash,
                bytes,
            };
            if args.ocr {
                let picture = crate::media::image_uri::decode_for_ocr(path, &request)?;
                return emit_recognized(&file, writer, "image", &picture, None);
            }
            // The session already paid for these exact bytes. Answering with
            // the facts keeps the caller's context free of a second copy, and
            // `--force` is how it asks for the pixels again.
            if !args.force && crate::media::image_uri::delivered_in_session(hash) {
                let output = ReadOutput {
                    r#type: "read",
                    path: path.display().to_string(),
                    content: None,
                    lines: 0,
                    lines_total: None,
                    truncated: None,
                    next: None,
                    bytes,
                    checksum: hash.to_owned(),
                    permissions: format_permissions(metadata),
                    modified: format_modified(metadata),
                    kind: "image".into(),
                    binary: true,
                    range: None,
                    verified: None,
                    mode: "delivered".into(),
                    media: Some(MediaInfo {
                        kind: "image".into(),
                        width: Some(facts.width),
                        height: Some(facts.height),
                        format: Some(facts.format.to_owned()),
                        visual_tokens: Some(facts.visual_tokens),
                        frames: facts.frames,
                        orientation: facts.orientation,
                        pages: None,
                        needs_vision: None,
                    }),
                };
                writer.write_event(&output)?;
                return Ok(());
            }
            let (uri, facts) = crate::media::image_uri::to_data_uri(path, format, &request)?;
            crate::media::image_uri::guard_size(&uri, crate::constants::IMAGE_MAX_DATA_URI_BYTES)?;
            tracing::debug!(
                path = %path.display(),
                width = facts.width,
                height = facts.height,
                visual_tokens = facts.visual_tokens,
                "picture encoded"
            );
            writer.flush()?;
            let mut out = std::io::stdout().lock();
            out.write_all(uri.as_bytes())?;
            out.write_all(b"\n")?;
            out.flush()?;
            crate::media::image_uri::mark_delivered(hash);
            Ok(())
        }
        crate::media::MediaKind::Heic => {
            let (source_width, source_height) = crate::media::image_uri::heic_size(path)?;
            let (max_edge, max_tokens) =
                crate::media::token_rule::tier_limits(request.high_resolution);
            let max_tokens = request.max_tokens.unwrap_or(max_tokens);
            let (final_width, final_height) =
                crate::media::token_rule::fit(source_width, source_height, max_edge, max_tokens);
            if args.stat {
                let output = ReadOutput {
                    r#type: "read",
                    path: path.display().to_string(),
                    content: None,
                    lines: 0,
                    lines_total: None,
                    truncated: None,
                    next: None,
                    bytes,
                    checksum: hash.to_owned(),
                    permissions: format_permissions(metadata),
                    modified: format_modified(metadata),
                    kind: "image".into(),
                    binary: true,
                    range: None,
                    verified: None,
                    mode: "stat".into(),
                    media: Some(MediaInfo {
                        kind: "image".into(),
                        width: Some(source_width),
                        height: Some(source_height),
                        format: Some("heic".into()),
                        visual_tokens: Some(crate::media::token_rule::visual_tokens(
                            final_width,
                            final_height,
                        )),
                        frames: None,
                        orientation: None,
                        pages: None,
                        needs_vision: None,
                    }),
                };
                writer.write_event(&output)?;
                return Ok(());
            }
            if args.ocr {
                let picture = crate::media::image_uri::decode_heic(path)?;
                let file = FileFacts {
                    path,
                    metadata,
                    hash,
                    bytes,
                };
                return emit_recognized(&file, writer, "image", &picture, None);
            }
            if !args.force && crate::media::image_uri::delivered_in_session(hash) {
                let output = ReadOutput {
                    r#type: "read",
                    path: path.display().to_string(),
                    content: None,
                    lines: 0,
                    lines_total: None,
                    truncated: None,
                    next: None,
                    bytes,
                    checksum: hash.to_owned(),
                    permissions: format_permissions(metadata),
                    modified: format_modified(metadata),
                    kind: "image".into(),
                    binary: true,
                    range: None,
                    verified: None,
                    mode: "delivered".into(),
                    media: Some(MediaInfo {
                        kind: "image".into(),
                        width: Some(final_width),
                        height: Some(final_height),
                        format: Some("jpeg".into()),
                        visual_tokens: Some(crate::media::token_rule::visual_tokens(
                            final_width,
                            final_height,
                        )),
                        frames: None,
                        orientation: None,
                        pages: None,
                        needs_vision: None,
                    }),
                };
                writer.write_event(&output)?;
                return Ok(());
            }
            let picture = crate::media::image_uri::decode_heic(path)?;
            let (uri, facts) = crate::media::image_uri::from_picture(picture, &request)?;
            crate::media::image_uri::guard_size(&uri, crate::constants::IMAGE_MAX_DATA_URI_BYTES)?;
            writer.flush()?;
            let mut out = std::io::stdout().lock();
            out.write_all(uri.as_bytes())?;
            out.write_all(b"\n")?;
            out.flush()?;
            crate::media::image_uri::mark_delivered(hash);
            tracing::debug!(
                path = %path.display(),
                width = facts.width,
                height = facts.height,
                visual_tokens = facts.visual_tokens,
                "heic picture encoded"
            );
            Ok(())
        }
        crate::media::MediaKind::Pdf => {
            // A scanned page carries no text, so `--pages` can only say that
            // the page needs vision. `--page` renders it instead.
            if let Some(page) = args.page {
                let file = FileFacts {
                    path,
                    metadata,
                    hash,
                    bytes,
                };
                return read_rendered_page(&file, page, args, writer, &request);
            }
            let total = crate::media::pdf_text::page_count(path)?;
            if args.stat {
                // `--stat` asks for the shape of the document, not its words:
                // decompressing every page here would pay the whole cost of a
                // text read and then throw the text away.
                let output = ReadOutput {
                    r#type: "read",
                    path: path.display().to_string(),
                    content: None,
                    lines: 0,
                    lines_total: None,
                    truncated: None,
                    next: None,
                    bytes,
                    checksum: hash.to_owned(),
                    permissions: format_permissions(metadata),
                    modified: format_modified(metadata),
                    kind: "pdf".into(),
                    binary: true,
                    range: None,
                    verified: None,
                    mode: "stat".into(),
                    media: Some(MediaInfo {
                        kind: "pdf".into(),
                        width: None,
                        height: None,
                        format: Some("pdf".into()),
                        visual_tokens: None,
                        frames: None,
                        orientation: None,
                        pages: Some((1..=total).collect()),
                        needs_vision: None,
                    }),
                };
                writer.write_event(&output)?;
                return Ok(());
            }
            let pages = requested_pages(&args.pages, total)?;
            let extracted = crate::media::pdf_text::extract_pages(
                path,
                &pages,
                crate::constants::PDF_MAX_DECOMPRESSED_BYTES,
            )?;
            let needs_vision: Vec<u32> = extracted
                .iter()
                .filter(|page| page.needs_vision())
                .map(|page| page.page)
                .collect();
            let content = join_pages(&extracted);
            let line_count = content.lines().count() as u64;
            let output = ReadOutput {
                r#type: "read",
                path: path.display().to_string(),
                content: Some(content),
                lines: line_count,
                lines_total: None,
                truncated: None,
                next: None,
                bytes,
                checksum: hash.to_owned(),
                permissions: format_permissions(metadata),
                modified: format_modified(metadata),
                kind: "pdf".into(),
                binary: true,
                range: None,
                verified: None,
                mode: if args.stat { "stat" } else { "pages" }.into(),
                media: Some(MediaInfo {
                    kind: "pdf".into(),
                    width: None,
                    height: None,
                    format: Some("pdf".into()),
                    visual_tokens: None,
                    frames: None,
                    orientation: None,
                    pages: Some(pages),
                    needs_vision: (!needs_vision.is_empty()).then_some(needs_vision),
                }),
            };
            writer.write_event(&output)?;
            Ok(())
        }
    }
}

/// Translate the media flags into the request the image path takes.
fn media_request(args: &ReadArgs) -> Result<crate::media::image_uri::RenderRequest> {
    let crop = match args.crop.as_deref() {
        None => None,
        Some(spec) => Some(parse_crop(spec)?),
    };
    Ok(crate::media::image_uri::RenderRequest {
        high_resolution: args.high_res,
        max_tokens: args.max_tokens,
        crop,
    })
}

/// Parse `X,Y,W,H`, refusing a window that is not four positive numbers.
fn parse_crop(spec: &str) -> Result<(u32, u32, u32, u32)> {
    let parts: Vec<&str> = spec.split(',').map(str::trim).collect();
    let [x, y, width, height] = parts.as_slice() else {
        return Err(AtomwriteError::InvalidInput {
            reason: format!("--crop needs X,Y,W,H and got {spec:?}"),
        }
        .into());
    };
    let parse = |value: &str, name: &str| -> Result<u32> {
        value.parse::<u32>().map_err(|_| {
            anyhow::Error::from(AtomwriteError::InvalidInput {
                reason: format!("--crop {name} must be a non-negative integer, got {value:?}"),
            })
        })
    };
    let window = (
        parse(x, "x")?,
        parse(y, "y")?,
        parse(width, "width")?,
        parse(height, "height")?,
    );
    if window.2 == 0 || window.3 == 0 {
        return Err(AtomwriteError::InvalidInput {
            reason: "--crop width and height must be greater than zero".into(),
        }
        .into());
    }
    Ok(window)
}

/// Pages the caller asked for, as 1-based numbers inside the document.
fn requested_pages(specs: &[String], total: u32) -> Result<Vec<u32>> {
    if specs.is_empty() {
        return Ok((1..=total).collect());
    }
    let mut pages: Vec<u32> = Vec::new();
    for spec in specs {
        match spec.split_once(':') {
            Some((first, last)) => {
                let first =
                    first
                        .trim()
                        .parse::<u32>()
                        .map_err(|_| AtomwriteError::InvalidInput {
                            reason: format!(
                                "--pages range start must be a page number, got {first:?}"
                            ),
                        })?;
                let last =
                    last.trim()
                        .parse::<u32>()
                        .map_err(|_| AtomwriteError::InvalidInput {
                            reason: format!(
                                "--pages range end must be a page number, got {last:?}"
                            ),
                        })?;
                pages.extend(crate::media::pdf_text::page_range(first, last, total));
            }
            None => {
                let page =
                    spec.trim()
                        .parse::<u32>()
                        .map_err(|_| AtomwriteError::InvalidInput {
                            reason: format!("--pages must be N or A:B, got {spec:?}"),
                        })?;
                pages.push(page);
            }
        }
    }
    pages.sort_unstable();
    pages.dedup();
    Ok(pages)
}

/// What every media answer repeats about the file it came from.
///
/// The four values travel together in every branch of [`read_media`], so one
/// struct keeps them from turning each helper into a wall of positional
/// arguments.
struct FileFacts<'a> {
    path: &'a Path,
    metadata: &'a fs::Metadata,
    hash: &'a str,
    bytes: u64,
}

/// Recognize the characters on a picture and answer with the text itself.
///
/// The text leaves as `content`, the same field a text file fills, so the
/// caller reads one shape whether the page carried a text layer or not.
fn emit_recognized(
    file: &FileFacts<'_>,
    writer: &mut NdjsonWriter<impl Write>,
    kind: &str,
    picture: &image::DynamicImage,
    pages: Option<Vec<u32>>,
) -> Result<()> {
    let FileFacts {
        path,
        metadata,
        hash,
        bytes,
    } = *file;
    let text = crate::media::ocr::recognize(picture)?;
    let lines = u64::try_from(text.lines().count()).unwrap_or(u64::MAX);
    let output = ReadOutput {
        r#type: "read",
        path: path.display().to_string(),
        content: Some(text),
        lines,
        lines_total: Some(lines),
        truncated: None,
        next: None,
        bytes,
        checksum: hash.to_owned(),
        permissions: format_permissions(metadata),
        modified: format_modified(metadata),
        kind: kind.into(),
        binary: true,
        range: None,
        verified: None,
        mode: "ocr".into(),
        media: Some(MediaInfo {
            kind: kind.into(),
            width: Some(picture.width()),
            height: Some(picture.height()),
            format: None,
            visual_tokens: None,
            frames: None,
            orientation: None,
            pages,
            needs_vision: None,
        }),
    };
    writer.write_event(&output)?;
    tracing::debug!(
        path = %path.display(),
        lines,
        "characters recognized"
    );
    Ok(())
}

/// Render one PDF page and deliver it as a picture.
///
/// `--stat` reports the size of the rendering instead of sending it, which is
/// the cheap way to learn how much a page would cost. The `data:` URI leaves by
/// stdout alone, exactly as a picture does, so a caller reads one channel.
fn read_rendered_page(
    file: &FileFacts<'_>,
    page: u32,
    args: &ReadArgs,
    writer: &mut NdjsonWriter<impl Write>,
    request: &crate::media::image_uri::RenderRequest,
) -> Result<()> {
    let FileFacts {
        path,
        metadata,
        hash,
        bytes,
    } = *file;
    let dpi = args.dpi.unwrap_or(if args.ocr {
        crate::constants::PDF_OCR_RENDER_DPI
    } else {
        crate::constants::PDF_RENDER_DPI
    });
    if !crate::constants::PDF_RENDER_DPI_RANGE.contains(&dpi) {
        return Err(crate::error::AtomwriteError::InvalidInput {
            reason: format!(
                "read: --dpi {dpi} is outside {} to {} dots per inch",
                crate::constants::PDF_RENDER_DPI_RANGE.start(),
                crate::constants::PDF_RENDER_DPI_RANGE.end()
            ),
        }
        .into());
    }
    let picture = crate::media::pdf_render::render_page(
        path,
        page,
        dpi,
        crate::constants::IMAGE_MAX_MEGAPIXELS.saturating_mul(1_000_000),
    )?;
    if args.ocr {
        return emit_recognized(file, writer, "pdf", &picture, Some(vec![page]));
    }
    let (uri, facts) = crate::media::image_uri::from_picture(picture, request)?;
    if args.stat {
        let output = ReadOutput {
            r#type: "read",
            path: path.display().to_string(),
            content: None,
            lines: 0,
            lines_total: None,
            truncated: None,
            next: None,
            bytes,
            checksum: hash.to_owned(),
            permissions: format_permissions(metadata),
            modified: format_modified(metadata),
            kind: "pdf".into(),
            binary: true,
            range: None,
            verified: None,
            mode: "stat".into(),
            media: Some(MediaInfo {
                kind: "pdf".into(),
                width: Some(facts.width),
                height: Some(facts.height),
                format: Some(facts.format.to_owned()),
                visual_tokens: Some(facts.visual_tokens),
                frames: None,
                orientation: None,
                pages: Some(vec![page]),
                needs_vision: None,
            }),
        };
        writer.write_event(&output)?;
        return Ok(());
    }
    crate::media::image_uri::guard_size(&uri, crate::constants::IMAGE_MAX_DATA_URI_BYTES)?;
    tracing::debug!(
        path = %path.display(),
        page,
        width = facts.width,
        height = facts.height,
        visual_tokens = facts.visual_tokens,
        "PDF page rendered"
    );
    writer.flush()?;
    let mut out = std::io::stdout().lock();
    out.write_all(uri.as_bytes())?;
    out.write_all(b"\n")?;
    out.flush()?;
    Ok(())
}

/// Join the pages, naming each one when the answer covers more than one.
fn join_pages(pages: &[crate::media::pdf_text::PageText]) -> String {
    let mut out = String::new();
    let several = pages.len() > 1;
    for page in pages {
        if several {
            out.push_str(&format!("--- page {} ---\n", page.page));
        }
        out.push_str(&page.text);
        if !out.ends_with('\n') {
            out.push('\n');
        }
    }
    out
}

fn write_raw(writer: &mut NdjsonWriter<impl Write>, data: &[u8], args: &ReadArgs) -> Result<()> {
    // B-006: `--format raw` always emits file bytes (TTY and non-TTY).
    // Agent NDJSON contract uses default format; `--bytes` is a no-op alias.
    writer.flush()?;
    let inner = std::io::stdout();
    let mut lock = inner.lock();
    // Filtering runs on the BYTES, never on a lossy `String`: decoding first
    // rewrote invalid UTF-8 into `EF BF BD` and dropped the `\r` of CRLF
    // files, so `read --format raw --head N` returned bytes that differed from
    // `head -n N`.
    let (rendered, emittable, total_lines) = render_raw_lines(data, args);
    let cap = if args.all {
        usize::MAX
    } else {
        crate::config::read_max_output_bytes()
    };
    let (payload, truncated) = cap_lines(&rendered, cap);
    let written = lock.write_all(payload);
    // The notice goes to stderr, never to stdout: raw stdout is the byte
    // channel and must stay `cat` for a pipe. The Bash tool merges both
    // streams, so the model still reads the next step.
    if truncated {
        // Only the lines whose end landed inside the payload count, so the
        // `--` separators never inflate the notice.
        let emitted = emittable
            .iter()
            .filter(|(_, end)| *end <= payload.len())
            .count()
            .max(1);
        let last = emittable
            .get(emitted.saturating_sub(1))
            .map(|(line, _)| *line)
            .unwrap_or(0);
        let next = last.saturating_add(1);
        crate::best_effort::best_effort(
            writeln!(
                std::io::stderr().lock(),
                "atomwrite: showing {emitted} of {} selected lines up to {cap} bytes; next: --lines {next}:{total_lines} (use --all for everything)",
                emittable.len()
            ),
            "write read truncation notice on stderr",
        );
    }
    // A closed reader is a failed run (exit 141), never a silent success, and
    // a failed flush loses bytes the same way a failed write does.
    written.and_then(|()| lock.flush()).map_err(|e| {
        if e.kind() == std::io::ErrorKind::BrokenPipe {
            crate::error::AtomwriteError::BrokenPipe.into()
        } else {
            anyhow::Error::from(e)
        }
    })
}
/// Write the bytes of the resolved targets, in argv order.
///
/// One target keeps the shape it had before this change: the file is materialized,
/// so `--lines`, `--head`, `--tail` and `--verify-checksum` keep answering
/// exactly as they did. Several targets stream, because the peak memory of a
/// concatenation must not follow the sum of the file sizes.
fn write_raw_targets(
    targets: &ResolvedTargets,
    args: &ReadArgs,
    global: &GlobalArgs,
    workspace: &std::path::Path,
    writer: &mut NdjsonWriter<impl Write>,
    verb: &str,
) -> Result<()> {
    // `--stat` answers for one file: a metadata envelope per operand would mix
    // JSON into the byte channel the caller asked to concatenate.
    if args.stat && targets.paths.len() > 1 {
        return Err(AtomwriteError::InvalidInput {
            reason: format!(
                "{verb} --stat takes one target per invocation, and {} were named",
                targets.paths.len()
            ),
        }
        .into());
    }
    if targets.paths.len() == 1 && targets.empty_patterns.is_empty() {
        let path = crate::path_safety::validate_path(&targets.paths[0], workspace)?;
        if !path.exists() {
            return Err(AtomwriteError::NotFound { path }.into());
        }
        // Every selector streams: a range, a head or a tail used to materialize
        // the whole file, which put the peak at the file size. Only a
        // structural selector needs the complete source for tree-sitter.
        if args.symbol.is_none() && !args.enclosing {
            return stream_one_raw(&path, args, global, writer, verb);
        }
        let raw_bytes = crate::file_io::read_file_bytes(&path, global.effective_max_filesize())?;
        // Binary bytes on the raw channel are paid tokens that carry no text.
        let head = &raw_bytes[..raw_bytes.len().min(crate::constants::BINARY_DETECT_SIZE)];
        if binary_detect::is_binary(head) {
            return Err(AtomwriteError::InvalidInput {
                reason: format!(
                    "{verb} --format raw refuses binary {}; drop --format raw: a picture then arrives as a data: URI and a PDF as page text",
                    path.display()
                ),
            }
            .into());
        }
        // The digest is computed only when the caller asked to verify it: a
        // whole-file hash on every read was work nobody consumed.
        if let Some(ref expected) = args.verify_checksum {
            let hash = checksum::hash_bytes(&raw_bytes);
            if &hash != expected {
                return Err(AtomwriteError::ChecksumVerifyFailed {
                    path,
                    expected: expected.clone(),
                }
                .into());
            }
        }
        let mut raw_bytes = raw_bytes;
        if let Some(name) = &args.symbol {
            let spans = crate::commands::outline::symbol_spans(&path, &raw_bytes, None, name);
            let span = crate::commands::outline::resolve_symbol_span(&spans, name, &path)
                .map_err(anyhow::Error::from)?;
            raw_bytes = raw_bytes[span.start..span.end].to_vec();
        } else if args.enclosing {
            let line = args.line.ok_or_else(|| AtomwriteError::InvalidInput {
                reason: "--enclosing needs --line N".to_string(),
            })?;
            let (start, end) =
                crate::commands::outline::enclosing_span(&path, &raw_bytes, None, line)
                    .ok_or_else(|| AtomwriteError::InvalidInput {
                        reason: format!(
                            "no structural block contains line {line} in {}",
                            path.display()
                        ),
                    })?;
            raw_bytes = raw_bytes[start..end].to_vec();
        }
        // A range that starts after the last line used to answer an empty
        // success, which reads as "the file is empty". The line total is the
        // fact that lets the caller fix the request in one step.
        if !args.lines.is_empty() {
            let total = line_spans(&raw_bytes).len();
            let selected = args.lines.iter().any(|spec| {
                spec.split(',')
                    .any(|part| parse_range_str(part.trim(), total).is_some_and(|(s, e)| s < e))
            });
            if !selected && total > 0 {
                return Err(AtomwriteError::InvalidInput {
                    reason: format!(
                        "--lines selects nothing in {}: the file has {total} lines",
                        path.display()
                    ),
                }
                .into());
            }
        }
        return write_raw(writer, &raw_bytes, args);
    }
    if !has_line_filters(args) {
        return stream_raw_targets(targets, global, workspace, writer);
    }
    stream_filtered_targets(targets, args, global, workspace, writer, verb)
}

/// Whether the call asks to carve lines out of the bytes.
fn has_line_filters(args: &ReadArgs) -> bool {
    args.head.is_some()
        || args.tail.is_some()
        || !args.lines.is_empty()
        || args.line.is_some()
        || args.symbol.is_some()
        || args.enclosing
        || args.grep.is_some()
        || args.number
        || args.no_comments
}

/// Open `path` and refuse a binary target before one byte reaches stdout.
fn open_raw_reader(
    path: &std::path::Path,
    verb: &str,
) -> Result<std::io::BufReader<std::fs::File>> {
    use std::io::BufRead;
    let file = std::fs::File::open(path)?;
    let mut reader = std::io::BufReader::with_capacity(crate::constants::RAW_COPY_BUF_BYTES, file);
    let head = reader.fill_buf()?;
    let len = head.len().min(crate::constants::BINARY_DETECT_SIZE);
    if binary_detect::is_binary(&head[..len]) {
        return Err(binary_raw_error(verb, path));
    }
    Ok(reader)
}

/// The verdict a raw read publishes for a binary target.
fn binary_raw_error(verb: &str, path: &std::path::Path) -> anyhow::Error {
    AtomwriteError::InvalidInput {
        reason: format!(
            "{verb} --format raw refuses binary {}; drop --format raw: a picture then arrives as a data: URI and a PDF as page text",
            path.display()
        ),
    }
    .into()
}

/// Copy one file to stdout, streaming every selector and the digest.
///
/// The binary sniff reads through `fill_buf`, and a filtered read applies the
/// selectors while the file is read, so memory stays at one buffer plus the
/// selected lines instead of following the file size.
fn stream_one_raw(
    path: &std::path::Path,
    args: &ReadArgs,
    global: &GlobalArgs,
    writer: &mut NdjsonWriter<impl Write>,
    verb: &str,
) -> Result<()> {
    writer.flush()?;
    let total_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
    // Raw streams the file, and that is exactly why the ceiling belongs here:
    // every other format refuses a target over `--max-filesize`, and this path
    // used to hand the same file back in silence.
    let max_size = global.effective_max_filesize();
    if total_bytes > max_size {
        return Err(AtomwriteError::FileTooLarge {
            path: path.to_path_buf(),
            size: total_bytes,
            max_size,
        }
        .into());
    }
    let mut reader = open_raw_reader(path, verb)?;
    // The digest runs only when the caller asked to verify it, and it streams:
    // a whole-file hash on every read was work nobody consumed.
    if let Some(ref expected) = args.verify_checksum {
        let (hash, _) = checksum::hash_file_streaming(path, global.effective_max_filesize())?;
        if &hash != expected {
            return Err(AtomwriteError::ChecksumVerifyFailed {
                path: path.to_path_buf(),
                expected: expected.clone(),
            }
            .into());
        }
    }
    let mut out = std::io::stdout().lock();
    if !has_line_filters(args) {
        return copy_plain_raw(path, args, &mut reader, &mut out, total_bytes);
    }
    let cap = if args.all {
        usize::MAX
    } else {
        crate::config::read_max_output_bytes()
    };
    let stats = stream_selected_into(path, args, &mut out, cap, verb)?;
    if cap != usize::MAX && stats.truncated {
        write_filtered_notice(path, &stats, cap);
    }
    out.flush().map_err(map_raw_io)
}

/// Copy a plain `cat` target, cutting a large answer on a line boundary.
fn copy_plain_raw(
    path: &std::path::Path,
    args: &ReadArgs,
    reader: &mut std::io::BufReader<std::fs::File>,
    out: &mut impl Write,
    total_bytes: u64,
) -> Result<()> {
    use std::io::Read;
    if args.all {
        copy_raw_stream(reader, out)?;
        return out.flush().map_err(map_raw_io);
    }
    let cap = crate::config::read_max_output_bytes();
    let mut buf = Vec::with_capacity(cap.min(total_bytes as usize).saturating_add(1));
    reader.by_ref().take(cap as u64 + 1).read_to_end(&mut buf)?;
    let (payload, truncated) = cap_lines(&buf, cap);
    // The cut lands on a line boundary, which can still be the middle of a
    // struct. Stepping back to the last complete outline item keeps the next
    // read from starting inside one.
    let payload = if truncated {
        align_raw_payload(path, payload)
    } else {
        payload
    };
    let written = out.write_all(payload).and_then(|()| out.flush());
    if truncated {
        // The line total costs one streaming pass over the bytes that were not
        // sent, and it buys the caller a range that lands exactly on the next
        // line instead of a guess.
        let emitted = payload.iter().filter(|b| **b == b'\n').count().max(1);
        let prefixed = buf.iter().filter(|b| **b == b'\n').count();
        let mut chunk = [0u8; 65_536];
        let mut remaining = 0usize;
        loop {
            let read = reader.read(&mut chunk)?;
            if read == 0 {
                break;
            }
            remaining += memchr::memchr_iter(b'\n', &chunk[..read]).count();
        }
        crate::best_effort::best_effort(
            writeln!(
                std::io::stderr().lock(),
                "atomwrite: {} ({} bytes) has {} lines; showing {} up to {cap} bytes, next: --lines {}:{} (--all for everything)",
                path.display(),
                total_bytes,
                prefixed + remaining,
                emitted,
                emitted + 1,
                prefixed + remaining
            ),
            "write read truncation notice on stderr",
        );
    }
    written.map_err(map_raw_io)
}

/// Counts a streaming selection publishes so the cap notice can name the next range.
struct SelectedStats {
    total_lines: u64,
    selected_lines: u64,
    payload_indices: Vec<usize>,
    truncated: bool,
}

/// Stream one file's selected lines into `out` under `cap`.
///
/// `cap == usize::MAX` writes each selected line as it is chosen; any other cap
/// buffers at most `cap` bytes, cuts at the last complete line and keeps reading
/// only to count what was left out.
fn stream_selected_into(
    path: &std::path::Path,
    args: &ReadArgs,
    out: &mut impl Write,
    cap: usize,
    verb: &str,
) -> Result<SelectedStats> {
    if cap == usize::MAX && !args.lines.is_empty() {
        // Nothing can be unwritten on the byte channel, so a range that lies
        // past the end is refused before the first byte leaves.
        let total = count_lines_streaming(path)?;
        ensure_ranges_select(path, total, args)?;
    }
    drop(open_raw_reader(path, verb)?);
    let number = numbering_enabled(args);
    let mut selector = LineSelector::new(args);
    let mut payload: Vec<u8> = Vec::new();
    let mut payload_indices: Vec<usize> = Vec::new();
    let mut truncated = false;
    let mut selected_lines: u64 = 0;
    let mut io_error: Option<std::io::Error> = None;
    let mut previous: Option<usize> = None;
    let mut emit = |index: usize, raw: &[u8]| {
        if io_error.is_some() {
            return;
        }
        selected_lines += 1;
        // `rg -C` separates non-adjacent groups with `--`, so a reader that
        // asked for two ranges can tell where the first one ends.
        let gap = previous.is_some_and(|prev| index != prev + 1);
        previous = Some(index);
        if cap == usize::MAX {
            if gap && let Err(error) = out.write_all(b"--\n") {
                io_error = Some(error);
                return;
            }
            if let Err(error) = write_numbered_raw(out, index, raw, number) {
                io_error = Some(error);
            }
            return;
        }
        if truncated {
            return;
        }
        let separator = usize::from(gap) * 3;
        let mut chunk = Vec::with_capacity(separator + raw.len() + 16);
        if gap {
            chunk.extend_from_slice(b"--\n");
        }
        chunk.extend_from_slice(&numbered_chunk(index, raw, number));
        if payload.len() + chunk.len() <= cap {
            payload.extend_from_slice(&chunk);
            payload_indices.push(index);
        } else {
            let remaining = cap - payload.len();
            let cut = memchr::memrchr(b'\n', &chunk[..chunk.len().min(remaining)])
                .map(|position| position + 1)
                .unwrap_or(0);
            payload.extend_from_slice(&chunk[..cut]);
            if cut > separator {
                payload_indices.push(index);
            }
            truncated = true;
        }
    };
    let total = visit_raw_lines(path, |index, line| selector.push(index, line, &mut emit))?;
    selector.finish(&mut emit);
    // The closure's borrow ends at its last use, so the buffers it wrote are
    // readable here without an explicit drop.
    if let Some(error) = io_error {
        return Err(map_raw_io(error));
    }
    ensure_ranges_select(path, total, args)?;
    if cap != usize::MAX {
        out.write_all(&payload).map_err(map_raw_io)?;
    }
    Ok(SelectedStats {
        total_lines: total,
        selected_lines,
        payload_indices,
        truncated,
    })
}

/// Write one raw line with its 1-based number in front when numbering is on.
fn write_numbered_raw(
    out: &mut impl Write,
    index: usize,
    raw: &[u8],
    number: bool,
) -> std::io::Result<()> {
    if number {
        write!(out, "{}:", index + 1)?;
    }
    out.write_all(raw)?;
    if number && !raw.ends_with(b"\n") {
        out.write_all(b"\n")?;
    }
    Ok(())
}

/// Build one raw line with its 1-based number in front when numbering is on.
fn numbered_chunk(index: usize, raw: &[u8], number: bool) -> Vec<u8> {
    let mut chunk = Vec::with_capacity(raw.len() + 16);
    if number {
        chunk.extend_from_slice(format!("{}:", index + 1).as_bytes());
    }
    chunk.extend_from_slice(raw);
    if number && !raw.ends_with(b"\n") {
        chunk.push(b'\n');
    }
    chunk
}

/// Tell stderr how much of the selection fit under the cap, and what comes next.
fn write_filtered_notice(path: &std::path::Path, stats: &SelectedStats, cap: usize) {
    let emitted = stats.payload_indices.len().max(1);
    let last = stats.payload_indices.last().copied().unwrap_or(0);
    crate::best_effort::best_effort(
        writeln!(
            std::io::stderr().lock(),
            "atomwrite: {}: showing {emitted} of {} selected lines up to {cap} bytes; next: --lines {}:{} (use --all for everything)",
            path.display(),
            stats.selected_lines,
            last.saturating_add(2),
            stats.total_lines
        ),
        "write read truncation notice on stderr",
    );
}

/// Map a stdout write failure to the crate's broken-pipe semantics.
fn map_raw_io(error: std::io::Error) -> anyhow::Error {
    if error.kind() == std::io::ErrorKind::BrokenPipe {
        crate::error::AtomwriteError::BrokenPipe.into()
    } else {
        anyhow::Error::from(error)
    }
}

/// Copy a reader to a writer in bounded chunks, converting broken pipe to 141.
fn copy_raw_stream(reader: &mut impl std::io::Read, out: &mut impl Write) -> Result<()> {
    let mut buf = vec![0u8; crate::constants::RAW_COPY_BUF_BYTES];
    loop {
        let read = reader.read(&mut buf)?;
        if read == 0 {
            return Ok(());
        }
        out.write_all(&buf[..read]).map_err(map_raw_io)?;
    }
}

/// Concatenate several targets, filtering each one, preserving argv order.
///
/// A header names the file whenever more than one target is in play, because a
/// concatenated answer with no owner is a fact the caller cannot attribute.
/// One target may fail and the rest still go out: the status is the first
/// failure's, the same rule the unfiltered path already follows.
fn stream_filtered_targets(
    targets: &ResolvedTargets,
    args: &ReadArgs,
    _global: &GlobalArgs,
    workspace: &std::path::Path,
    writer: &mut NdjsonWriter<impl Write>,
    verb: &str,
) -> Result<()> {
    writer.flush()?;
    let mut out = std::io::stdout().lock();
    let header = targets.paths.len() > 1;
    let cap = if args.all {
        usize::MAX
    } else {
        crate::config::read_max_output_bytes()
    };
    let mut first_error: Option<anyhow::Error> = None;
    for raw_path in &targets.paths {
        let path = match crate::path_safety::validate_path(raw_path, workspace) {
            Ok(path) => path,
            Err(error) => {
                crate::best_effort::best_effort(
                    writeln!(
                        std::io::stderr().lock(),
                        "atomwrite: {}: {error}",
                        raw_path.display()
                    ),
                    "report rejected target on stderr",
                );
                first_error.get_or_insert(error);
                continue;
            }
        };
        if !path.exists() {
            let error = AtomwriteError::NotFound { path: path.clone() };
            crate::best_effort::best_effort(
                writeln!(
                    std::io::stderr().lock(),
                    "atomwrite: {}: not found",
                    path.display()
                ),
                "report missing target on stderr",
            );
            first_error.get_or_insert(error.into());
            continue;
        }
        // The binary sniff runs before the header, so a refused target leaves
        // no half-written attribution on stdout.
        open_raw_reader(&path, verb)?;
        if header {
            out.write_all(format!("==> {} <==\n", path.display()).as_bytes())
                .map_err(map_raw_io)?;
        }
        match stream_selected_into(&path, args, &mut out, cap, verb) {
            Ok(stats) => {
                if cap != usize::MAX && stats.truncated {
                    write_filtered_notice(&path, &stats, cap);
                }
            }
            Err(error) => {
                // A binary target and a checksum mismatch end the run; an
                // unreadable target lets the other files still answer.
                let fatal = error
                    .downcast_ref::<AtomwriteError>()
                    .is_some_and(|failure| {
                        matches!(
                            failure,
                            AtomwriteError::InvalidInput { .. }
                                | AtomwriteError::ChecksumVerifyFailed { .. }
                        )
                    });
                if fatal {
                    return Err(error);
                }
                crate::best_effort::best_effort(
                    writeln!(
                        std::io::stderr().lock(),
                        "atomwrite: {}: {error}",
                        path.display()
                    ),
                    "report unreadable target on stderr",
                );
                first_error.get_or_insert(error);
            }
        }
    }
    out.flush().map_err(map_raw_io)?;
    match first_error {
        Some(error) => Err(error),
        None => Ok(()),
    }
}

/// Concatenate every target byte for byte, the way `cat` does.
///
/// No header, no separator and no added newline: a file that ends without `\n`
/// is followed by the next file's first byte. A target that fails prints one
/// line on stderr and the run keeps going, because the other targets still owe
/// their bytes to stdout. The status is the first failure's, which is the
/// semantic code the owner chose over `cat`'s flat 1.
fn stream_raw_targets(
    targets: &ResolvedTargets,
    global: &GlobalArgs,
    workspace: &std::path::Path,
    writer: &mut NdjsonWriter<impl Write>,
) -> Result<()> {
    writer.flush()?;
    let max_size = global.effective_max_filesize();
    let shutdown = crate::signal::get_or_install_handlers()?;
    let mut cancel = crate::signal::CancelCheck::for_bytes(shutdown.flag());
    let mut out = std::io::stdout().lock();
    let mut buf = vec![0u8; crate::constants::RAW_COPY_BUF_BYTES];
    let mut first_error: Option<AtomwriteError> = None;
    let mut bytes_total: u64 = 0;
    for path in &targets.paths {
        // Every target faces the crate's one jail check, and expansion does not
        // skip it: a pattern that matches a symbolic link pointing outside the
        // workspace would otherwise stream the target's bytes, while the same
        // link named explicitly is refused with 126 (measured on the build
        // before this change).
        let checked = match crate::path_safety::validate_path(path, workspace) {
            Ok(checked) => checked,
            Err(e) => {
                let failure = e.downcast::<AtomwriteError>().unwrap_or_else(|other| {
                    AtomwriteError::InternalError {
                        reason: format!("{other:#}"),
                    }
                });
                record_failure(failure, &mut first_error);
                continue;
            }
        };
        match copy_one_target(
            &checked,
            &mut out,
            &mut buf,
            max_size,
            &mut cancel,
            &mut bytes_total,
        ) {
            Ok(()) => {}
            // A closed reader and an elapsed deadline end the run: the bytes
            // after them were never requested in full by anyone.
            Err(e) if is_fatal(&e) => return Err(e.into()),
            Err(e) => record_failure(e, &mut first_error),
        }
    }
    for pattern in &targets.empty_patterns {
        record_failure(
            AtomwriteError::GlobNoMatches {
                pattern: pattern.clone(),
            },
            &mut first_error,
        );
    }
    out.flush()
        .map_err(|e| write_error_at(std::path::Path::new(""), e))?;
    tracing::debug!(
        targets = targets.paths.len(),
        bytes = bytes_total,
        "read raw concatenation complete"
    );
    match first_error {
        // The line for every failure already went to stderr, in argv order, so
        // the status travels alone: the marker tells the dispatcher that the
        // verdict was published and only the exit code is still owed.
        Some(e) => Err(anyhow::Error::from(e).context(crate::output::VerdictPublished)),
        None => Ok(()),
    }
}

/// Report every failure where it happens and keep the FIRST for the status.
///
/// Printing only the later ones and leaving the first to the caller put the
/// first failure LAST on stderr, so the lines ran against argv order (A14,
/// measured with two oversized targets: `b.rs` printed before `a.rs`). The
/// status still comes from the first failure, and the returned error carries
/// [`crate::output::VerdictPublished`] so the caller does not print it twice.
fn record_failure(e: AtomwriteError, first: &mut Option<AtomwriteError>) {
    report_target_error(&e);
    if first.is_none() {
        *first = Some(e);
    }
}

/// Whether a per-target failure ends the whole run instead of skipping a file.
fn is_fatal(e: &AtomwriteError) -> bool {
    matches!(e, AtomwriteError::BrokenPipe) || crate::signal::is_global_shutdown()
}

/// Copy one file to stdout through the cancel check.
fn copy_one_target(
    path: &std::path::Path,
    out: &mut impl Write,
    buf: &mut [u8],
    max_size: u64,
    cancel: &mut crate::signal::CancelCheck,
    bytes_total: &mut u64,
) -> std::result::Result<(), AtomwriteError> {
    use std::io::Read;
    let mut file = match fs::File::open(path) {
        Ok(file) => file,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(AtomwriteError::NotFound {
                path: path.to_path_buf(),
            });
        }
        Err(e) => return Err(AtomwriteError::from_io_at(path, e)),
    };
    let size = file
        .metadata()
        .map_err(|e| AtomwriteError::from_io_at(path, e))?
        .len();
    if max_size > 0 && size > max_size {
        return Err(AtomwriteError::FileTooLarge {
            path: path.to_path_buf(),
            size,
            max_size,
        });
    }
    loop {
        let read = file
            .read(buf)
            .map_err(|e| AtomwriteError::from_io_at(path, e))?;
        if read == 0 {
            return Ok(());
        }
        // The deadline has to cut a 196 MB file in the middle, not after it.
        cancel.advance(read as u64)?;
        out.write_all(&buf[..read])
            .map_err(|e| write_error_at(path, e))?;
        *bytes_total += read as u64;
    }
}

/// Map a write failure, keeping the broken-pipe verdict separate.
fn write_error_at(path: &std::path::Path, e: std::io::Error) -> AtomwriteError {
    if e.kind() == std::io::ErrorKind::BrokenPipe {
        AtomwriteError::BrokenPipe
    } else {
        AtomwriteError::from_io_at(path, e)
    }
}

/// Report one target's failure on stderr, where diagnostics belong.
fn report_target_error(e: &AtomwriteError) {
    crate::best_effort::best_effort(
        writeln!(std::io::stderr().lock(), "atomwrite: {e}"),
        "report read batch error on stderr",
    );
}

/// Whether the file starts with bytes that mark it as binary.
///
/// Only the detection window is read, so deciding the `kind` of a 280 MB file
/// costs one small read instead of the whole file.
fn detect_binary_prefix(path: &std::path::Path) -> Result<bool> {
    use std::io::Read;
    let mut file =
        fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
    let mut head = [0u8; crate::constants::BINARY_DETECT_SIZE];
    let read = file
        .read(&mut head)
        .with_context(|| format!("cannot read {}", path.display()))?;
    Ok(binary_detect::is_binary(&head[..read]))
}

/// Select the requested lines from `data`, returning their ORIGINAL bytes.
///
/// `raw` promises the file's bytes, so this runs on `&[u8]` and copies whole
/// line slices, terminator included. The previous version decoded with
/// `from_utf8_lossy` and rebuilt the result with `join("\n") + "\n"`, which
/// rewrote invalid UTF-8 into `EF BF BD`, dropped the `\r` of CRLF files, and
/// appended a newline to a file whose last line had none. A slice of the
/// original buffer cannot do any of those three things.
///
/// A file that does not end in a newline keeps that shape: the final line is
/// copied without a terminator, exactly as `head` and `tail` would print it.
/// Render the selected raw lines, with optional numbering.
///
/// Returns the payload, the original 0-based index of every emitted line and
/// the file's total line count, so the cap notice can name the exact next
/// range instead of guessing from a byte count.
fn render_raw_lines(data: &[u8], args: &ReadArgs) -> (Vec<u8>, Vec<(usize, usize)>, usize) {
    let ranges = line_spans(data);
    let total = ranges.len();
    let indices = select_line_indices(&ranges, data, args, total);
    let numbered = numbering_enabled(args);
    let mut out = Vec::new();
    let mut emitted = Vec::with_capacity(indices.len());
    let mut previous: Option<usize> = None;
    for &index in &indices {
        // `rg -C` separates non-adjacent groups with `--`, so a reader that
        // asked for two ranges can tell where the first one ends.
        if let Some(prev) = previous
            && index != prev + 1
        {
            out.extend_from_slice(b"--\n");
        }
        let (start, end) = ranges[index];
        if numbered {
            out.extend_from_slice(format!("{}:", index + 1).as_bytes());
        }
        out.extend_from_slice(&data[start..end]);
        if numbered && !out.ends_with(b"\n") {
            out.push(b'\n');
        }
        emitted.push((index + 1, out.len()));
        previous = Some(index);
    }
    (out, emitted, total)
}

/// Pick the 0-based line indices the request selects, in file order.
///
/// Every selector composes on the ORIGINAL line numbers, so `--no-comments`
/// removes lines from the answer without renumbering the rest.
fn select_line_indices(
    ranges: &[(usize, usize)],
    data: &[u8],
    args: &ReadArgs,
    total: usize,
) -> Vec<usize> {
    let mut keep = vec![false; total];
    let mut filtered = false;
    if args.enclosing {
        // The enclosing block is the answer; a line selector inside it would
        // carve the block back down to the single line it came from.
    } else if !args.lines.is_empty() {
        filtered = true;
        for raw in &args.lines {
            for part in raw.split(',') {
                if let Some((start, end)) = parse_range_str(part.trim(), total) {
                    for slot in keep.iter_mut().take(end).skip(start) {
                        *slot = true;
                    }
                }
            }
        }
    } else if let Some(line_num) = args.line {
        filtered = true;
        let idx = line_num.saturating_sub(1);
        if idx < total {
            let ctx = args.context;
            let start = idx.saturating_sub(ctx);
            let end = (idx + ctx + 1).min(total);
            for slot in keep.iter_mut().take(end).skip(start) {
                *slot = true;
            }
        }
    } else if let Some(n) = args.head {
        filtered = true;
        for slot in keep.iter_mut().take(n.min(total)) {
            *slot = true;
        }
    } else if let Some(n) = args.tail {
        filtered = true;
        for slot in keep.iter_mut().skip(total.saturating_sub(n)) {
            *slot = true;
        }
    } else if let Some(pattern) = &args.grep {
        filtered = true;
        if let Ok(re) = regex::Regex::new(pattern) {
            for (index, (start, end)) in ranges.iter().enumerate() {
                if re.is_match(&String::from_utf8_lossy(trim_line_ending(
                    &data[*start..*end],
                ))) {
                    let lo = index.saturating_sub(args.context);
                    let hi = (index + args.context + 1).min(total);
                    for slot in keep.iter_mut().take(hi).skip(lo) {
                        *slot = true;
                    }
                }
            }
        }
    }

    let mut out = Vec::new();
    for (index, (start, end)) in ranges.iter().enumerate() {
        if filtered && !keep[index] {
            continue;
        }
        if args.no_comments && is_comment_or_blank(&data[*start..*end]) {
            continue;
        }
        out.push(index);
    }
    out
}

/// Whether a line is blank or a comment in the languages the CLI edits.
///
/// Opt-in through `--no-comments`: a filter guessed wrong on an exotic grammar
/// would silence real content, so the list stays to the markers every language
/// in the project writes the same way.
fn is_comment_or_blank(line: &[u8]) -> bool {
    let text = String::from_utf8_lossy(line);
    let trimmed = text.trim();
    trimmed.is_empty()
        || trimmed.starts_with("//")
        || trimmed.starts_with('#')
        || trimmed.starts_with("--")
        || trimmed.starts_with("/*")
        || trimmed.starts_with('*')
}

/// Cut `payload` back to the end of the last complete outline item.
///
/// Only a byte cut on a plain `cat` reaches here, because a caller-named range
/// must return the lines it asked for. Invalid UTF-8 and an unknown grammar
/// keep the line cut, since no outline can be trusted there.
fn align_raw_payload<'a>(path: &std::path::Path, payload: &'a [u8]) -> &'a [u8] {
    let Ok(prefix) = std::str::from_utf8(payload) else {
        return payload;
    };
    let last_line = payload.iter().filter(|b| **b == b'\n').count();
    let Some(end_line) = align_cut_to_outline(path, prefix, last_line) else {
        return payload;
    };
    if end_line == 0 || end_line >= last_line {
        return payload;
    }
    let mut seen = 0usize;
    for (index, byte) in payload.iter().enumerate() {
        if *byte == b'\n' {
            seen += 1;
            if seen == end_line {
                return &payload[..=index];
            }
        }
    }
    payload
}

/// Cut `payload` at the last complete line that fits `cap` bytes.
fn cap_lines(payload: &[u8], cap: usize) -> (&[u8], bool) {
    if payload.len() <= cap {
        return (payload, false);
    }
    let cut = memchr::memrchr(b'\n', &payload[..cap])
        .map(|index| index + 1)
        .unwrap_or(0);
    (&payload[..cut], true)
}

/// Byte span of every line in `data`, terminator included when present.
///
/// Splitting on the `\n` byte keeps the `\r` inside the span, so concatenating
/// spans reproduces CRLF files exactly, and the last line keeps its absent
/// terminator instead of gaining one.
fn line_spans(data: &[u8]) -> Vec<(usize, usize)> {
    let mut spans = Vec::new();
    let mut start = 0;
    for (index, byte) in data.iter().enumerate() {
        if *byte == b'\n' {
            spans.push((start, index + 1));
            start = index + 1;
        }
    }
    if start < data.len() {
        spans.push((start, data.len()));
    }
    spans
}

fn parse_range_str(s: &str, total: usize) -> Option<(usize, usize)> {
    let parts: Vec<&str> = s.split(':').collect();
    if parts.len() != 2 {
        return None;
    }
    let start = parts[0].parse::<usize>().ok()?.saturating_sub(1).min(total);
    let end = parts[1].parse::<usize>().ok()?.min(total);
    if start >= end {
        return Some((0, 0));
    }
    Some((start, end))
}

fn parse_line_range(args: &ReadArgs, total: u64) -> Option<LineRange> {
    let mut merged: Option<(usize, usize)> = None;
    for raw in &args.lines {
        for part in raw.split(',') {
            if let Some((start, end)) = parse_range_str(part.trim(), total as usize) {
                merged = Some(match merged {
                    Some((lo, hi)) => (lo.min(start), hi.max(end)),
                    None => (start, end),
                });
            }
        }
    }
    if let Some((start, end)) = merged {
        return Some(LineRange {
            start: start + 1,
            end,
        });
    }
    if let Some(line_num) = args.line {
        let ctx = args.context;
        let start = line_num.saturating_sub(ctx);
        let end = (line_num + ctx).min(total as usize);
        return Some(LineRange { start, end });
    }
    None
}

fn format_permissions(metadata: &fs::Metadata) -> String {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        format!(
            "0o{:o}",
            metadata.permissions().mode() & crate::constants::UNIX_MODE_PERMISSION_MASK
        )
    }
    #[cfg(not(unix))]
    {
        if metadata.permissions().readonly() {
            "readonly".into()
        } else {
            "readwrite".into()
        }
    }
}

fn format_modified(metadata: &fs::Metadata) -> String {
    match metadata.modified() {
        Ok(time) => {
            // defaults to epoch if file mtime precedes UNIX epoch — display-only
            let secs = time
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            let (year, month, day, hour, min, sec) = crate::atomic::epoch_to_utc(secs);
            format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
        }
        Err(_) => "unknown".into(),
    }
}

include!("read_globs.inc.rs");
include!("read_lines.inc.rs");