Skip to main content

rgx/
compact.rs

1//! Token-savings presentation: reshape ripgrep's `path:line:text` stream into a compact, paginated
2//! view for agents. This is a pure transform over already-rendered output — matching stays 100%
3//! ripgrep (see `confirm`). The contract is deliberately weaker than the byte-for-byte CLI: the
4//! *match set* is identical to `rg` (nothing is ever dropped — pagination is the only volume
5//! control), but the *presentation* differs: the path is printed once per file, results are paged,
6//! and pathologically long lines are center-truncated on the match (one `Read` from full content).
7
8use std::collections::HashMap;
9
10use grep::matcher::Matcher;
11
12use crate::confirm::{SearchOptions, build_matcher};
13use crate::cursor::{self, Cursor, Mode};
14use crate::effective_pattern;
15
16/// Default matches per page. Generous: an agent pulls the next page cheaply (warm index).
17pub const DEFAULT_PAGE_SIZE: usize = 50;
18/// Default max rendered columns per line; normal code lines pass untouched, only long/minified
19/// lines get center-truncated on the match.
20pub const DEFAULT_MAX_COLS: usize = 200;
21
22pub struct CompactOpts {
23    pub mode: Mode,
24    /// Keyset resume position: render only entries strictly after this `(path, lineno)` key (lineno is
25    /// ignored in files/count modes). `None` starts from the beginning.
26    pub start_after: Option<(String, u64)>,
27    pub page_size: usize,
28    pub max_cols: usize,
29}
30
31impl Default for CompactOpts {
32    fn default() -> Self {
33        Self {
34            mode: Mode::Matches,
35            start_after: None,
36            page_size: DEFAULT_PAGE_SIZE,
37            max_cols: DEFAULT_MAX_COLS,
38        }
39    }
40}
41
42/// A rendered page: surface-agnostic `header` + `body`, plus the counts and keyset state each caller
43/// (CLI / MCP) needs to mint the "next page" cursor and detect a changed result set.
44pub struct Page {
45    pub header: String,
46    pub body: String,
47    pub total_matches: usize,
48    pub total_files: usize,
49    /// 1-based index of the first/last entry on this page (in matches, or files for `-l`/`-c`); 0 when
50    /// empty.
51    pub first_index: usize,
52    pub last_index: usize,
53    /// Keyset key of the last rendered entry, to seed the next cursor; `None` when nothing remains.
54    pub last_key: Option<(String, u64)>,
55    pub has_more: bool,
56    /// Fingerprint of the full result set, for staleness detection across pages.
57    pub fingerprint: u64,
58}
59
60impl Page {
61    /// The cursor that fetches the page after this one, or `None` when this is the last page. Both the
62    /// CLI and MCP surfaces mint it the same way; `root_hint` is the only per-surface input (the
63    /// resolved root for the CLI, `None` for MCP where the server root is authoritative).
64    pub fn next_cursor(
65        &self,
66        mode: Mode,
67        pattern: String,
68        opts: SearchOptions,
69        page_size: usize,
70        root_hint: Option<String>,
71    ) -> Option<Cursor> {
72        self.has_more.then(|| Cursor {
73            mode,
74            pattern,
75            opts,
76            page_size,
77            last_path: self.last_key.as_ref().map(|(p, _)| p.clone()),
78            last_lineno: self.last_key.as_ref().map_or(0, |(_, l)| *l),
79            prev_total: self.total_matches,
80            fingerprint: self.fingerprint as u32,
81            root_hint,
82        })
83    }
84
85    /// A note for the caller to surface when resuming a cursor whose result set has since changed
86    /// (fingerprint mismatch), or `None`. `prev` is the cursor's `(prev_total, fingerprint)`; the
87    /// fingerprint is the low 32 bits, so compare against this page's truncated to match.
88    pub fn staleness_note(&self, prev: Option<(usize, u32)>) -> Option<String> {
89        match prev {
90            Some((prev_total, prev_fp)) if prev_fp != self.fingerprint as u32 => Some(format!(
91                "result set changed since the previous page ({prev_total} -> {} matches)",
92                self.total_matches
93            )),
94            _ => None,
95        }
96    }
97}
98
99fn plural(n: usize) -> &'static str {
100    if n == 1 { "" } else { "s" }
101}
102
103struct Row<'a> {
104    path: &'a str,
105    lineno: u64,
106    is_match: bool,
107    text: &'a str,
108    /// Block id: a maximal run of consecutive rows with the same path, not crossing a `--` separator.
109    block: usize,
110}
111
112/// Reshape `raw` (ripgrep's `path:line:text` output) into a compact page. `pattern`/`opts` are used
113/// only to locate the match within long lines for centered truncation. Pagination is keyset
114/// (resume after `start_after`), not offset, so it stays correct when the result set shifts between
115/// calls; `mode` selects the matches / files (`-l`) / count (`-c`) shape.
116pub fn format(raw: &[u8], pattern: &str, opts: SearchOptions, c: CompactOpts) -> Page {
117    let text = String::from_utf8_lossy(raw);
118    let rows = parse_rows(&text);
119
120    // Keyset paging compares match keys as (path-string, lineno), so match_idx MUST be in that exact
121    // order. We cannot trust the input order: collect_search's daemon path emits in index file-id
122    // order (fresh-build = `Path::cmp`, which differs from byte-string order at the `/` boundary; and
123    // incrementally-added files are appended with the highest id), and the cold-scan path is
124    // nondeterministic. Sorting here makes the window, skip count, and last_key self-consistent and
125    // the output deterministic regardless of how the bytes arrived.
126    let mut match_idx: Vec<usize> = rows
127        .iter()
128        .enumerate()
129        .filter(|(_, r)| r.is_match)
130        .map(|(i, _)| i)
131        .collect();
132    match_idx.sort_by(|&a, &b| (rows[a].path, rows[a].lineno).cmp(&(rows[b].path, rows[b].lineno)));
133    let total_matches = match_idx.len();
134
135    let mut files: Vec<&str> = rows.iter().filter(|r| r.is_match).map(|r| r.path).collect();
136    files.sort_unstable();
137    files.dedup();
138    let total_files = files.len();
139
140    let fingerprint =
141        cursor::fingerprint(match_idx.iter().map(|&i| (rows[i].path, rows[i].lineno)));
142    let page_size = c.page_size.max(1);
143
144    match c.mode {
145        Mode::Matches => render_matches(
146            &rows,
147            &match_idx,
148            total_matches,
149            total_files,
150            pattern,
151            opts,
152            &c,
153            page_size,
154            fingerprint,
155        ),
156        Mode::Files | Mode::Count => render_by_file(
157            &rows,
158            &match_idx,
159            &files,
160            total_matches,
161            total_files,
162            &c,
163            page_size,
164            fingerprint,
165        ),
166    }
167}
168
169#[allow(clippy::too_many_arguments)]
170fn render_matches(
171    rows: &[Row],
172    match_idx: &[usize],
173    total_matches: usize,
174    total_files: usize,
175    pattern: &str,
176    opts: SearchOptions,
177    c: &CompactOpts,
178    page_size: usize,
179    fingerprint: u64,
180) -> Page {
181    // Keyset: count matches at or before the resume key, then take the next window.
182    let skip = match &c.start_after {
183        Some((p, l)) => match_idx
184            .iter()
185            .filter(|&&i| (rows[i].path, rows[i].lineno) <= (p.as_str(), *l))
186            .count(),
187        None => 0,
188    };
189    let window_matches: Vec<usize> = match_idx
190        .iter()
191        .copied()
192        .skip(skip)
193        .take(page_size)
194        .collect();
195    let rendered = window_matches.len();
196    let window: std::collections::HashSet<usize> = window_matches.iter().copied().collect();
197    let first_index = if rendered == 0 { 0 } else { skip + 1 };
198    let last_index = if rendered == 0 { 0 } else { skip + rendered };
199    let has_more = skip + rendered < total_matches;
200    let last_key = window_matches
201        .last()
202        .map(|&i| (rows[i].path.to_string(), rows[i].lineno));
203
204    let header = if total_matches == 0 {
205        "[no matches]".to_string()
206    } else {
207        format!(
208            "[matches {first_index}-{last_index} of {total_matches} in {total_files} file{}]",
209            plural(total_files)
210        )
211    };
212
213    // A context row renders iff the nearest match (within its block) is in the window. Collect the
214    // rows to show, then emit them in canonical (path, lineno) order — the same order the keyset
215    // windows in — so the body is sorted and stable regardless of the input's arrival order.
216    let nearest = nearest_match_per_row(rows);
217    let mut to_render: Vec<usize> = (0..rows.len())
218        .filter(|&i| {
219            if rows[i].is_match {
220                window.contains(&i)
221            } else {
222                nearest[i].is_some_and(|m| window.contains(&m))
223            }
224        })
225        .collect();
226    to_render.sort_by(|&a, &b| (rows[a].path, rows[a].lineno).cmp(&(rows[b].path, rows[b].lineno)));
227    let matcher = build_matcher(&effective_pattern(pattern, opts), opts).ok();
228    let mut body = String::new();
229    let mut cur_path: Option<&str> = None;
230    for &i in &to_render {
231        let r = &rows[i];
232        if cur_path != Some(r.path) {
233            body.push_str(r.path);
234            body.push('\n');
235            cur_path = Some(r.path);
236        }
237        let center = if r.is_match {
238            matcher
239                .as_ref()
240                .and_then(|m| m.find(r.text.as_bytes()).ok().flatten())
241                .map(|mat| mat.start())
242        } else {
243            None
244        };
245        let sep = if r.is_match { ':' } else { '-' };
246        body.push_str("  ");
247        body.push_str(&r.lineno.to_string());
248        body.push(sep);
249        body.push(' ');
250        body.push_str(&truncate_centered(r.text, c.max_cols, center));
251        body.push('\n');
252    }
253
254    Page {
255        header,
256        body,
257        total_matches,
258        total_files,
259        first_index,
260        last_index,
261        has_more,
262        last_key,
263        fingerprint,
264    }
265}
266
267#[allow(clippy::too_many_arguments)]
268fn render_by_file(
269    rows: &[Row],
270    match_idx: &[usize],
271    files: &[&str],
272    total_matches: usize,
273    total_files: usize,
274    c: &CompactOpts,
275    page_size: usize,
276    fingerprint: u64,
277) -> Page {
278    let skip = match &c.start_after {
279        Some((p, _)) => files.iter().filter(|&&f| f <= p.as_str()).count(),
280        None => 0,
281    };
282    let window: Vec<&str> = files.iter().copied().skip(skip).take(page_size).collect();
283    let rendered = window.len();
284    let first_index = if rendered == 0 { 0 } else { skip + 1 };
285    let last_index = if rendered == 0 { 0 } else { skip + rendered };
286    let has_more = skip + rendered < total_files;
287    let last_key = window.last().map(|&p| (p.to_string(), 0));
288
289    let counts: HashMap<&str, usize> = if matches!(c.mode, Mode::Count) {
290        let mut m = HashMap::new();
291        for &i in match_idx {
292            *m.entry(rows[i].path).or_insert(0) += 1;
293        }
294        m
295    } else {
296        HashMap::new()
297    };
298
299    let body: String = match c.mode {
300        Mode::Count => window
301            .iter()
302            .map(|&p| format!("{p}:{}\n", counts.get(p).copied().unwrap_or(0)))
303            .collect(),
304        _ => window.iter().map(|&p| format!("{p}\n")).collect(),
305    };
306
307    let header = if total_files == 0 {
308        "[no matches]".to_string()
309    } else if matches!(c.mode, Mode::Count) {
310        format!(
311            "[count {first_index}-{last_index} of {total_files} file{} \u{b7} {total_matches} match{}]",
312            plural(total_files),
313            if total_matches == 1 { "" } else { "es" }
314        )
315    } else {
316        format!("[files {first_index}-{last_index} of {total_files}]")
317    };
318
319    Page {
320        header,
321        body,
322        total_matches,
323        total_files,
324        first_index,
325        last_index,
326        has_more,
327        last_key,
328        fingerprint,
329    }
330}
331
332/// One parsed line: `(path, lineno, is_match, text)`.
333type Cand<'a> = (&'a str, u64, bool, &'a str);
334
335enum Entry<'a> {
336    /// ripgrep's `--` context-block separator.
337    Break,
338    /// A rendered line with its match (`path:N:text`) and/or context (`path-N-text`) candidate split.
339    Row(Option<Cand<'a>>, Option<Cand<'a>>),
340}
341
342fn parse_rows(text: &str) -> Vec<Row<'_>> {
343    let entries: Vec<Entry> = text
344        .lines()
345        .filter_map(|line| {
346            if line == "--" {
347                return Some(Entry::Break);
348            }
349            let m = split_on(line, b':').map(|(p, n, t)| (p, n, true, t));
350            let c = split_on(line, b'-').map(|(p, n, t)| (p, n, false, t));
351            match (m, c) {
352                (None, None) => None, // not a rendered result line (e.g. a binary-file notice)
353                (m, c) => Some(Entry::Row(m, c)),
354            }
355        })
356        .collect();
357
358    // ripgrep's text output is ambiguous: a context line's *text* can hold a `:N:` token (timestamps,
359    // URLs, slices) and a match line's path/text a `-N-` token (version dirs like `v1-2-3`). A path
360    // never contains the line-number separator, so an "anchor" — a line where only one split is
361    // viable — reveals its block's true path; every line in a context block shares that one path.
362    let anchors: Vec<Option<&str>> = entries
363        .iter()
364        .map(|e| match e {
365            Entry::Row(Some(m), None) => Some(m.0),
366            Entry::Row(None, Some(c)) => Some(c.0),
367            _ => None,
368        })
369        .collect();
370
371    let mut rows = Vec::new();
372    let mut block = 0usize;
373    let mut prev_path: Option<&str> = None;
374    let mut pending_break = false;
375    for (i, e) in entries.iter().enumerate() {
376        let (path, lineno, is_match, body) = match e {
377            Entry::Break => {
378                pending_break = true;
379                continue;
380            }
381            Entry::Row(Some(m), None) => *m,
382            Entry::Row(None, Some(c)) => *c,
383            Entry::Row(Some(m), Some(c)) => {
384                // Ambiguous: pick the candidate whose path matches the nearest anchor; if neither
385                // does, fall back to the match split (correct whenever the path holds no colon).
386                let near = nearest_anchor(&anchors, i);
387                if near == Some(c.0) && near != Some(m.0) {
388                    *c
389                } else {
390                    *m
391                }
392            }
393            Entry::Row(None, None) => continue,
394        };
395        if let Some(pp) = prev_path
396            && (pp != path || pending_break)
397        {
398            block += 1;
399        }
400        pending_break = false;
401        prev_path = Some(path);
402        rows.push(Row {
403            path,
404            lineno,
405            is_match,
406            text: body,
407            block,
408        });
409    }
410    rows
411}
412
413/// The path of the nearest anchored line to index `i` (closest by row distance, earlier wins ties).
414fn nearest_anchor<'a>(anchors: &[Option<&'a str>], i: usize) -> Option<&'a str> {
415    (1..anchors.len()).find_map(|d| {
416        i.checked_sub(d)
417            .and_then(|j| anchors[j])
418            .or_else(|| anchors.get(i + d).copied().flatten())
419    })
420}
421
422fn split_on(line: &str, sep: u8) -> Option<(&str, u64, &str)> {
423    let bytes = line.as_bytes();
424    let mut i = 0;
425    while i < bytes.len() {
426        if bytes[i] == sep {
427            let rest = &bytes[i + 1..];
428            let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
429            if digits > 0 && rest.get(digits) == Some(&sep) {
430                let lineno: u64 = line[i + 1..i + 1 + digits].parse().ok()?;
431                return Some((&line[..i], lineno, &line[i + 1 + digits + 1..]));
432            }
433        }
434        i += 1;
435    }
436    None
437}
438
439/// For each row, the index of the nearest match row in the same block (by row distance, ties favor
440/// the following match). Match rows map to themselves.
441fn nearest_match_per_row(rows: &[Row]) -> Vec<Option<usize>> {
442    let n = rows.len();
443    let mut out = vec![None; n];
444    // Nearest match scanning forward, then backward, staying within the same block.
445    let mut last: Option<usize> = None;
446    for i in 0..n {
447        if rows[i].is_match {
448            last = Some(i);
449        }
450        out[i] = last.filter(|&m| rows[m].block == rows[i].block);
451    }
452    let mut next: Option<usize> = None;
453    for i in (0..n).rev() {
454        if rows[i].is_match {
455            next = Some(i);
456        }
457        let fwd = next.filter(|&m| rows[m].block == rows[i].block);
458        out[i] = match (out[i], fwd) {
459            (Some(b), Some(f)) => Some(if i - b <= f - i { b } else { f }),
460            (b, f) => b.or(f),
461        };
462    }
463    out
464}
465
466/// Truncate `text` to `max_cols` columns, centered on byte offset `center` (the match start) when
467/// given, else head-anchored. UTF-8 safe; adds `…` on trimmed sides.
468fn truncate_centered(text: &str, max_cols: usize, center: Option<usize>) -> String {
469    let char_count = text.chars().count();
470    if char_count <= max_cols {
471        return text.to_string();
472    }
473    let center_char = match center {
474        Some(byte) => {
475            // The match offset comes from a byte search; snap it down to a char boundary so slicing
476            // a multi-byte line never panics.
477            let mut b = byte.min(text.len());
478            while b > 0 && !text.is_char_boundary(b) {
479                b -= 1;
480            }
481            text[..b].chars().count()
482        }
483        None => 0,
484    };
485    let before = max_cols / 3;
486    let start = center_char
487        .saturating_sub(before)
488        .min(char_count - max_cols);
489    let end = start + max_cols;
490
491    let char_byte = |ci: usize| {
492        text.char_indices()
493            .nth(ci)
494            .map(|(b, _)| b)
495            .unwrap_or(text.len())
496    };
497    let slice = &text[char_byte(start)..char_byte(end)];
498    let mut out = String::new();
499    if start > 0 {
500        out.push('\u{2026}');
501    }
502    out.push_str(slice);
503    if end < char_count {
504        out.push('\u{2026}');
505    }
506    out
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    const RAW: &[u8] = b"src/a.rs:1:fn one() {}\n\
514src/a.rs:2:fn two() {}\n\
515src/b.rs:10:fn three() {}\n";
516
517    fn page(raw: &[u8], pattern: &str, c: CompactOpts) -> Page {
518        format(raw, pattern, SearchOptions::default(), c)
519    }
520
521    #[test]
522    fn groups_by_file_with_counts() {
523        let p = page(RAW, "fn", CompactOpts::default());
524        assert_eq!(p.total_matches, 3);
525        assert_eq!(p.total_files, 2);
526        assert!(!p.has_more);
527        // Path printed once per file, lines indented under it.
528        assert_eq!(p.body.matches("src/a.rs\n").count(), 1);
529        assert!(
530            p.body
531                .contains("src/a.rs\n  1: fn one() {}\n  2: fn two() {}\n")
532        );
533        assert!(p.body.contains("src/b.rs\n  10: fn three() {}\n"));
534        assert!(p.header.contains("matches 1-3 of 3 in 2 files"));
535    }
536
537    #[test]
538    fn paginates_without_dropping_matches() {
539        let p1 = page(
540            RAW,
541            "fn",
542            CompactOpts {
543                page_size: 2,
544                ..Default::default()
545            },
546        );
547        assert!(p1.has_more);
548        assert_eq!((p1.first_index, p1.last_index), (1, 2));
549        assert!(p1.body.contains("  1: fn one"));
550        assert!(p1.body.contains("  2: fn two"));
551        assert!(!p1.body.contains("three"));
552
553        let p2 = page(
554            RAW,
555            "fn",
556            CompactOpts {
557                page_size: 2,
558                start_after: p1.last_key.clone(),
559                ..Default::default()
560            },
561        );
562        assert!(!p2.has_more);
563        assert_eq!((p2.first_index, p2.last_index), (3, 3));
564        assert!(p2.body.contains("src/b.rs\n  10: fn three"));
565        assert!(!p2.body.contains("fn one"));
566    }
567
568    #[test]
569    fn keyset_survives_unsorted_input_without_dropping_matches() {
570        // The daemon emits matches in index file-id order (Path::cmp / append order), which is NOT
571        // byte-string order: `src/a/b.rs` sorts before `src/a.rs` as a Path but after it as a string.
572        // Feed that daemon-style order and walk every page; all three matches must be reachable.
573        const UNSORTED: &[u8] = b"src/a/b.rs:1:fn x\n\
574src/a.rs:1:fn y\n\
575src/ab.rs:1:fn z\n";
576        let mut seen = Vec::new();
577        let mut start_after = None;
578        for _ in 0..5 {
579            let p = page(
580                UNSORTED,
581                "fn",
582                CompactOpts {
583                    page_size: 1,
584                    start_after: start_after.clone(),
585                    ..Default::default()
586                },
587            );
588            assert_eq!(p.total_matches, 3);
589            for line in p.body.lines().filter(|l| l.starts_with("  ")) {
590                seen.push(line.to_string());
591            }
592            if !p.has_more {
593                break;
594            }
595            start_after = p.last_key.clone();
596        }
597        // Every match rendered exactly once, in canonical path order, none dropped or duplicated.
598        assert_eq!(seen, vec!["  1: fn y", "  1: fn x", "  1: fn z"]);
599    }
600
601    #[test]
602    fn keyset_resume_after_last_key() {
603        // Resume mid-file: page_size 1 over two matches in src/a.rs.
604        let p1 = page(
605            RAW,
606            "fn",
607            CompactOpts {
608                page_size: 1,
609                ..Default::default()
610            },
611        );
612        assert_eq!(p1.last_key, Some(("src/a.rs".to_string(), 1)));
613        let p2 = page(
614            RAW,
615            "fn",
616            CompactOpts {
617                page_size: 1,
618                start_after: p1.last_key.clone(),
619                ..Default::default()
620            },
621        );
622        assert!(p2.body.contains("  2: fn two"));
623        assert!(!p2.body.contains("fn one"));
624        assert_eq!((p2.first_index, p2.last_index), (2, 2));
625    }
626
627    #[test]
628    fn files_mode_lists_paths() {
629        let p = page(
630            RAW,
631            "fn",
632            CompactOpts {
633                mode: Mode::Files,
634                ..Default::default()
635            },
636        );
637        assert_eq!(p.total_files, 2);
638        assert!(p.header.contains("files 1-2 of 2"));
639        assert!(p.body.contains("src/a.rs\n"));
640        assert!(p.body.contains("src/b.rs\n"));
641        assert!(!p.body.contains("fn one")); // no match text in files mode
642    }
643
644    #[test]
645    fn count_mode_tallies_per_file() {
646        let p = page(
647            RAW,
648            "fn",
649            CompactOpts {
650                mode: Mode::Count,
651                ..Default::default()
652            },
653        );
654        assert!(p.body.contains("src/a.rs:2\n"));
655        assert!(p.body.contains("src/b.rs:1\n"));
656        assert!(p.header.contains("count 1-2 of 2 files"));
657        assert!(p.header.contains("3 matches"));
658    }
659
660    #[test]
661    fn fingerprint_stable_across_calls_and_pages() {
662        let full = page(RAW, "fn", CompactOpts::default());
663        let paged = page(
664            RAW,
665            "fn",
666            CompactOpts {
667                page_size: 1,
668                ..Default::default()
669            },
670        );
671        assert_eq!(full.fingerprint, paged.fingerprint);
672        assert_ne!(full.fingerprint, 0);
673    }
674
675    #[test]
676    fn truncates_long_line_centered_on_match() {
677        let long = format!("src/x.rs:1:{}NEEDLE{}\n", "a".repeat(400), "b".repeat(400));
678        let p = page(
679            long.as_bytes(),
680            "NEEDLE",
681            CompactOpts {
682                max_cols: 60,
683                ..Default::default()
684            },
685        );
686        let line = p.body.lines().find(|l| l.contains("NEEDLE")).unwrap();
687        assert!(line.contains('\u{2026}'), "expected ellipsis: {line}");
688        assert!(line.chars().count() < 100);
689    }
690
691    #[test]
692    fn truncates_long_multibyte_line_without_panicking() {
693        let long = format!(
694            "src/x.rs:1:{}café NEEDLE {}\n",
695            "é".repeat(300),
696            "ü".repeat(300)
697        );
698        let p = page(
699            long.as_bytes(),
700            "NEEDLE",
701            CompactOpts {
702                max_cols: 50,
703                ..Default::default()
704            },
705        );
706        let line = p.body.lines().find(|l| l.contains("NEEDLE")).unwrap();
707        assert!(line.contains('\u{2026}'));
708        assert!(line.chars().count() < 90);
709    }
710
711    #[test]
712    fn empty_input_has_no_body() {
713        let p = page(b"", "fn", CompactOpts::default());
714        assert_eq!(p.total_matches, 0);
715        assert!(!p.has_more);
716        assert!(p.body.is_empty());
717        assert_eq!(p.header, "[no matches]");
718    }
719
720    #[test]
721    fn context_lines_attach_to_their_match_and_dont_count() {
722        // ripgrep `-C` shape: `path-N-` context lines, `path:N:` match lines, `--` block separators.
723        let raw = b"f.rs-4-before a\n\
724f.rs:5:MATCH a\n\
725f.rs-6-after a\n\
726--\n\
727f.rs-9-before b\n\
728f.rs:10:MATCH b\n\
729f.rs-11-after b\n";
730        let p = page(
731            raw,
732            "MATCH",
733            CompactOpts {
734                page_size: 1,
735                ..Default::default()
736            },
737        );
738        // Two matches in one file; context lines never inflate the count.
739        assert_eq!(p.total_matches, 2);
740        assert_eq!(p.total_files, 1);
741        assert!(p.has_more);
742        // Page 1 carries match a with its surrounding context, and nothing from match b's block.
743        assert!(p.body.contains("  5: MATCH a"));
744        assert!(p.body.contains("  4- before a"));
745        assert!(p.body.contains("  6- after a"));
746        assert!(!p.body.contains("MATCH b"));
747        assert!(!p.body.contains("before b"));
748    }
749
750    #[test]
751    fn context_line_with_colon_digits_in_text_is_not_misparsed() {
752        // A before-context line whose text holds a `:N:` token (timestamp) must stay context, not be
753        // mistaken for a match — otherwise it inflates counts and invents a phantom file.
754        let raw = b"f.txt-2-log at 12:34:56 here\n\
755f.txt:3:TARGET match\n\
756f.txt-4-after line\n";
757        let p = page(raw, "TARGET", CompactOpts::default());
758        assert_eq!(p.total_matches, 1, "{}", p.body);
759        assert_eq!(p.total_files, 1, "{}", p.body);
760        assert!(p.body.contains("f.txt\n  2- log at 12:34:56 here"));
761        assert!(p.body.contains("  3: TARGET match"));
762        assert!(!p.body.contains("12\n"));
763    }
764
765    #[test]
766    fn colon_separator_wins_over_hyphen_in_path() {
767        // A real `:N:` must split even when the path/text contains hyphens (and digits around them).
768        let p = page(
769            b"src/a-b-2.rs:42:let x-1 = y-2;\n",
770            "let",
771            CompactOpts::default(),
772        );
773        assert!(
774            p.body.contains("src/a-b-2.rs\n  42: let x-1 = y-2;"),
775            "{}",
776            p.body
777        );
778        assert_eq!(p.total_matches, 1);
779    }
780
781    #[test]
782    fn start_after_past_end_is_empty() {
783        let p = page(
784            RAW,
785            "fn",
786            CompactOpts {
787                start_after: Some(("zzz".to_string(), 0)),
788                ..Default::default()
789            },
790        );
791        assert!(!p.has_more);
792        assert_eq!(p.first_index, 0);
793        assert_eq!(p.last_index, 0);
794        assert!(p.body.is_empty());
795        assert_eq!(p.last_key, None);
796    }
797}