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