Skip to main content

yarp_cli/
archive_query.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::fmt::Write as _;
3use std::io::{Read, Seek, SeekFrom};
4
5use regex::bytes::{Regex, RegexBuilder};
6
7use crate::archive::{Archive, SourceCompleteness, SourceName, VerifiedSource};
8use crate::config;
9use crate::reducers::filter::AnsiStripper;
10
11pub const SEARCH_HELP: &str = "Search one archived YARP call.\n\nExamples:\n  yarp search REF 'error|FAILED'\n  yarp search REF 'literal text' -F -i\n  yarp search REF 'warning' -v -C 3 --max-results 20\n  yarp read REF stdout 118:130\n\nUsage:\n  yarp search REF PATTERN [options]\n  yarp search REF -e PATTERN [-e PATTERN ...] [options]\n\nOptions: -e/--regexp -F/--fixed-strings -i/--ignore-case\n         -w/--word-regexp (ASCII boundaries) -v/--invert-match\n         -A/--after-context -B/--before-context -C/--context\n         -m/--max-results --\n";
12
13const MAX_PATTERN_BYTES: usize = 1_024;
14const MAX_PATTERNS: usize = 8;
15const MAX_QUERY_LINE_BYTES: usize = 1024 * 1024;
16const MAX_DISPLAY_LINE_BYTES: usize = 4 * 1024;
17const MAX_DISPLAY_BYTES_PER_SOURCE: usize = 7 * 1024;
18const MAX_DISPLAY_RECORDS: usize = 2_048;
19const MAX_RENDERED_SELECTED_LINES: usize = 32;
20const REGEX_SIZE_LIMIT: usize = 512 * 1024;
21
22#[derive(Debug, Eq, PartialEq)]
23pub enum SearchOutcome {
24    Matches(Vec<u8>),
25    NoMatches(Vec<u8>),
26}
27
28#[derive(Debug)]
29#[expect(
30    clippy::struct_excessive_bools,
31    reason = "the supported search flags are independent command-line switches"
32)]
33struct SearchOptions {
34    archive_ref: String,
35    patterns: Vec<String>,
36    fixed: bool,
37    ignore_case: bool,
38    word: bool,
39    invert: bool,
40    before: usize,
41    after: usize,
42    max_results: usize,
43}
44
45#[derive(Debug)]
46struct DisplayLine {
47    text: String,
48}
49
50#[derive(Debug, Clone, Copy)]
51struct QueryLimits {
52    max_bytes: usize,
53    max_lines: usize,
54}
55
56struct SourceSearch {
57    name: SourceName,
58    completeness: SourceCompleteness,
59    total_lines: u64,
60    total_selected: u64,
61    first_selected: Option<u64>,
62    displayed_selected: Vec<u64>,
63    lines: BTreeMap<u64, DisplayLine>,
64    before: usize,
65    after: usize,
66    max_results: usize,
67}
68
69/// Search verified archived sources with a deliberately small `rg`-style syntax.
70///
71/// # Errors
72///
73/// Returns an error before source output when arguments, references, payloads, text, or bounds are
74/// invalid.
75pub fn search(arguments: &[String]) -> Result<SearchOutcome, String> {
76    if matches!(arguments, [argument] if argument == "--help" || argument == "-h") {
77        return Ok(SearchOutcome::Matches(SEARCH_HELP.as_bytes().to_vec()));
78    }
79    let config = config::load()?;
80    let limits = QueryLimits {
81        max_bytes: config.output.recovery_cap_bytes,
82        max_lines: config.output.recovery_cap_lines,
83    };
84    let options = parse_search(arguments)?;
85    let matcher = compile_matcher(&options)?;
86    let archive = Archive::open_read_only()?;
87    let sources = archive.searchable_sources(&options.archive_ref)?;
88    let mut results = Vec::with_capacity(sources.len());
89    let mut selected = 0_u64;
90    for source in sources {
91        let result = search_source(source, &matcher, &options)?;
92        selected = selected.saturating_add(result.total_selected);
93        results.push(result);
94    }
95    if selected == 0 {
96        return Ok(SearchOutcome::NoMatches(b"No matches\n".to_vec()));
97    }
98    let output = render_search(&options.archive_ref, &mut results, limits)?;
99    Ok(SearchOutcome::Matches(output))
100}
101
102/// Read one exact bounded line or byte range from a verified archived source.
103///
104/// # Errors
105///
106/// Returns an error before stdout when arguments, references, payloads, sources, or ranges are
107/// invalid.
108pub fn read(arguments: &[String]) -> Result<Vec<u8>, String> {
109    let config = config::load()?;
110    let limits = QueryLimits {
111        max_bytes: config.output.recovery_cap_bytes,
112        max_lines: config.output.recovery_cap_lines,
113    };
114    let request = parse_read(arguments)?;
115    let archive = Archive::open_read_only()?;
116    let sources = archive.searchable_sources(&request.archive_ref)?;
117    let mut source = choose_source(sources, request.source.as_deref())?;
118    match request.range {
119        ReadRange::Lines { start, end } => read_line_range(&mut source, start, end, limits),
120        ReadRange::Bytes { start, end } => read_byte_range(&mut source, start, end, limits),
121    }
122}
123
124#[expect(
125    clippy::too_many_lines,
126    reason = "keeping the closed search option grammar in one parser makes conflicts explicit"
127)]
128fn parse_search(arguments: &[String]) -> Result<SearchOptions, String> {
129    let mut positional = Vec::new();
130    let mut patterns = Vec::new();
131    let mut fixed = false;
132    let mut ignore_case = false;
133    let mut word = false;
134    let mut invert = false;
135    let mut before = None;
136    let mut after = None;
137    let mut max_results = None;
138    let mut end_options = false;
139    let mut index = 0;
140    while index < arguments.len() {
141        let argument = &arguments[index];
142        if end_options {
143            positional.push(argument.clone());
144            index += 1;
145            continue;
146        }
147        match argument.as_str() {
148            "--" => {
149                end_options = true;
150                index += 1;
151            }
152            "-F" | "--fixed-strings" => {
153                fixed = true;
154                index += 1;
155            }
156            "-i" | "--ignore-case" => {
157                ignore_case = true;
158                index += 1;
159            }
160            "-w" | "--word-regexp" => {
161                word = true;
162                index += 1;
163            }
164            "-v" | "--invert-match" => {
165                invert = true;
166                index += 1;
167            }
168            "-e" | "--regexp" => {
169                patterns.push(require_value(arguments, index, argument)?);
170                index += 2;
171            }
172            "-A" | "--after-context" => {
173                let value = parse_count(&require_value(arguments, index, argument)?, 0, 50)?;
174                set_consistent(&mut after, value, "after-context")?;
175                index += 2;
176            }
177            "-B" | "--before-context" => {
178                let value = parse_count(&require_value(arguments, index, argument)?, 0, 50)?;
179                set_consistent(&mut before, value, "before-context")?;
180                index += 2;
181            }
182            "-C" | "--context" => {
183                let value = parse_count(&require_value(arguments, index, argument)?, 0, 50)?;
184                set_consistent(&mut before, value, "before-context")?;
185                set_consistent(&mut after, value, "after-context")?;
186                index += 2;
187            }
188            "-m" | "--max-results" => {
189                let value = parse_count(&require_value(arguments, index, argument)?, 1, 100)?;
190                set_consistent(&mut max_results, value, "max-results")?;
191                index += 2;
192            }
193            value if value.starts_with('-') => {
194                return Err(format!(
195                    "unknown search option {value}; expected: yarp search REF PATTERN"
196                ));
197            }
198            _ => {
199                positional.push(argument.clone());
200                index += 1;
201            }
202        }
203    }
204    let Some(archive_ref) = positional.first().cloned() else {
205        return Err("missing archive reference; expected: yarp search REF PATTERN".to_owned());
206    };
207    let positional_patterns = &positional[1..];
208    if !patterns.is_empty() && !positional_patterns.is_empty() {
209        return Err(
210            "positional PATTERN and -e are mutually exclusive; expected: yarp search REF PATTERN"
211                .to_owned(),
212        );
213    }
214    if positional_patterns.len() > 1 {
215        return Err(
216            "search accepts one positional pattern; expected: yarp search REF PATTERN".to_owned(),
217        );
218    }
219    if let Some(pattern) = positional_patterns.first() {
220        patterns.push(pattern.clone());
221    }
222    if patterns.is_empty() {
223        return Err("missing search pattern; expected: yarp search REF PATTERN".to_owned());
224    }
225    if patterns.len() > MAX_PATTERNS {
226        return Err(format!("search accepts at most {MAX_PATTERNS} patterns"));
227    }
228    for pattern in &patterns {
229        if pattern.is_empty() || pattern.len() > MAX_PATTERN_BYTES {
230            return Err(format!(
231                "each search pattern must contain 1 through {MAX_PATTERN_BYTES} UTF-8 bytes"
232            ));
233        }
234    }
235    Ok(SearchOptions {
236        archive_ref,
237        patterns,
238        fixed,
239        ignore_case,
240        word,
241        invert,
242        before: before.unwrap_or(2),
243        after: after.unwrap_or(2),
244        max_results: max_results.unwrap_or(20),
245    })
246}
247
248fn require_value(arguments: &[String], index: usize, option: &str) -> Result<String, String> {
249    arguments
250        .get(index + 1)
251        .cloned()
252        .ok_or_else(|| format!("{option} requires a value; expected: yarp search REF PATTERN"))
253}
254
255fn parse_count(value: &str, minimum: usize, maximum: usize) -> Result<usize, String> {
256    if value.is_empty()
257        || (value.len() > 1 && value.starts_with('0'))
258        || !value.bytes().all(|byte| byte.is_ascii_digit())
259    {
260        return Err(format!("invalid canonical unsigned count {value:?}"));
261    }
262    let parsed = value
263        .parse::<usize>()
264        .map_err(|_| format!("count {value:?} is outside the supported range"))?;
265    if !(minimum..=maximum).contains(&parsed) {
266        return Err(format!("count must be between {minimum} and {maximum}"));
267    }
268    Ok(parsed)
269}
270
271fn set_consistent(target: &mut Option<usize>, value: usize, label: &str) -> Result<(), String> {
272    if target.is_some_and(|existing| existing != value) {
273        return Err(format!("conflicting {label} values"));
274    }
275    *target = Some(value);
276    Ok(())
277}
278
279fn compile_matcher(options: &SearchOptions) -> Result<Regex, String> {
280    let alternatives = options
281        .patterns
282        .iter()
283        .map(|pattern| {
284            let pattern = if options.fixed {
285                regex::escape(pattern)
286            } else {
287                pattern.clone()
288            };
289            if options.word {
290                format!(r"(?:\b(?:{pattern})\b)")
291            } else {
292                format!("(?:{pattern})")
293            }
294        })
295        .collect::<Vec<_>>()
296        .join("|");
297    RegexBuilder::new(&alternatives)
298        .case_insensitive(options.ignore_case)
299        .unicode(false)
300        .size_limit(REGEX_SIZE_LIMIT)
301        .dfa_size_limit(REGEX_SIZE_LIMIT)
302        .build()
303        .map_err(|error| format!("invalid bounded search pattern: {error}"))
304}
305
306fn search_source(
307    mut source: VerifiedSource,
308    matcher: &Regex,
309    options: &SearchOptions,
310) -> Result<SourceSearch, String> {
311    if source.media_type != "text/plain; charset=utf-8" {
312        return Err(format!(
313            "{} is binary; use yarp read REF {} --bytes START:END",
314            source.name.as_str(),
315            source.name.as_str()
316        ));
317    }
318    source
319        .body
320        .seek(SeekFrom::Start(0))
321        .map_err(|error| format!("could not rewind {}: {error}", source.name.as_str()))?;
322    let mut previous = VecDeque::<(u64, String)>::with_capacity(options.before);
323    let mut display = DisplayStore::default();
324    let mut displayed_selected = Vec::new();
325    let mut total_selected = 0_u64;
326    let mut first_selected = None;
327    let mut total_lines = 0_u64;
328    let mut after_remaining = 0_usize;
329    let mut stripper = AnsiStripper::new();
330    scan_query_lines(&mut source.body, |line_number, raw| {
331        total_lines = line_number;
332        let normalized = normalize_line(raw, &mut stripper)?;
333        if after_remaining > 0 {
334            display.insert(line_number, normalized.clone());
335            after_remaining -= 1;
336        }
337        let line_matches = matcher.is_match(normalized.as_bytes());
338        let selected = if options.invert {
339            !line_matches
340        } else {
341            line_matches
342        };
343        if selected {
344            total_selected = total_selected.saturating_add(1);
345            first_selected.get_or_insert(line_number);
346            if displayed_selected.len() < options.max_results.min(MAX_RENDERED_SELECTED_LINES)
347                && display.insert(line_number, normalized.clone())
348            {
349                for (context_line, context) in &previous {
350                    display.insert(*context_line, context.clone());
351                }
352                displayed_selected.push(line_number);
353                after_remaining = options.after;
354            }
355        }
356        if options.before > 0 {
357            if previous.len() == options.before {
358                previous.pop_front();
359            }
360            previous.push_back((line_number, normalized));
361        }
362        Ok(())
363    })?;
364    Ok(SourceSearch {
365        name: source.name,
366        completeness: source.completeness,
367        total_lines,
368        total_selected,
369        first_selected,
370        displayed_selected,
371        lines: display.lines,
372        before: options.before,
373        after: options.after,
374        max_results: options.max_results,
375    })
376}
377
378#[derive(Default)]
379struct DisplayStore {
380    lines: BTreeMap<u64, DisplayLine>,
381    bytes: usize,
382}
383
384impl DisplayStore {
385    fn insert(&mut self, number: u64, text: String) -> bool {
386        if self.lines.contains_key(&number) {
387            return true;
388        }
389        let text = truncate_display_line(text);
390        if self.lines.len() >= MAX_DISPLAY_RECORDS
391            || self.bytes.saturating_add(text.len()) > MAX_DISPLAY_BYTES_PER_SOURCE
392        {
393            return false;
394        }
395        self.bytes = self.bytes.saturating_add(text.len());
396        self.lines.insert(number, DisplayLine { text });
397        true
398    }
399}
400
401fn scan_query_lines(
402    reader: &mut impl Read,
403    mut observe: impl FnMut(u64, &[u8]) -> Result<(), String>,
404) -> Result<(), String> {
405    let mut buffer = vec![0_u8; 64 * 1024];
406    let mut line = Vec::with_capacity(64 * 1024);
407    let mut line_number = 0_u64;
408    loop {
409        let count = reader
410            .read(&mut buffer)
411            .map_err(|error| format!("could not read archived source: {error}"))?;
412        if count == 0 {
413            break;
414        }
415        let mut start = 0;
416        for (index, byte) in buffer[..count].iter().enumerate() {
417            if *byte != b'\n' {
418                continue;
419            }
420            append_bounded_line(&mut line, &buffer[start..=index])?;
421            line_number = line_number.saturating_add(1);
422            observe(line_number, &line)?;
423            line.clear();
424            start = index + 1;
425        }
426        append_bounded_line(&mut line, &buffer[start..count])?;
427    }
428    if !line.is_empty() {
429        line_number = line_number.saturating_add(1);
430        observe(line_number, &line)?;
431    }
432    Ok(())
433}
434
435fn append_bounded_line(line: &mut Vec<u8>, part: &[u8]) -> Result<(), String> {
436    if line.len().saturating_add(part.len()) > MAX_QUERY_LINE_BYTES {
437        return Err(format!(
438            "archived source line exceeds {MAX_QUERY_LINE_BYTES} bytes; use a bounded yarp read --bytes range"
439        ));
440    }
441    line.extend_from_slice(part);
442    Ok(())
443}
444
445fn normalize_line(raw: &[u8], ansi: &mut AnsiStripper) -> Result<String, String> {
446    let mut clean_bytes = Vec::with_capacity(raw.len().min(MAX_QUERY_LINE_BYTES));
447    for byte in raw {
448        if let Some(value) = ansi.push_byte(*byte) {
449            clean_bytes.push(value);
450        }
451    }
452    let clean_bytes = clean_bytes
453        .strip_suffix(b"\r\n")
454        .or_else(|| clean_bytes.strip_suffix(b"\n"))
455        .unwrap_or(&clean_bytes);
456    let text = std::str::from_utf8(clean_bytes)
457        .map_err(|_| "archived source is not valid UTF-8; use yarp read --bytes".to_owned())?;
458    let mut normalized = String::with_capacity(text.len());
459    for character in text.chars() {
460        if character == '\t' || !character.is_control() {
461            normalized.push(character);
462        } else if character.is_ascii() {
463            write!(normalized, "\\x{:02x}", u32::from(character))
464                .map_err(|error| format!("could not render control byte: {error}"))?;
465        } else {
466            write!(normalized, "\\u{{{:x}}}", u32::from(character))
467                .map_err(|error| format!("could not render control character: {error}"))?;
468        }
469        if normalized.len() > MAX_QUERY_LINE_BYTES {
470            return Err("normalized archived source line exceeds 1 MiB".to_owned());
471        }
472    }
473    Ok(normalized)
474}
475
476fn truncate_display_line(mut value: String) -> String {
477    if value.len() <= MAX_DISPLAY_LINE_BYTES {
478        return value;
479    }
480    let mut end = MAX_DISPLAY_LINE_BYTES.saturating_sub(3);
481    while !value.is_char_boundary(end) {
482        end = end.saturating_sub(1);
483    }
484    value.truncate(end);
485    value.push_str("...");
486    value
487}
488
489fn render_search(
490    archive_ref: &str,
491    sources: &mut [SourceSearch],
492    limits: QueryLimits,
493) -> Result<Vec<u8>, String> {
494    loop {
495        let output = render_search_once(archive_ref, sources, limits.max_bytes);
496        if output.len() <= limits.max_bytes && output_line_count(&output) <= limits.max_lines {
497            return Ok(output);
498        }
499        if !shrink_search(sources) {
500            return Err(format!(
501                "search output cannot fit configured recovery limits of {} bytes and {} lines; narrow the pattern or context",
502                limits.max_bytes, limits.max_lines
503            ));
504        }
505    }
506}
507
508fn render_search_once(archive_ref: &str, sources: &[SourceSearch], max_bytes: usize) -> Vec<u8> {
509    let mut output = Vec::with_capacity(max_bytes);
510    let display_line_bytes = MAX_DISPLAY_LINE_BYTES.min(max_bytes.saturating_div(2).max(64));
511    if sources
512        .iter()
513        .all(|source| source.displayed_selected.is_empty())
514    {
515        let total = sources
516            .iter()
517            .map(|source| source.total_selected)
518            .sum::<u64>();
519        if let Some((source, line)) = sources
520            .iter()
521            .find_map(|source| source.first_selected.map(|line| (source, line)))
522        {
523            output.extend_from_slice(
524                format!(
525                    "[yarp search: ref={archive_ref}; matches={total}; showing=0; omitted={total}; first={}:{}; read=yarp read {archive_ref} {} {line}:{line}]\n",
526                    source.name.as_str(),
527                    line,
528                    source.name.as_str(),
529                )
530                .as_bytes(),
531            );
532        }
533        return output;
534    }
535    output.extend_from_slice(format!("[yarp search: ref={archive_ref}]\n").as_bytes());
536    for (source_index, source) in sources.iter().enumerate() {
537        if source_index > 0 {
538            output.extend_from_slice(b"--\n");
539        }
540        output.extend_from_slice(
541            format!(
542                "[source={} complete={} matches={} showing={} before={} after={} max_results={}]\n",
543                source.name.as_str(),
544                completeness_label(source.completeness),
545                source.total_selected,
546                source.displayed_selected.len(),
547                source.before,
548                source.after,
549                source.max_results
550            )
551            .as_bytes(),
552        );
553        let groups = displayed_groups(source);
554        let selected = source
555            .displayed_selected
556            .iter()
557            .copied()
558            .collect::<BTreeSet<_>>();
559        for (group_index, (start, end)) in groups.iter().enumerate() {
560            if group_index > 0 {
561                output.extend_from_slice(b"--\n");
562            }
563            for (number, line) in source.lines.range(*start..=*end) {
564                let separator = if selected.contains(number) { ':' } else { '-' };
565                output.extend_from_slice(
566                    format!(
567                        "{}{separator}{}{separator}{}\n",
568                        source.name.as_str(),
569                        number,
570                        truncate_rendered_line(&line.text, display_line_bytes)
571                    )
572                    .as_bytes(),
573                );
574            }
575        }
576        let omitted = source
577            .total_selected
578            .saturating_sub(u64::try_from(source.displayed_selected.len()).unwrap_or(u64::MAX));
579        if omitted > 0 {
580            output.extend_from_slice(
581                format!("[yarp search: {omitted} selected line(s) omitted]\n").as_bytes(),
582            );
583        }
584        for (start, end) in groups {
585            output.extend_from_slice(
586                format!(
587                    "Read exact context: yarp read {archive_ref} {} {start}:{end}\n",
588                    source.name.as_str()
589                )
590                .as_bytes(),
591            );
592        }
593    }
594    output
595}
596
597fn shrink_search(sources: &mut [SourceSearch]) -> bool {
598    if let Some(source) = sources
599        .iter_mut()
600        .filter(|source| source.before > 0 || source.after > 0)
601        .max_by_key(|source| source.before.saturating_add(source.after))
602    {
603        if source.after >= source.before && source.after > 0 {
604            source.after -= 1;
605        } else {
606            source.before = source.before.saturating_sub(1);
607        }
608        return true;
609    }
610    let displayed = sources
611        .iter()
612        .map(|source| source.displayed_selected.len())
613        .sum::<usize>();
614    if displayed == 0 {
615        return false;
616    }
617    if let Some(source) = sources
618        .iter_mut()
619        .filter(|source| !source.displayed_selected.is_empty())
620        .max_by_key(|source| source.displayed_selected.len())
621    {
622        source.displayed_selected.pop();
623        return true;
624    }
625    false
626}
627
628fn truncate_rendered_line(value: &str, max_bytes: usize) -> String {
629    if value.len() <= max_bytes {
630        return value.to_owned();
631    }
632    let mut end = max_bytes.saturating_sub(3);
633    while !value.is_char_boundary(end) {
634        end = end.saturating_sub(1);
635    }
636    format!("{}...", &value[..end])
637}
638
639#[expect(
640    clippy::naive_bytecount,
641    reason = "recovery output is already bounded below 48 KiB"
642)]
643fn output_line_count(output: &[u8]) -> usize {
644    let newlines = output.iter().filter(|byte| **byte == b'\n').count();
645    newlines + usize::from(!output.is_empty() && !output.ends_with(b"\n"))
646}
647
648fn displayed_groups(source: &SourceSearch) -> Vec<(u64, u64)> {
649    let mut groups = Vec::<(u64, u64)>::new();
650    for selected in &source.displayed_selected {
651        let start = selected.saturating_sub(source.before as u64).max(1);
652        let end = selected
653            .saturating_add(source.after as u64)
654            .min(source.total_lines);
655        if let Some(last) = groups.last_mut()
656            && start <= last.1.saturating_add(1)
657        {
658            last.1 = last.1.max(end);
659        } else {
660            groups.push((start, end));
661        }
662    }
663    groups
664}
665
666const fn completeness_label(value: SourceCompleteness) -> &'static str {
667    match value {
668        SourceCompleteness::Complete => "true",
669        SourceCompleteness::Incomplete => "false",
670        SourceCompleteness::Unknown => "unknown",
671    }
672}
673
674#[derive(Debug)]
675struct ReadRequest {
676    archive_ref: String,
677    source: Option<String>,
678    range: ReadRange,
679}
680
681#[derive(Debug)]
682enum ReadRange {
683    Lines { start: u64, end: u64 },
684    Bytes { start: u64, end: u64 },
685}
686
687fn parse_read(arguments: &[String]) -> Result<ReadRequest, String> {
688    match arguments {
689        [archive_ref, range] => {
690            let (start, end) = parse_range(range, 1)?;
691            Ok(ReadRequest {
692                archive_ref: archive_ref.clone(),
693                source: None,
694                range: ReadRange::Lines { start, end },
695            })
696        }
697        [archive_ref, source, range] => {
698            let (start, end) = parse_range(range, 1)?;
699            Ok(ReadRequest {
700                archive_ref: archive_ref.clone(),
701                source: Some(source.clone()),
702                range: ReadRange::Lines { start, end },
703            })
704        }
705        [archive_ref, source, bytes, range] if bytes == "--bytes" => {
706            let (start, end) = parse_range(range, 0)?;
707            Ok(ReadRequest {
708                archive_ref: archive_ref.clone(),
709                source: Some(source.clone()),
710                range: ReadRange::Bytes { start, end },
711            })
712        }
713        _ => Err("invalid read arguments; expected: yarp read REF [SOURCE] START:END".to_owned()),
714    }
715}
716
717fn parse_range(value: &str, minimum_start: u64) -> Result<(u64, u64), String> {
718    let Some((start, end)) = value.split_once(':') else {
719        return Err("range must have START:END form".to_owned());
720    };
721    let start = parse_u64(start)?;
722    let end = parse_u64(end)?;
723    if start < minimum_start || end < start {
724        return Err("range start and end are invalid".to_owned());
725    }
726    Ok((start, end))
727}
728
729fn parse_u64(value: &str) -> Result<u64, String> {
730    if value.is_empty()
731        || (value.len() > 1 && value.starts_with('0'))
732        || !value.bytes().all(|byte| byte.is_ascii_digit())
733    {
734        return Err(format!("invalid canonical unsigned integer {value:?}"));
735    }
736    value
737        .parse()
738        .map_err(|_| format!("integer {value:?} is outside the supported range"))
739}
740
741fn choose_source(
742    mut sources: Vec<VerifiedSource>,
743    requested: Option<&str>,
744) -> Result<VerifiedSource, String> {
745    if let Some(requested) = requested {
746        let Some(index) = sources
747            .iter()
748            .position(|source| source.name.as_str() == requested)
749        else {
750            let names = sources
751                .iter()
752                .map(|source| source.name.as_str())
753                .collect::<Vec<_>>()
754                .join(", ");
755            return Err(format!(
756                "source {requested:?} is unavailable; choose: {names}"
757            ));
758        };
759        return Ok(sources.swap_remove(index));
760    }
761    if sources.len() != 1 {
762        let names = sources
763            .iter()
764            .map(|source| source.name.as_str())
765            .collect::<Vec<_>>()
766            .join(", ");
767        return Err(format!("source is required; choose: {names}"));
768    }
769    sources
770        .pop()
771        .ok_or_else(|| "archive reference has no readable source".to_owned())
772}
773
774fn read_byte_range(
775    source: &mut VerifiedSource,
776    start: u64,
777    end: u64,
778    limits: QueryLimits,
779) -> Result<Vec<u8>, String> {
780    if end > source.byte_length {
781        return Err(format!(
782            "byte range ends at {end}, but {} has {} bytes",
783            source.name.as_str(),
784            source.byte_length
785        ));
786    }
787    let length = end.saturating_sub(start);
788    if length > limits.max_bytes as u64 {
789        return Err(format!(
790            "exact read is {length} bytes; use a range no larger than {} bytes",
791            limits.max_bytes
792        ));
793    }
794    source
795        .body
796        .seek(SeekFrom::Start(start))
797        .map_err(|error| format!("could not seek archived source: {error}"))?;
798    let length = usize::try_from(length).map_err(|_| "byte range is too large".to_owned())?;
799    let mut output = vec![0_u8; length];
800    source
801        .body
802        .read_exact(&mut output)
803        .map_err(|error| format!("could not read archived byte range: {error}"))?;
804    if output_line_count(&output) > limits.max_lines {
805        return Err(format!(
806            "exact read exceeds the configured {} line limit; use a smaller byte range",
807            limits.max_lines
808        ));
809    }
810    Ok(output)
811}
812
813fn read_line_range(
814    source: &mut VerifiedSource,
815    start: u64,
816    end: u64,
817    limits: QueryLimits,
818) -> Result<Vec<u8>, String> {
819    let requested_lines = end.saturating_sub(start).saturating_add(1);
820    if requested_lines > limits.max_lines as u64 {
821        return Err(format!(
822            "exact read requests {requested_lines} lines; use a range no larger than {} lines",
823            limits.max_lines
824        ));
825    }
826    source
827        .body
828        .seek(SeekFrom::Start(0))
829        .map_err(|error| format!("could not rewind archived source: {error}"))?;
830    let mut output = Vec::new();
831    let mut buffer = vec![0_u8; 64 * 1024];
832    let mut line_number = 1_u64;
833    let mut saw_byte = false;
834    let mut last_was_newline = false;
835    let mut reached_end = false;
836    'read: loop {
837        let count = source
838            .body
839            .read(&mut buffer)
840            .map_err(|error| format!("could not read archived line range: {error}"))?;
841        if count == 0 {
842            break;
843        }
844        for byte in &buffer[..count] {
845            saw_byte = true;
846            last_was_newline = *byte == b'\n';
847            if (start..=end).contains(&line_number) {
848                if output.len() == limits.max_bytes {
849                    return Err(format!(
850                        "exact line read exceeds {} bytes; use yarp read REF {} --bytes START:END",
851                        limits.max_bytes,
852                        source.name.as_str()
853                    ));
854                }
855                output.push(*byte);
856            }
857            if *byte == b'\n' {
858                if line_number == end {
859                    reached_end = true;
860                    break 'read;
861                }
862                line_number = line_number.saturating_add(1);
863            }
864        }
865    }
866    let total_lines = if reached_end {
867        end
868    } else if !saw_byte {
869        0
870    } else if last_was_newline {
871        line_number.saturating_sub(1)
872    } else {
873        line_number
874    };
875    if start > total_lines || (!reached_end && end > total_lines) {
876        return Err(format!(
877            "line range {start}:{end} exceeds {} with {total_lines} lines",
878            source.name.as_str()
879        ));
880    }
881    Ok(output)
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    fn strings(values: &[&str]) -> Vec<String> {
889        values.iter().map(ToString::to_string).collect()
890    }
891
892    #[test]
893    fn parses_search_options_in_any_position() {
894        let options = parse_search(&strings(&[
895            "-i",
896            "yr_0123456789abcdef0123456789abcdef",
897            "error",
898            "-C",
899            "3",
900            "--max-results",
901            "20",
902        ]))
903        .expect("search options");
904        assert!(options.ignore_case);
905        assert_eq!(options.before, 3);
906        assert_eq!(options.after, 3);
907        assert_eq!(options.max_results, 20);
908    }
909
910    #[test]
911    fn supports_short_max_results_and_rejects_old_long_spelling() {
912        let options = parse_search(&strings(&[
913            "yr_0123456789abcdef0123456789abcdef",
914            "error",
915            "-m",
916            "7",
917        ]))
918        .expect("short max-results option");
919        assert_eq!(options.max_results, 7);
920        assert!(
921            parse_search(&strings(&[
922                "yr_0123456789abcdef0123456789abcdef",
923                "error",
924                "--max-count",
925                "7",
926            ]))
927            .is_err()
928        );
929    }
930
931    #[test]
932    fn ignore_case_uses_ascii_matching_without_unicode_features() {
933        let options = parse_search(&strings(&[
934            "yr_0123456789abcdef0123456789abcdef",
935            "error.*code",
936            "-i",
937        ]))
938        .expect("case-insensitive search options");
939        let matcher = compile_matcher(&options).expect("case-insensitive matcher");
940        assert!(matcher.is_match(b"ERROR 42 CODE"));
941        assert!(matcher.is_match("éERROR code".as_bytes()));
942    }
943
944    #[test]
945    fn word_regexp_uses_ascii_boundaries_without_unicode_features() {
946        let options = parse_search(&strings(&[
947            "yr_0123456789abcdef0123456789abcdef",
948            "error",
949            "-w",
950        ]))
951        .expect("word search options");
952        let matcher = compile_matcher(&options).expect("word matcher");
953        assert!(matcher.is_match(b"error"));
954        assert!(matcher.is_match(b"an error!"));
955        assert!(matcher.is_match("éerroré".as_bytes()));
956        assert!(!matcher.is_match(b"terror"));
957        assert!(!matcher.is_match(b"error_code"));
958    }
959
960    #[test]
961    fn rejects_ambiguous_patterns_and_noncanonical_counts() {
962        assert!(
963            parse_search(&strings(&[
964                "yr_0123456789abcdef0123456789abcdef",
965                "one",
966                "-e",
967                "two",
968            ]))
969            .is_err()
970        );
971        assert!(
972            parse_search(&strings(&[
973                "yr_0123456789abcdef0123456789abcdef",
974                "one",
975                "-C",
976                "03",
977            ]))
978            .is_err()
979        );
980    }
981
982    #[test]
983    fn strips_ansi_and_renders_control_bytes_visibly() {
984        let mut stripper = AnsiStripper::new();
985        assert_eq!(
986            normalize_line(b"\x1b[31merror\x1b[0m\x07\n", &mut stripper).expect("line"),
987            "error\\x07"
988        );
989    }
990
991    #[test]
992    fn parses_exact_line_and_byte_ranges() {
993        assert!(matches!(
994            parse_read(&strings(&[
995                "yr_0123456789abcdef0123456789abcdef",
996                "stdout",
997                "1:4"
998            ]))
999            .expect("line read")
1000            .range,
1001            ReadRange::Lines { start: 1, end: 4 }
1002        ));
1003        assert!(matches!(
1004            parse_read(&strings(&[
1005                "yr_0123456789abcdef0123456789abcdef",
1006                "stdout",
1007                "--bytes",
1008                "0:4"
1009            ]))
1010            .expect("byte read")
1011            .range,
1012            ReadRange::Bytes { start: 0, end: 4 }
1013        ));
1014    }
1015}