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