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
69pub 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
102pub 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}