Skip to main content

pipedash_core/infrastructure/run_logs/
document.rs

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, &regex, 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}