1use std::borrow::Cow;
2use std::collections::{
3 BTreeMap,
4 HashMap,
5};
6use std::fs::{
7 self,
8 File,
9};
10use std::io::{
11 self,
12 Read,
13 Seek,
14 SeekFrom,
15};
16use std::ops::Range;
17use std::path::{
18 Path,
19 PathBuf,
20};
21use std::sync::{
22 Arc,
23 Mutex,
24 PoisonError,
25};
26use std::thread;
27
28use bytes::Bytes;
29use chrono::{
30 DateTime,
31 SecondsFormat,
32};
33use regex::{
34 Regex,
35 RegexBuilder,
36};
37use thiserror::Error;
38use tokio_util::sync::CancellationToken;
39
40use super::ansi::Span;
41use super::block::{
42 self,
43 BlockRef,
44 DecodedBlock,
45 LineMeta,
46 INDEX_RECORD_BYTES,
47};
48use super::ingest::{
49 BLOCKS_FILE,
50 INDEX_FILE,
51 META_FILE,
52};
53use super::store::{
54 StoredDocMeta,
55 STORED_FORMAT,
56};
57use super::{
58 BLOCK_CACHE_BUDGET_BYTES,
59 BLOCK_MAX_RAW_BYTES,
60 LINE_UNITS_MAX,
61 LINE_UNITS_MIN,
62 PAGE_MAX_LINES,
63 PAGE_MAX_TEXT_BYTES,
64 SEARCH_MATCH_CAP,
65 SEARCH_MAX_QUERY_CHARS,
66 SEARCH_REGEX_SIZE_LIMIT,
67};
68use crate::domain::{
69 DomainError,
70 DomainResult,
71 LogLine,
72 LogLinesPage,
73 LogSearchQuery,
74 LogSearchResult,
75};
76
77const MAX_SEARCH_WORKERS: usize = 4;
78const COMPRESSED_SLACK_BYTES: usize = 1024 * 1024;
79
80#[derive(Debug, Error)]
81pub enum DocError {
82 #[error("Log cache is corrupted")]
83 Corrupt,
84 #[error("Log cache I/O error: {0}")]
85 Io(#[from] io::Error),
86 #[error("Search cancelled")]
87 Cancelled,
88}
89
90impl DocError {
91 pub fn is_missing(&self) -> bool {
92 matches!(self, DocError::Io(e) if e.kind() == io::ErrorKind::NotFound)
93 }
94}
95
96pub fn search_regex(query: &LogSearchQuery) -> DomainResult<Option<Regex>> {
97 if query.text.is_empty() {
98 return Ok(None);
99 }
100 if query.text.chars().nth(SEARCH_MAX_QUERY_CHARS).is_some() {
101 return Err(DomainError::InvalidConfig("Search text is too long".into()));
102 }
103 let pattern = if query.regex {
104 Cow::Borrowed(query.text.as_str())
105 } else {
106 Cow::Owned(regex::escape(&query.text))
107 };
108 RegexBuilder::new(&pattern)
109 .case_insensitive(!query.case_sensitive)
110 .size_limit(SEARCH_REGEX_SIZE_LIMIT)
111 .build()
112 .map(Some)
113 .map_err(|e| DomainError::InvalidConfig(format!("Invalid search pattern: {e}")))
114}
115
116type CacheKey = (PathBuf, u32);
117
118struct CacheEntry {
119 block: Arc<DecodedBlock>,
120 size: usize,
121 tick: u64,
122}
123
124#[derive(Default)]
125struct CacheInner {
126 map: HashMap<CacheKey, CacheEntry>,
127 order: BTreeMap<u64, CacheKey>,
128 tick: u64,
129 total: usize,
130}
131
132pub struct BlockCache {
133 inner: Mutex<CacheInner>,
134 budget: usize,
135}
136
137impl Default for BlockCache {
138 fn default() -> Self {
139 Self::new(BLOCK_CACHE_BUDGET_BYTES)
140 }
141}
142
143impl BlockCache {
144 pub fn new(budget: usize) -> Self {
145 Self {
146 inner: Mutex::new(CacheInner::default()),
147 budget,
148 }
149 }
150
151 fn get(&self, key: &CacheKey) -> Option<Arc<DecodedBlock>> {
152 let mut inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
153 inner.tick += 1;
154 let tick = inner.tick;
155 let entry = inner.map.get_mut(key)?;
156 let old = std::mem::replace(&mut entry.tick, tick);
157 let block = entry.block.clone();
158 inner.order.remove(&old);
159 inner.order.insert(tick, key.clone());
160 Some(block)
161 }
162
163 fn insert(&self, key: CacheKey, block: Arc<DecodedBlock>) {
164 let size = block.text.len()
165 + block.lines.len() * size_of::<LineMeta>()
166 + block.spans.len() * size_of::<Span>();
167 let mut inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
168 inner.tick += 1;
169 let tick = inner.tick;
170 if let Some(old) = inner
171 .map
172 .insert(key.clone(), CacheEntry { block, size, tick })
173 {
174 inner.total -= old.size;
175 inner.order.remove(&old.tick);
176 }
177 inner.order.insert(tick, key);
178 inner.total += size;
179 while inner.total > self.budget && inner.order.len() > 1 {
180 let Some((_, oldest)) = inner.order.pop_first() else {
181 break;
182 };
183 if let Some(entry) = inner.map.remove(&oldest) {
184 inner.total -= entry.size;
185 }
186 }
187 }
188
189 pub fn forget(&self, dir: &Path) {
190 let mut inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
191 let keys: Vec<CacheKey> = inner.map.keys().filter(|k| k.0 == dir).cloned().collect();
192 for key in keys {
193 if let Some(entry) = inner.map.remove(&key) {
194 inner.total -= entry.size;
195 inner.order.remove(&entry.tick);
196 }
197 }
198 }
199}
200
201pub struct Document {
202 dir: PathBuf,
203 meta: Arc<StoredDocMeta>,
204 blocks: Arc<[BlockRef]>,
205}
206
207struct Partial {
208 matches: Vec<(u32, u32, u32)>,
209 total: u64,
210}
211
212impl Document {
213 pub fn open(dir: &Path) -> Result<Self, DocError> {
214 let meta: StoredDocMeta = serde_json::from_slice(&fs::read(dir.join(META_FILE))?)
215 .map_err(|_| DocError::Corrupt)?;
216 if meta.format != STORED_FORMAT {
217 return Err(DocError::Corrupt);
218 }
219 let index = fs::read(dir.join(INDEX_FILE))?;
220 let blocks_len = fs::metadata(dir.join(BLOCKS_FILE))?.len();
221 let (records, rest) = index.as_chunks::<INDEX_RECORD_BYTES>();
222 if !rest.is_empty() {
223 return Err(DocError::Corrupt);
224 }
225 let blocks: Vec<BlockRef> = records
226 .iter()
227 .filter_map(|record| BlockRef::from_bytes(record))
228 .collect();
229 if blocks.is_empty() != (meta.line_count == 0) {
230 return Err(DocError::Corrupt);
231 }
232 let mut previous: Option<u32> = None;
233 for block in &blocks {
234 let ordered = match previous {
235 None => block.first_line == 0,
236 Some(first) => block.first_line > first,
237 };
238 let end = block.offset.checked_add(u64::from(block.compressed_len));
239 if !ordered
240 || block.first_line >= meta.line_count
241 || end.is_none_or(|end| end > blocks_len)
242 || block.compressed_len as usize > BLOCK_MAX_RAW_BYTES + COMPRESSED_SLACK_BYTES
243 {
244 return Err(DocError::Corrupt);
245 }
246 previous = Some(block.first_line);
247 }
248 Ok(Self {
249 dir: dir.to_path_buf(),
250 meta: Arc::new(meta),
251 blocks: blocks.into(),
252 })
253 }
254
255 pub(super) fn dir(&self) -> &Path {
256 &self.dir
257 }
258
259 pub fn meta(&self) -> &StoredDocMeta {
260 &self.meta
261 }
262
263 pub fn block_count(&self) -> usize {
264 self.blocks.len()
265 }
266
267 fn block_lines(&self, index: usize) -> u32 {
268 let next = self
269 .blocks
270 .get(index + 1)
271 .map_or(self.meta.line_count, |b| b.first_line);
272 next - self.blocks[index].first_line
273 }
274
275 fn load_block(
276 &self, file: &mut Option<File>, scratch: &mut Vec<u8>, index: usize,
277 ) -> Result<DecodedBlock, DocError> {
278 let block_ref = self.blocks[index];
279 let file = match file {
280 Some(file) => file,
281 None => file.insert(File::open(self.dir.join(BLOCKS_FILE))?),
282 };
283 file.seek(SeekFrom::Start(block_ref.offset))?;
284 scratch.resize(block_ref.compressed_len as usize, 0);
285 file.read_exact(scratch).map_err(|e| match e.kind() {
286 io::ErrorKind::UnexpectedEof => DocError::Corrupt,
287 _ => DocError::Io(e),
288 })?;
289 let decoded =
290 block::decode(scratch, self.meta.styles.len()).map_err(|_| DocError::Corrupt)?;
291 if decoded.lines.len() != self.block_lines(index) as usize {
292 return Err(DocError::Corrupt);
293 }
294 Ok(decoded)
295 }
296
297 fn cached_block(
298 &self, cache: &BlockCache, file: &mut Option<File>, scratch: &mut Vec<u8>, index: usize,
299 ) -> Result<Arc<DecodedBlock>, DocError> {
300 let key = (self.dir.clone(), index as u32);
301 if let Some(block) = cache.get(&key) {
302 return Ok(block);
303 }
304 let block = Arc::new(self.load_block(file, scratch, index)?);
305 cache.insert(key, block.clone());
306 Ok(block)
307 }
308
309 pub fn read_lines(
310 &self, cache: &BlockCache, start: u32, count: u32, max_units: u32,
311 ) -> Result<LogLinesPage, DocError> {
312 let count = count.min(PAGE_MAX_LINES);
313 let max_units = max_units.clamp(LINE_UNITS_MIN, LINE_UNITS_MAX) as usize;
314 let line_count = self.meta.line_count;
315 let mut lines = Vec::new();
316 if start >= line_count || count == 0 {
317 return Ok(LogLinesPage { start, lines });
318 }
319 let end = start.saturating_add(count).min(line_count);
320 let mut index = self
321 .blocks
322 .partition_point(|b| b.first_line <= start)
323 .checked_sub(1)
324 .ok_or(DocError::Corrupt)?;
325 let mut file = None;
326 let mut scratch = Vec::new();
327 let mut next = start;
328 let mut page_bytes = 0usize;
329 'blocks: while next < end && index < self.blocks.len() {
330 let block = self.cached_block(cache, &mut file, &mut scratch, index)?;
331 let skip = (next - self.blocks[index].first_line) as usize;
332 for meta in block.lines.iter().skip(skip) {
333 if next >= end {
334 break 'blocks;
335 }
336 let line = render_line(&block, meta, max_units);
337 page_bytes += line.text.len();
338 lines.push(line);
339 next += 1;
340 if page_bytes > PAGE_MAX_TEXT_BYTES {
341 break 'blocks;
342 }
343 }
344 index += 1;
345 }
346 Ok(LogLinesPage { start, lines })
347 }
348
349 pub fn search(
350 &self, regex: &Regex, cancel: &CancellationToken,
351 ) -> Result<LogSearchResult, DocError> {
352 let total_blocks = self.blocks.len();
353 if total_blocks == 0 {
354 return Ok(LogSearchResult::default());
355 }
356 let workers = thread::available_parallelism()
357 .map_or(1, std::num::NonZero::get)
358 .min(MAX_SEARCH_WORKERS)
359 .min(total_blocks);
360 let per_worker = total_blocks.div_ceil(workers);
361 let partials: Vec<Result<Partial, DocError>> = thread::scope(|scope| {
362 let handles: Vec<_> = (0..workers)
363 .map(|worker| {
364 let range = (worker * per_worker).min(total_blocks)
365 ..((worker + 1) * per_worker).min(total_blocks);
366 let regex = regex.clone();
367 scope.spawn(move || self.search_range(range, ®ex, cancel))
368 })
369 .collect();
370 handles
371 .into_iter()
372 .map(|handle| {
373 handle
374 .join()
375 .unwrap_or_else(|_| Err(io::Error::other("Search worker panicked").into()))
376 })
377 .collect()
378 });
379
380 let mut matches = Vec::new();
381 let mut total = 0u64;
382 for partial in partials {
383 let partial = partial?;
384 total += partial.total;
385 let room = SEARCH_MATCH_CAP.saturating_sub(matches.len());
386 matches.extend(partial.matches.into_iter().take(room));
387 }
388 Ok(LogSearchResult {
389 matches,
390 total,
391 capped: total > SEARCH_MATCH_CAP as u64,
392 })
393 }
394
395 fn search_range(
396 &self, range: Range<usize>, regex: &Regex, cancel: &CancellationToken,
397 ) -> Result<Partial, DocError> {
398 let mut partial = Partial {
399 matches: Vec::new(),
400 total: 0,
401 };
402 let mut file = None;
403 let mut scratch = Vec::new();
404 for index in range {
405 if cancel.is_cancelled() {
406 return Err(DocError::Cancelled);
407 }
408 let block = self.load_block(&mut file, &mut scratch, index)?;
409 let first_line = self.blocks[index].first_line;
410 for (offset, meta) in block.lines.iter().enumerate() {
411 search_line(
412 regex,
413 block.line_text(meta),
414 first_line + offset as u32,
415 &mut partial,
416 );
417 }
418 }
419 Ok(partial)
420 }
421
422 pub fn text_block(&self, index: usize) -> Result<Bytes, DocError> {
423 if index >= self.blocks.len() {
424 return Err(io::Error::new(io::ErrorKind::InvalidInput, "No such log block").into());
425 }
426 let block = self.load_block(&mut None, &mut Vec::new(), index)?;
427 let mut out = String::with_capacity(block.text.len() + block.lines.len() * 25);
428 for meta in &block.lines {
429 if let Some(stamp) = meta.ts.and_then(DateTime::from_timestamp_millis) {
430 out.push_str(&stamp.to_rfc3339_opts(SecondsFormat::Millis, true));
431 out.push(' ');
432 }
433 out.push_str(block.line_text(meta));
434 out.push('\n');
435 }
436 Ok(Bytes::from(out.into_bytes()))
437 }
438}
439
440fn utf16_len(text: &str) -> u32 {
441 text.encode_utf16().count() as u32
442}
443
444fn search_line(regex: &Regex, text: &str, line: u32, partial: &mut Partial) {
445 let mut ascii: Option<bool> = None;
446 let mut last_byte = 0usize;
447 let mut last_units = 0u32;
448 for found in regex.find_iter(text) {
449 if found.is_empty() {
450 continue;
451 }
452 partial.total += 1;
453 if partial.matches.len() >= SEARCH_MATCH_CAP {
454 continue;
455 }
456 let ascii = *ascii.get_or_insert_with(|| text.is_ascii());
457 let (start, end) = if ascii {
458 (found.start() as u32, found.end() as u32)
459 } else {
460 let start = last_units + utf16_len(&text[last_byte..found.start()]);
461 let end = start + utf16_len(found.as_str());
462 last_byte = found.end();
463 last_units = end;
464 (start, end)
465 };
466 partial.matches.push((line, start, end));
467 }
468}
469
470fn render_line(block: &DecodedBlock, meta: &LineMeta, max_units: usize) -> LogLine {
471 let text = block.line_text(meta);
472 let spans = block.line_spans(meta);
473 let ascii = text.is_ascii();
474 let (kept_end, omitted) = if text.len() <= max_units {
475 (text.len(), 0)
476 } else if ascii {
477 (max_units, text.len() - max_units)
478 } else {
479 let mut units = 0usize;
480 let mut cut = text.len();
481 for (index, c) in text.char_indices() {
482 if units + c.len_utf16() > max_units {
483 cut = index;
484 break;
485 }
486 units += c.len_utf16();
487 }
488 (cut, utf16_len(&text[cut..]) as usize)
489 };
490 let kept = &text[..kept_end];
491 let table: Vec<u32> = if ascii || spans.is_empty() {
492 Vec::new()
493 } else {
494 let mut table = vec![0u32; kept.len() + 1];
495 let mut units = 0u32;
496 for (index, c) in kept.char_indices() {
497 table[index] = units;
498 units += c.len_utf16() as u32;
499 }
500 table[kept.len()] = units;
501 table
502 };
503 let to_units = |byte: usize| if ascii { byte as u32 } else { table[byte] };
504 let spans = spans
505 .iter()
506 .filter(|s| (s.start as usize) < kept_end)
507 .map(|s| {
508 let end = (s.start as usize + s.len as usize).min(kept_end);
509 (to_units(s.start as usize), to_units(end), s.style)
510 })
511 .collect();
512 LogLine {
513 text: kept.to_string(),
514 kind: meta.kind,
515 ts: meta.ts,
516 spans,
517 omitted: omitted as u32,
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::super::testing::{
524 cache,
525 ingest,
526 small_limits,
527 };
528 use super::super::{
529 BlockLimits,
530 MAX_LINE_BYTES,
531 };
532 use super::*;
533 use crate::domain::LineKind;
534
535 fn sample() -> (String, Vec<(String, LineKind)>) {
536 let mut input = String::new();
537 let mut expected = Vec::new();
538 for i in 0..40 {
539 let stamp = format!("2026-01-01T10:00:{i:02}.000Z ");
540 let (raw, emitted) = match i {
541 2 => (
542 "##[group]Outer".to_string(),
543 Some(("Outer".to_string(), LineKind::Group)),
544 ),
545 3 => (
546 "##[group]Inner".to_string(),
547 Some(("Inner".to_string(), LineKind::Group)),
548 ),
549 5 | 6 => ("##[endgroup]".to_string(), None),
550 10 | 20 | 30 => (
551 format!("##[error]boom {i}"),
552 Some((format!("boom {i}"), LineKind::Error)),
553 ),
554 21 => (
555 "\u{1b}[31mred \u{e9}\u{1f600}\u{1b}[0m tail".to_string(),
556 Some(("red \u{e9}\u{1f600} tail".to_string(), LineKind::Plain)),
557 ),
558 22 => (
559 "a\rb\rfinal".to_string(),
560 Some(("final".to_string(), LineKind::Plain)),
561 ),
562 35 => (
563 "##[group]Unclosed".to_string(),
564 Some(("Unclosed".to_string(), LineKind::Group)),
565 ),
566 _ => {
567 let text = format!("plain line {i} \u{fc}n\u{ef}code");
568 (text.clone(), Some((text, LineKind::Plain)))
569 }
570 };
571 input.push_str(&stamp);
572 input.push_str(&raw);
573 input.push_str(if i % 2 == 0 { "\r\n" } else { "\n" });
574 expected.extend(emitted);
575 }
576 (input, expected)
577 }
578
579 fn doc_from(lines: &[&str], limits: BlockLimits) -> (tempfile::TempDir, Document) {
580 let dir = tempfile::tempdir().expect("tempdir");
581 let data: String = lines.iter().map(|l| format!("{l}\n")).collect();
582 ingest(dir.path(), data.as_bytes(), 1 << 16, Vec::new(), limits);
583 let doc = Document::open(dir.path()).expect("open");
584 (dir, doc)
585 }
586
587 fn query(text: &str, regex: bool, case_sensitive: bool) -> Regex {
588 search_regex(&LogSearchQuery {
589 text: text.into(),
590 regex,
591 case_sensitive,
592 })
593 .expect("pattern")
594 .expect("non-empty")
595 }
596
597 fn run(doc: &Document, regex: &Regex) -> LogSearchResult {
598 doc.search(regex, &CancellationToken::new())
599 .expect("search")
600 }
601
602 #[test]
603 fn run_logs_document_reads_exact_text_across_blocks() {
604 let (input, expected) = sample();
605 let dir = tempfile::tempdir().expect("tempdir");
606 let outcome = ingest(dir.path(), input.as_bytes(), 7, Vec::new(), small_limits());
607 assert!(!outcome.truncated);
608 let meta = &outcome.meta;
609 assert_eq!(meta.line_count as usize, expected.len());
610 assert_eq!(meta.line_count, 38);
611 assert_eq!(meta.error_total, 3);
612 let error_lines: Vec<u32> = expected
613 .iter()
614 .enumerate()
615 .filter(|(_, (_, kind))| *kind == LineKind::Error)
616 .map(|(i, _)| i as u32)
617 .collect();
618 assert_eq!(meta.error_lines, error_lines);
619 assert_eq!(meta.sections.len(), 3);
620 let unclosed = meta
621 .sections
622 .iter()
623 .find(|s| s.title == "Unclosed")
624 .expect("unclosed");
625 assert_eq!(unclosed.end_line, 38);
626 let inner = meta
627 .sections
628 .iter()
629 .find(|s| s.title == "Inner")
630 .expect("inner");
631 assert_eq!(
632 inner.parent,
633 meta.sections
634 .iter()
635 .find(|s| s.title == "Outer")
636 .map(|s| s.id)
637 );
638
639 let doc = Document::open(dir.path()).expect("open");
640 assert!(doc.block_count() >= 9);
641 let cache = cache();
642 let page = doc.read_lines(&cache, 10, 15, 16384).expect("page");
643 assert_eq!(page.start, 10);
644 assert_eq!(page.lines.len(), 15);
645 for (line, (text, kind)) in page.lines.iter().zip(&expected[10..25]) {
646 assert_eq!(&line.text, text);
647 assert_eq!(&line.kind, kind);
648 assert!(line.ts.is_some() || text == "final");
649 }
650 let all = doc.read_lines(&cache, 0, 5000, 16384).expect("all");
651 assert_eq!(all.lines.len(), 38);
652 for (line, (text, kind)) in all.lines.iter().zip(&expected) {
653 assert_eq!((&line.text, &line.kind), (text, kind));
654 }
655 let red = expected
656 .iter()
657 .position(|(t, _)| t.starts_with("red"))
658 .expect("red");
659 assert_eq!(all.lines[red].spans, vec![(0, 7, 0)]);
660 assert!(doc
661 .read_lines(&cache, 38, 10, 4096)
662 .expect("past end")
663 .lines
664 .is_empty());
665 }
666
667 #[test]
668 fn run_logs_document_cuts_long_lines_by_utf16_units() {
669 let ascii = "x".repeat(1000);
670 let wide = "\u{e9}".repeat(1000);
671 let emoji = format!("\u{1b}[31m{}\u{1b}[0m", "\u{1f600}".repeat(300));
672 let lines = [ascii.as_str(), wide.as_str(), emoji.as_str()];
673 let (_dir, doc) = doc_from(&lines, BlockLimits::default());
674 let page = doc.read_lines(&cache(), 0, 3, 256).expect("page");
675 assert_eq!(page.lines[0].text.len(), 256);
676 assert_eq!(page.lines[0].omitted, 744);
677 assert_eq!(page.lines[1].text.chars().count(), 256);
678 assert_eq!(page.lines[1].omitted, 744);
679 assert_eq!(page.lines[2].text.chars().count(), 128);
680 assert_eq!(page.lines[2].omitted, 600 - 256);
681 assert_eq!(page.lines[2].spans, vec![(0, 256, 0)]);
682 let full = doc.read_lines(&cache(), 0, 1, 1 << 20).expect("full");
683 assert_eq!(full.lines[0].omitted, 0);
684 }
685
686 #[test]
687 fn run_logs_document_splits_giant_line_on_char_boundaries() {
688 let giant = "\u{20ac}".repeat(1_048_576);
689 let dir = tempfile::tempdir().expect("tempdir");
690 let outcome = ingest(
691 dir.path(),
692 giant.as_bytes(),
693 1 << 16,
694 Vec::new(),
695 BlockLimits::default(),
696 );
697 assert!(outcome.meta.line_count >= 3);
698 let doc = Document::open(dir.path()).expect("open");
699 let cache = cache();
700 let mut joined = String::new();
701 let mut start = 0;
702 while start < outcome.meta.line_count {
703 let page = doc.read_lines(&cache, start, 10, 1 << 20).expect("page");
704 for line in &page.lines {
705 assert!(line.text.len() <= MAX_LINE_BYTES);
706 assert_eq!(line.omitted, 0);
707 joined.push_str(&line.text);
708 }
709 start += page.lines.len() as u32;
710 }
711 assert_eq!(joined, giant);
712 }
713
714 #[test]
715 fn run_logs_document_truncates_at_text_limit() {
716 let lines: Vec<String> = (0..50).map(|_| "0123456789".to_string()).collect();
717 let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
718 let limits = BlockLimits {
719 max_doc_text_bytes: 100,
720 ..small_limits()
721 };
722 let (dir, doc) = doc_from(&refs, limits);
723 assert!(doc.meta().truncated);
724 assert_eq!(doc.meta().line_count, 9);
725 assert_eq!(doc.meta().text_bytes, 99);
726 let page = doc.read_lines(&cache(), 0, 100, 4096).expect("page");
727 assert_eq!(page.lines.len(), 9);
728 drop(dir);
729 }
730
731 #[test]
732 fn run_logs_document_empty_input_is_readable() {
733 let dir = tempfile::tempdir().expect("tempdir");
734 let outcome = ingest(dir.path(), b"", 16, Vec::new(), BlockLimits::default());
735 assert_eq!(outcome.meta.line_count, 0);
736 let doc = Document::open(dir.path()).expect("open");
737 assert_eq!(doc.block_count(), 0);
738 assert!(doc
739 .read_lines(&cache(), 0, 10, 4096)
740 .expect("page")
741 .lines
742 .is_empty());
743 assert_eq!(run_empty(&doc), LogSearchResult::default());
744 }
745
746 fn run_empty(doc: &Document) -> LogSearchResult {
747 run(doc, &query("x", false, true))
748 }
749
750 #[test]
751 fn run_logs_document_rejects_corrupt_files() {
752 let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
753 let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
754 let (dir, _) = doc_from(&refs, small_limits());
755 let index_path = dir.path().join(INDEX_FILE);
756 let index = fs::read(&index_path).expect("index");
757 let blocks_path = dir.path().join(BLOCKS_FILE);
758 let blocks = fs::read(&blocks_path).expect("blocks");
759
760 let mut longer = index.clone();
761 longer.push(0);
762 fs::write(&index_path, longer).expect("write");
763 assert!(matches!(Document::open(dir.path()), Err(DocError::Corrupt)));
764
765 let mut shifted = index.clone();
766 shifted[12] = 1;
767 fs::write(&index_path, shifted).expect("write");
768 assert!(matches!(Document::open(dir.path()), Err(DocError::Corrupt)));
769
770 fs::write(&index_path, &index[..INDEX_RECORD_BYTES]).expect("write");
771 let one_block = Document::open(dir.path()).expect("open");
772 assert_eq!(one_block.block_count(), 1);
773 fs::write(&index_path, &index).expect("write");
774
775 fs::write(&blocks_path, &blocks[..blocks.len() - 1]).expect("write");
776 assert!(matches!(Document::open(dir.path()), Err(DocError::Corrupt)));
777
778 fs::write(&blocks_path, vec![0u8; blocks.len()]).expect("write");
779 let doc = Document::open(dir.path()).expect("open");
780 assert!(matches!(
781 doc.read_lines(&cache(), 0, 5, 4096),
782 Err(DocError::Corrupt)
783 ));
784 assert!(matches!(doc.text_block(0), Err(DocError::Corrupt)));
785 }
786
787 #[test]
788 fn run_logs_search_matches_in_order_across_blocks() {
789 let lines = [
790 "start",
791 "an Error here",
792 "x",
793 "y",
794 "z",
795 "error again",
796 "x",
797 "y",
798 "z",
799 "ERROR end",
800 "x",
801 "y",
802 ];
803 let (_dir, doc) = doc_from(&lines, small_limits());
804 assert!(doc.block_count() >= 3);
805 let found = run(&doc, &query("error", false, false));
806 assert_eq!(found.matches, vec![(1, 3, 8), (5, 0, 5), (9, 0, 5)]);
807 assert_eq!((found.total, found.capped), (3, false));
808 let sensitive = run(&doc, &query("error", false, true));
809 assert_eq!(sensitive.matches, vec![(5, 0, 5)]);
810 }
811
812 #[test]
813 fn run_logs_search_regex_and_anchors() {
814 let (_dir, doc) = doc_from(
815 &["err", "error", "xx", "boom", "boomerang", "a boom", "boom"],
816 small_limits(),
817 );
818 let found = run(&doc, &query("err(or)?", true, true));
819 assert_eq!(found.matches, vec![(0, 0, 3), (1, 0, 5)]);
820 let exact = run(&doc, &query("^boom$", true, true));
821 assert_eq!(exact.matches, vec![(3, 0, 4), (6, 0, 4)]);
822 let empties = run(&doc, &query("x*", true, true));
823 assert_eq!(empties.matches, vec![(2, 0, 2)]);
824 }
825
826 #[test]
827 fn run_logs_search_rejects_bad_patterns() {
828 let bad = |text: &str, regex: bool| {
829 search_regex(&LogSearchQuery {
830 text: text.into(),
831 regex,
832 case_sensitive: true,
833 })
834 };
835 assert!(
836 matches!(bad("(", true), Err(DomainError::InvalidConfig(m)) if m.starts_with("Invalid search pattern"))
837 );
838 assert!(matches!(
839 bad(r"(?-u:\xA9)", true),
840 Err(DomainError::InvalidConfig(_))
841 ));
842 assert!(bad("(", false).expect("literal").is_some());
843 assert!(bad("", true).expect("empty").is_none());
844 let long = "a".repeat(SEARCH_MAX_QUERY_CHARS + 1);
845 assert!(
846 matches!(bad(&long, false), Err(DomainError::InvalidConfig(m)) if m == "Search text is too long")
847 );
848 assert!(bad(&long[1..], false).is_ok());
849 }
850
851 #[test]
852 fn run_logs_search_reports_utf16_offsets() {
853 let (_dir, doc) = doc_from(&["a\u{e9}\u{1f600}b b", "\u{1f600}"], small_limits());
854 assert_eq!(
855 run(&doc, &query("b", false, true)).matches,
856 vec![(0, 4, 5), (0, 6, 7)]
857 );
858 assert_eq!(
859 run(&doc, &query("\u{1f600}", false, true)).matches,
860 vec![(0, 2, 4), (1, 0, 2)]
861 );
862 assert_eq!(
863 run(&doc, &query("\u{e9}", false, true)).matches,
864 vec![(0, 1, 2)]
865 );
866 }
867
868 #[test]
869 fn run_logs_search_is_independent_of_block_size() {
870 let lines: Vec<String> = (0..100)
871 .map(|i| {
872 if i % 7 == 0 {
873 format!("caf\u{e9} {i} match")
874 } else {
875 format!("noise {i}")
876 }
877 })
878 .collect();
879 let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
880 let (_a, small) = doc_from(&refs, small_limits());
881 let (_b, large) = doc_from(&refs, BlockLimits::default());
882 assert!(small.block_count() > large.block_count());
883 let pattern = query("match|noise 9", true, false);
884 assert_eq!(run(&small, &pattern), run(&large, &pattern));
885 assert_eq!(run(&small, &pattern).total, 24);
886 }
887
888 #[test]
889 fn run_logs_search_caps_matches_but_counts_all() {
890 let lines = vec!["x"; 12_000];
891 let (_dir, doc) = doc_from(&lines, BlockLimits::default());
892 let found = run(&doc, &query("x", false, true));
893 assert_eq!(found.matches.len(), SEARCH_MATCH_CAP);
894 assert_eq!(found.total, 12_000);
895 assert!(found.capped);
896 assert!(found.matches.windows(2).all(|w| w[0].0 < w[1].0));
897 }
898
899 #[test]
900 fn run_logs_search_honours_cancellation() {
901 let (_dir, doc) = doc_from(&["a", "b", "c", "d", "e"], small_limits());
902 let token = CancellationToken::new();
903 token.cancel();
904 assert!(matches!(
905 doc.search(&query("a", false, true), &token),
906 Err(DocError::Cancelled)
907 ));
908 }
909
910 #[test]
911 fn run_logs_document_text_block_renders_timestamps() {
912 let input = "2026-01-01T10:00:00.250Z hello\nno stamp\n";
913 let dir = tempfile::tempdir().expect("tempdir");
914 ingest(
915 dir.path(),
916 input.as_bytes(),
917 5,
918 Vec::new(),
919 BlockLimits::default(),
920 );
921 let doc = Document::open(dir.path()).expect("open");
922 let text = doc.text_block(0).expect("block");
923 assert_eq!(&text[..], b"2026-01-01T10:00:00.250Z hello\nno stamp\n");
924 assert!(doc.text_block(1).is_err());
925 }
926}