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
/// True when this invocation emits the paired `begin` / `end` anchor events.
///
/// `--count` and `--files` replace the anchors with one `count` / `file`
/// event per file, and `--no-begin-end` removes the anchors altogether
/// (GAP-2026-010). Both `begin` and `end` consult this single predicate so
/// the two anchors can never drift apart (B5 parity: every opening an
/// invocation emits is matched by exactly one closing, and vice versa).
fn emits_anchor_events(args: &crate::cli::SearchArgs) -> bool {
    !args.count && !args.files && !args.no_begin_end
}

/// What the consumer actually wrote, as opposed to what the walker found.
///
/// The summary must report the answer the consumer can read. The producer
/// counted every file it scanned, so `search needle . --limit 1` printed one
/// `match` and a summary claiming twenty-nine — the value published was the
/// lie, not the policy. These counters rise only for records that left.
#[derive(Debug, Default)]
struct SearchTally {
    files_matched: u64,
    total_matches: u64,
    /// Match records the `--offset` page window walked past.
    offset_skipped: u64,
}

/// Emit one search event, reporting how many answer records left.
///
/// A `match`, `count` or `file` line is an answer; anchors and context lines
/// frame an answer without being one, so they return `0`. The caller sums the
/// result and stops at the `--limit` cap, which is what makes the summary and
/// the stream agree even when the writer is holding every record for a tail
/// pass.
fn emit_search_event(
    event: SearchEvent,
    writer: &mut NdjsonWriter<impl Write>,
    args: &crate::cli::SearchArgs,
    has_matches: &mut bool,
    tally: &mut SearchTally,
    text: &mut TextLayout,
) -> Result<u64> {
    // `--format text` has its own writer: the payload is lines of text, not
    // records, and the answer tallies still have to rise so `--limit`,
    // `--offset` and the exit code keep meaning what they mean.
    if args.format == Some(crate::cli::SearchFormat::Text) {
        return emit_text_event(event, writer, args, has_matches, tally, text);
    }
    match event {
        SearchEvent::Begin(path) => {
            if emits_anchor_events(args) {
                // An anchor frames a file; it is not an answer. Charging the
                // `--limit` budget for it spent the whole allowance before the
                // first `match` could be written.
                writer.write_framing_event(&SearchBegin {
                    r#type: "begin",
                    path: path.display().to_string(),
                })?;
            }
            Ok(0)
        }
        SearchEvent::Match {
            path,
            line_number,
            lines,
            byte_offset,
            submatches,
            raw: _,
        } => {
            *has_matches = true;
            if args.count || args.files || crate::output::limit_reached() {
                return Ok(0);
            }
            // `--offset N` is documented as the other half of the paging pair,
            // and it used to be accepted and ignored on the content path: the
            // run printed every match while claiming to show page two. The skip
            // happens here, on the record that would have been written, so the
            // summary never counts a match the page does not show.
            if tally.offset_skipped < args.offset {
                tally.offset_skipped = tally.offset_skipped.saturating_add(1);
                return Ok(0);
            }
            writer.write_event(&SearchMatch {
                r#type: "match",
                path: path.display().to_string(),
                line_number,
                lines,
                byte_offset,
                submatches,
            })?;
            tally.total_matches = tally.total_matches.saturating_add(1);
            Ok(1)
        }
        SearchEvent::Context {
            path,
            line_number,
            lines,
            raw_block: _,
        } => {
            // Context belongs to the match that pulled it in. While the page
            // window is still skipping, that match was paged out, so its
            // surrounding lines belong to a page the consumer did not ask for.
            let paged_out = tally.offset_skipped < args.offset;
            if !args.count && !args.files && !paged_out && !crate::output::limit_reached() {
                writer.write_event(&SearchContext {
                    r#type: "context",
                    path: path.display().to_string(),
                    line_number,
                    lines,
                })?;
            }
            Ok(0)
        }
        SearchEvent::End {
            path,
            matches,
            lines_searched,
        } => {
            *has_matches |= matches > 0;
            let path_str = path.display().to_string();
            let mut answered = 0;
            if args.files && matches > 0 && !crate::output::limit_reached() {
                writer.write_event(&SearchFile {
                    r#type: "file",
                    path: path_str.clone(),
                })?;
                tally.total_matches = tally.total_matches.saturating_add(matches);
                answered = 1;
            }
            if args.count && matches > 0 && !crate::output::limit_reached() {
                writer.write_event(&SearchCount {
                    r#type: "count",
                    path: path_str.clone(),
                    count: matches,
                })?;
                tally.total_matches = tally.total_matches.saturating_add(matches);
                answered = 1;
            }
            if emits_anchor_events(args) {
                // B5 parity: the closing anchor is emitted for every file the
                // opening anchor was emitted for, including the zero-match
                // case. `stats.matches` stays the real per-file count, so a
                // closed pair with `matches: 0` reports the truth and the
                // consumer no longer sees an opening without a closing.
                writer.write_framing_event(&SearchEnd {
                    r#type: "end",
                    path: path_str,
                    stats: FileStats {
                        matches,
                        lines_searched,
                    },
                })?;
            }
            Ok(answered)
        }
    }
}

/// Where the next `--format text` line stands relative to the previous one.
///
/// `rg` prints `--` between two groups that are not contiguous in the file,
/// and never inside a group. Tracking the last line number printed, and
/// whether context is on at all, is what reproduces that: without context a
/// match at line 9 straight after line 1 gets no separator, which is exactly
/// what `rg` does.
#[derive(Debug, Default)]
struct TextLayout {
    /// Path whose group is open, so a new file starts a new group.
    current_path: Option<std::path::PathBuf>,
    /// Last line number written for `current_path`.
    last_line: Option<u64>,
    /// A group is open, so a separator is only owed when lines are skipped.
    in_group: bool,
    /// Workspace root, resolved once, that every printed path is relative to.
    workspace: std::path::PathBuf,
    /// The targets as they were typed, so the prefix matches `rg` exactly.
    targets: Vec<std::path::PathBuf>,
    /// Whether the file name goes in front of every line.
    show_path: bool,
}

/// Roots this run searches, with the omitted case made explicit.
///
/// `search` keeps the `rg` label of a caller who typed nothing, so the omitted
/// list becomes `.` here instead of an empty walk.
pub(crate) fn search_roots(args: &SearchArgs) -> Vec<std::path::PathBuf> {
    if args.paths.is_empty() {
        vec![std::path::PathBuf::from(".")]
    } else {
        args.paths.clone()
    }
}

/// Stamp the roots this run resolved, so every terminal envelope names them.
///
/// An omitted list is ambient authority and says `workspace`; a named root is
/// the caller's and says `argv`. Several roots are joined with `,` in argv
/// order, which is the shape `replace` already publishes.
pub(crate) fn stamp_search_target<W: std::io::Write>(
    writer: &mut crate::output::NdjsonWriter<W>,
    args: &SearchArgs,
    resolved: &[std::path::PathBuf],
    workspace: &std::path::Path,
) {
    let source = if args.paths.is_empty() {
        crate::commands::path_resolution::TargetSource::Workspace
    } else {
        crate::commands::path_resolution::TargetSource::Argv
    };
    let parts = if args.paths.is_empty() {
        vec![workspace.display().to_string()]
    } else {
        resolved
            .iter()
            .map(|p| p.display().to_string())
            .collect::<Vec<_>>()
    };
    writer.set_targets(parts, source.as_str());
}

impl TextLayout {
    /// Build the layout for one run.
    ///
    /// `rg` decides the path prefix from the command line, not from what the
    /// walk found: a single file prints `LINE:COL:TEXT`, and anything else prints
    /// the target as typed followed by the file below it (`.` yields `./a.rs`,
    /// `src` yields `src/a.rs`).
    fn new(args: &SearchArgs, workspace: &std::path::Path) -> Self {
        Self {
            workspace: workspace.to_path_buf(),
            targets: search_roots(args),
            show_path: text_needs_path(args, workspace),
            ..Self::default()
        }
    }

    /// Ordering key, reproducing how `rg` sequences two files.
    ///
    /// `rg` walks the targets in the order they were typed and, inside a
    /// directory, by path. Sorting on the printed label alone would put
    /// `sub/b.rs` before `a.rs` when both were named on the line, which is not
    /// what the command asked for; keying on the target index first keeps the
    /// caller's order and falls back to the path inside a directory, where
    /// every file shares the same index.
    fn order_key(&self, path: &std::path::Path) -> (usize, String) {
        let relative = path.strip_prefix(&self.workspace).unwrap_or(path);
        let relative = relative.to_string_lossy().replace('\\', "/");
        let index = self
            .targets
            .iter()
            .enumerate()
            .filter(|(_, target)| !target.is_file())
            .find_map(|(index, target)| {
                let base = target.to_string_lossy().replace('\\', "/");
                let base = base.trim_end_matches('/').to_string();
                (base.is_empty() || base == "." || relative.starts_with(&format!("{base}/")))
                    .then_some(index)
            })
            .unwrap_or(usize::MAX);
        (index, relative)
    }

    /// The path as `rg` prints it: target prefix plus the file below it.
    fn path_label(&self, path: &std::path::Path) -> String {
        let relative = path.strip_prefix(&self.workspace).unwrap_or(path);
        let relative = relative.to_string_lossy().replace('\\', "/");
        for target in &self.targets {
            if target.is_file() {
                continue;
            }
            let base = target.to_string_lossy().replace('\\', "/");
            let base = base.trim_end_matches('/');
            if base.is_empty() || base == "." {
                return format!("./{relative}");
            }
            if let Some(rest) = relative.strip_prefix(&format!("{base}/")) {
                return format!("{base}/{rest}");
            }
        }
        relative
    }
}

/// Emit one event as plain text, `rg -n` shaped.
///
/// Returns the same answer count the NDJSON path returns, so the two formats
/// agree on what `--limit` spent and what the summary counted.
fn emit_text_event(
    event: SearchEvent,
    writer: &mut NdjsonWriter<impl Write>,
    args: &SearchArgs,
    has_matches: &mut bool,
    tally: &mut SearchTally,
    text: &mut TextLayout,
) -> Result<u64> {
    match event {
        SearchEvent::Begin(_) => Ok(0),
        SearchEvent::Match {
            path,
            line_number,
            raw,
            submatches,
            ..
        } => {
            *has_matches = true;
            if args.count || args.files || crate::output::limit_reached() {
                return Ok(0);
            }
            if tally.offset_skipped < args.offset {
                tally.offset_skipped = tally.offset_skipped.saturating_add(1);
                return Ok(0);
            }
            // `rg --column` prints the 1-based byte column of the first match
            // on the line. `extract_submatches` already produced it, so text
            // mode reads it here instead of adding a field to the NDJSON record
            // and paying bytes on the expensive format.
            let column = submatches.first().map(|sub| sub.start as u64 + 1);
            text.write_line(writer, args, &path, (line_number, column), &raw, b':')?;
            tally.total_matches = tally.total_matches.saturating_add(1);
            Ok(1)
        }
        SearchEvent::Context {
            path,
            line_number,
            raw_block,
            ..
        } => {
            let paged_out = tally.offset_skipped < args.offset;
            if args.count || args.files || paged_out || crate::output::limit_reached() {
                return Ok(0);
            }
            // One `SinkContext` carries a WHOLE block, and its `line_number` is
            // the first line of that block, so the remaining lines are numbered
            // from it rather than from a per-line field the searcher never sends.
            let mut number = line_number;
            for line in raw_block.split(|b| *b == b'\n') {
                if line.is_empty() && number > line_number {
                    // The trailing element of a `\n`-terminated block.
                    continue;
                }
                text.write_line(writer, args, &path, (number, None), line, b'-')?;
                number = number.saturating_add(1);
            }
            Ok(0)
        }
        SearchEvent::End { path, matches, .. } => {
            *has_matches |= matches > 0;
            if args.files && matches > 0 && !crate::output::limit_reached() {
                let line = format!("{}\n", text.path_label(&path));
                writer.write_text_bytes(line.as_bytes())?;
                tally.total_matches = tally.total_matches.saturating_add(matches);
                return Ok(1);
            }
            if args.count && matches > 0 && !crate::output::limit_reached() {
                let line = if text.show_path {
                    format!("{}:{matches}\n", text.path_label(&path))
                } else {
                    format!("{matches}\n")
                };
                writer.write_text_bytes(line.as_bytes())?;
                tally.total_matches = tally.total_matches.saturating_add(matches);
                return Ok(1);
            }
            Ok(0)
        }
    }
}

impl TextLayout {
    /// Whether a line needs the file name in front of it.
    ///
    /// `rg` omits it when the search was given exactly one file to read. The
    /// decision is per run, so it is asked of the arguments, never of how many
    /// files the walk happened to reach.
    fn write_line(
        &mut self,
        writer: &mut NdjsonWriter<impl Write>,
        args: &SearchArgs,
        path: &std::path::Path,
        position: (u64, Option<u64>),
        bytes: &[u8],
        separator: u8,
    ) -> Result<()> {
        let (line_number, column) = position;
        let new_path = self.current_path.as_deref() != Some(path);
        let skipped = self
            .last_line
            .is_none_or(|last| line_number > last.saturating_add(1));
        // `rg` separates groups only when context is on; without it every
        // match is its own line and a separator would be noise.
        if args.context > 0 && self.in_group && (new_path || skipped) {
            writer.write_text_bytes(b"--\n")?;
        }
        let mut line: Vec<u8> = Vec::with_capacity(bytes.len() + 32);
        if self.show_path {
            line.extend_from_slice(self.path_label(path).as_bytes());
            line.push(separator);
        }
        line.extend_from_slice(line_number.to_string().as_bytes());
        line.push(separator);
        // A match carries the column of its first submatch; a context line has
        // no match of its own, so it stays `LINE:TEXT`, exactly as `rg` prints.
        if let Some(column) = column {
            line.extend_from_slice(column.to_string().as_bytes());
            line.push(separator);
        }
        line.extend_from_slice(bytes);
        line.push(b'\n');
        writer.write_text_bytes(&line)?;
        self.current_path = Some(path.to_path_buf());
        self.last_line = Some(line_number);
        self.in_group = true;
        Ok(())
    }
}

/// Whether every printed line carries the file name, the way `rg` decides it.
///
/// A single file named on the command line prints `LINE:COL:TEXT`; a directory or
/// several files print the path too. Only the targets on the command line
/// decide this: `--include` narrows which files are read and never turns a
/// named file into a set, which is why `rg -F pat a.rs -g '*.rs'` still prints
/// a bare `7:text`.
fn text_needs_path(args: &SearchArgs, workspace: &std::path::Path) -> bool {
    if args.paths.len() != 1 {
        return true;
    }
    let only = args.paths[0].clone();
    let resolved = if only.is_absolute() {
        only
    } else {
        workspace.join(only)
    };
    !resolved.is_file()
}

/// Every pattern this run matches, positional first, then each `-e/--regex`.
///
/// An empty positional (the caller supplied only `-e` values) and a bare
/// `--regex` that stored an empty value contribute nothing, so the list is
/// exactly what the invocation asked to match.
fn search_patterns(args: &SearchArgs) -> Vec<String> {
    let mut patterns = Vec::with_capacity(args.regex.len() + 1);
    if let Some(positional) = args.pattern.as_deref().filter(|p| !p.is_empty()) {
        patterns.push(positional.to_string());
    }
    patterns.extend(args.regex.iter().filter(|p| !p.is_empty()).cloned());
    patterns
}

/// Refuse a run that names no pattern at all.
///
/// The positional is optional only so `-e/--regex` can carry every pattern;
/// a command line with neither is a usage mistake, and the error names the
/// flag that would fix it instead of searching for nothing.
fn validate_patterns(args: &SearchArgs) -> Result<()> {
    if search_patterns(args).is_empty() {
        return Err(crate::error::AtomwriteError::MissingFlag {
            flag: "-e/--regex".to_string(),
            reason: "search needs at least one pattern: pass it positionally or with -e/--regex PATTERN"
                .to_string(),
        }
        .into());
    }
    Ok(())
}

fn build_matcher(args: &SearchArgs) -> Result<grep_regex::RegexMatcher> {
    let patterns = search_patterns(args);
    let mut builder = RegexMatcherBuilder::new();

    if args.case_insensitive {
        builder.case_insensitive(true);
    }

    if args.smart_case {
        builder.case_smart(true);
    }

    if args.word {
        builder.word(true);
    }

    if args.multiline {
        builder.multi_line(true);
    }

    if args.fixed {
        builder.fixed_strings(true);
    }

    // `build_many` compiles the alternation of every pattern in one matcher,
    // so repeated `-e` and the positional pattern all match at once.
    builder.build_many(&patterns).map_err(|e| {
        crate::error::AtomwriteError::invalid(
            &format!("invalid search pattern '{}'", patterns.join("', '")),
            e,
        )
        .into()
    })
}

fn build_walker(
    args: &SearchArgs,
    canonical_paths: &[std::path::PathBuf],
    global: &GlobalArgs,
) -> Result<ignore::WalkBuilder> {
    let mut builder =
        crate::commands::build_scoped_walker(canonical_paths, global, &args.include, &args.exclude)?;

    if let Some(SortBy::Path) = &args.sort {
        builder.sort_by_file_path(|a, b| a.cmp(b));
    }

    Ok(builder)
}

fn extract_submatches(matcher: &grep_regex::RegexMatcher, line: &str) -> Vec<Submatch> {
    let mut subs = Vec::with_capacity(crate::constants::SEARCH_SUBMATCHES_CAPACITY);
    // The line already matched; a matcher error here only costs the
    // per-match offsets, so the line is still reported without them.
    crate::best_effort::best_effort(
        matcher.find_iter(line.as_bytes(), |m| {
            let matched_text = matched_fragment(line, m.start(), m.end());
            subs.push(Submatch {
                r#match: matched_text,
                start: m.start(),
                end: m.end(),
            });
            true
        }),
        "collect submatch offsets",
    );
    subs
}

/// The text a byte-oriented matcher matched, rendered without slicing a `str`
/// at an offset that is not a character boundary.
///
/// `grep_regex` compiles with UTF-8 syntax translation and empty-match UTF-8
/// checking both disabled (`utf8(false)` and `utf8_empty(false)`), because the
/// searcher runs over raw file bytes. Its offsets are therefore byte offsets: a
/// zero-width match can land between the code units of a multi-byte character,
/// and a byte-oriented pattern can match one of its bytes. Slicing `&str` with
/// either panics, and the release profile aborts on panic, so the fragment is
/// rendered lossily instead.
fn matched_fragment(line: &str, start: usize, end: usize) -> String {
    String::from_utf8_lossy(line.as_bytes().get(start..end).unwrap_or(&[])).into_owned()
}

/// One `NotFound` per root named in the invocation that does not exist.
///
/// The tally is rebuilt where it is needed instead of being threaded through
/// every branch: it holds one entry per named root, so rebuilding it costs the
/// same as carrying it.
fn root_failures(missing: &[std::path::PathBuf]) -> FailureTally {
    let mut tally = FailureTally::default();
    tally.record_missing_roots(missing);
    tally
}

/// The single exit of a `search` run: one place decides the status.
///
/// A root named in the invocation that does not exist outranks everything,
/// even matches: the caller asked about a place the run never reached (Bug 2).
/// A file that failed DURING the walk keeps the run at zero when it also
/// answered, and the summary says `partial: true` so nobody reads that answer
/// as complete. Text mode has no terminal record, so every failure goes to
/// stderr with its path and reason, and the status still follows the same
/// rules.
///
/// # Errors
///
/// Returns the first failure tagged with [`crate::output::VerdictPublished`]
/// when a root is missing or when nothing matched and something failed;
/// returns `AtomwriteError::NoMatches` when nothing failed and nothing matched.
fn finish_search_run(
    writer: &mut NdjsonWriter<impl Write>,
    base: Summary,
    failures: FailureTally,
    roots_missing: u64,
    answered: bool,
    text_mode: bool,
) -> Result<()> {
    let (summary, first_failure) = failures.into_summary(base);
    if text_mode {
        if let Some(errors) = &summary.errors {
            for failure in errors {
                crate::best_effort::best_effort(
                    writeln!(
                        std::io::stderr().lock(),
                        "atomwrite: {}: {}",
                        failure.path,
                        failure.message
                    ),
                    "report search failure on stderr",
                );
            }
        }
        // Text mode has no terminal record, so the `--limit` cut reaches the
        // caller here. NDJSON carries the same sentence in `hint`, which keeps
        // the two formats telling the same story about a partial page.
        if let Some(hint) = &summary.base.hint {
            crate::best_effort::best_effort(
                writeln!(std::io::stderr().lock(), "atomwrite: {hint}"),
                "report the --limit cut on stderr",
            );
        }
        if roots_missing > 0
            && let Some(err) = first_failure
        {
            return Err(err.context(crate::output::VerdictPublished));
        }
        if answered {
            return Ok(());
        }
        if let Some(err) = first_failure {
            return Err(err.context(crate::output::VerdictPublished));
        }
        return Err(crate::error::AtomwriteError::NoMatches.into());
    }
    // A named root that does not exist is a failed invocation, matches or not.
    if roots_missing > 0
        && let Some(err) = first_failure
    {
        return Err(writer.publish_failure(&summary, err));
    }
    if answered {
        // `if_accepted`, not `write_terminal`: `--filter` must not swallow the
        // record that says part of the answer is missing.
        writer.write_terminal_if_accepted(&summary)?;
        return Ok(());
    }
    if let Some(err) = first_failure {
        return Err(writer.publish_failure(&summary, err));
    }
    Err(writer.publish_failure(&summary, crate::error::AtomwriteError::NoMatches.into()))
}