1use std::collections::{HashSet, VecDeque};
39use std::sync::mpsc::{Receiver, TryRecvError};
40use std::time::Duration;
41
42use rich::cells::cell_len;
43use rich::{Segment, Style};
44use rich_ext::event::{Severity, Value};
45use rich_ext::log_stream::{
46 Assembler, LEVEL_KEYS, MESSAGE_KEYS, Predicate, Record, TIME_KEYS, TimeMode, format_offset,
47};
48
49use crate::component::{Component, Context, Flow, View};
50use crate::components::{Theme, fit, plain, text};
51use crate::event::{Event, KeyCode};
52use crate::keymap::{Keymap, keys};
53use crate::kit::TextBuffer;
54use crate::policy::{LineIo, NotInteractive};
55
56#[derive(Clone, Debug, PartialEq)]
58pub enum LogEvent {
59 Record(Record),
61 Continuation(String),
64}
65
66impl From<Record> for LogEvent {
81 fn from(record: Record) -> LogEvent {
82 LogEvent::Record(record)
83 }
84}
85
86pub fn log_view_keymap() -> Keymap {
88 Keymap::new("log-view")
89 .bind("quit", keys("q escape"), "quit")
90 .bind("filter", keys("/"), "filter")
91 .bind("pause", keys("space"), "pause or follow again")
92 .bind("expand", keys("enter"), "show the record's fields")
93 .bind("up", keys("up k"), "previous record")
94 .bind("down", keys("down j"), "next record")
95 .bind("page-up", keys("pageup"), "page up")
96 .bind("page-down", keys("pagedown"), "page down")
97 .bind("top", keys("home g"), "first record")
98 .bind("bottom", keys("end G"), "last record, and follow")
99}
100
101pub fn log_filter_keymap() -> Keymap {
104 Keymap::new("log-filter")
105 .bind("apply", keys("enter"), "keep the filter")
106 .bind("clear", keys("escape"), "clear the filter")
107 .bind("left", keys("left"), "move left")
108 .bind("right", keys("right"), "move right")
109 .bind("home", keys("home ctrl+a"), "go to the start")
110 .bind("end", keys("end ctrl+e"), "go to the end")
111 .bind("delete-to-start", keys("ctrl+u"), "delete to the start")
112 .bind("delete-word", keys("ctrl+w"), "delete a word")
113 .bind("backspace", keys("backspace"), "delete back")
114 .bind("delete", keys("delete"), "delete forward")
115}
116
117struct Entry {
118 record: Record,
119 search: String,
122 more: usize,
125}
126
127#[derive(Clone, Copy, Debug)]
129struct Row {
130 entry: usize,
131 count: usize,
132}
133
134enum Term {
136 Field(Predicate),
137 Text(String),
138}
139
140#[must_use = "a component does nothing until it is run or mounted"]
142pub struct LogView {
143 entries: VecDeque<Entry>,
146 first: usize,
147 rows: Vec<Row>,
148 source: Option<Receiver<LogEvent>>,
149 filter: TextBuffer,
150 typing: bool,
151 terms: Vec<Term>,
152 cursor: usize,
153 offset: usize,
154 following: bool,
155 paused: bool,
156 unseen: usize,
158 expanded: HashSet<usize>,
159 expand_all: bool,
160 collapse: bool,
161 time: TimeMode,
162 origin: Option<i64>,
163 limit: usize,
164 dropped: usize,
165 keymap: Keymap,
166 filter_keymap: Keymap,
167 theme: Theme,
168 tick: Duration,
169}
170
171impl Default for LogView {
172 fn default() -> LogView {
173 LogView::new()
174 }
175}
176
177const DRAIN: usize = 20_000;
180
181impl LogView {
182 pub const LIMIT: usize = 200_000;
184
185 pub fn new() -> LogView {
187 LogView {
188 entries: VecDeque::new(),
189 first: 0,
190 rows: Vec::new(),
191 source: None,
192 filter: TextBuffer::new(),
193 typing: false,
194 terms: Vec::new(),
195 cursor: 0,
196 offset: 0,
197 following: true,
198 paused: false,
199 unseen: 0,
200 expanded: HashSet::new(),
201 expand_all: false,
202 collapse: true,
203 time: TimeMode::Absolute,
204 origin: None,
205 limit: LogView::LIMIT,
206 dropped: 0,
207 keymap: log_view_keymap(),
208 filter_keymap: log_filter_keymap(),
209 theme: Theme::default(),
210 tick: Duration::from_millis(100),
211 }
212 }
213
214 pub fn records(mut self, records: impl IntoIterator<Item = Record>) -> LogView {
216 for record in records {
217 self.push(record);
218 }
219 self
220 }
221
222 pub fn source(mut self, source: Receiver<LogEvent>) -> LogView {
225 self.source = Some(source);
226 self
227 }
228
229 pub fn collapse(mut self, on: bool) -> LogView {
231 self.collapse = on;
232 self.rebuild();
233 self
234 }
235
236 pub fn time(mut self, mode: TimeMode) -> LogView {
238 self.time = mode;
239 self
240 }
241
242 pub fn expand_all(mut self, on: bool) -> LogView {
244 self.expand_all = on;
245 self
246 }
247
248 pub fn filter(mut self, filter: impl Into<String>) -> LogView {
250 self.filter.set_text(filter);
251 self.refilter();
252 self
253 }
254
255 pub fn limit(mut self, records: usize) -> LogView {
258 self.limit = records.max(1);
259 self
260 }
261
262 pub fn theme(mut self, theme: Theme) -> LogView {
263 self.theme = theme;
264 self
265 }
266
267 pub fn len(&self) -> usize {
269 self.entries.len()
270 }
271
272 pub fn is_empty(&self) -> bool {
273 self.entries.is_empty()
274 }
275
276 pub fn rows(&self) -> usize {
278 self.rows.len()
279 }
280
281 pub fn dropped(&self) -> usize {
283 self.dropped
284 }
285
286 pub fn is_following(&self) -> bool {
288 self.following
289 }
290
291 pub fn is_paused(&self) -> bool {
292 self.paused
293 }
294
295 pub fn filter_text(&self) -> &str {
297 self.filter.text()
298 }
299
300 pub fn push(&mut self, record: Record) {
302 if self.origin.is_none() {
303 self.origin = record.instant();
304 }
305 let search = search_text(&record);
306 let entry = self.first + self.entries.len();
307 self.entries.push_back(Entry {
308 record,
309 search,
310 more: 0,
311 });
312 if self.matches(entry) {
313 self.add_row(entry);
314 }
315 if self.entries.len() > self.limit + self.limit / 10 {
316 self.trim();
317 }
318 }
319
320 pub fn push_continuation(&mut self, line: String) {
324 let Some(last) = self.entries.back_mut() else {
325 return;
326 };
327 if last.record.continuation.len() >= Assembler::CONTINUATION_LIMIT {
328 last.more += 1;
329 return;
330 }
331 last.search.push('\n');
332 last.search.push_str(&line.to_lowercase());
333 last.record.continuation.push(line);
334 }
335
336 fn trim(&mut self) {
338 let excess = self.entries.len().saturating_sub(self.limit);
339 self.entries.drain(..excess);
340 self.first += excess;
341 self.dropped += excess;
342 self.expanded.retain(|entry| *entry >= self.first);
343 let cursor_entry = self.rows.get(self.cursor).map(|row| row.entry);
344 self.rebuild();
345 self.cursor = cursor_entry
346 .and_then(|entry| self.rows.iter().position(|row| row.entry >= entry))
347 .unwrap_or(self.rows.len().saturating_sub(1));
348 self.offset = self.offset.min(self.cursor);
349 }
350
351 fn entry(&self, entry: usize) -> &Entry {
352 &self.entries[entry - self.first]
353 }
354
355 fn add_row(&mut self, entry: usize) {
356 if self.collapse
357 && let Some(last) = self.rows.last().map(|row| row.entry)
358 {
359 let repeats = self.entry(last).record.same_as(&self.entry(entry).record);
360 if repeats {
361 if let Some(row) = self.rows.last_mut() {
362 row.count += 1;
363 }
364 return;
365 }
366 }
367 self.rows.push(Row { entry, count: 1 });
368 if self.following {
369 self.cursor = self.rows.len() - 1;
370 } else {
371 self.unseen += 1;
372 }
373 }
374
375 fn matches(&self, entry: usize) -> bool {
376 let entry = self.entry(entry);
377 self.terms.iter().all(|term| match term {
378 Term::Field(predicate) => predicate.matches(&entry.record),
379 Term::Text(word) => entry.search.contains(word.as_str()),
380 })
381 }
382
383 fn rebuild(&mut self) {
385 let following = self.following;
386 self.rows.clear();
387 let unseen = self.unseen;
388 self.following = true;
389 for entry in self.first..self.first + self.entries.len() {
390 if self.matches(entry) {
391 self.add_row(entry);
392 }
393 }
394 self.following = following;
395 self.unseen = unseen;
396 }
397
398 fn refilter(&mut self) {
400 let cursor_entry = self.rows.get(self.cursor).map(|row| row.entry);
401 self.terms = words(self.filter.text())
402 .into_iter()
403 .map(|word| {
404 let field = word.contains(['=', '<', '>', '~']) || word.starts_with('!');
405 match Predicate::parse(&word) {
406 Ok(predicate) if field => Term::Field(predicate),
407 _ => Term::Text(word.to_lowercase()),
408 }
409 })
410 .collect();
411 self.rebuild();
412 self.cursor = if self.following {
413 self.rows.len().saturating_sub(1)
414 } else {
415 cursor_entry
416 .and_then(|entry| self.rows.iter().position(|row| row.entry >= entry))
417 .unwrap_or(self.rows.len().saturating_sub(1))
418 };
419 self.offset = self.offset.min(self.cursor);
420 }
421
422 fn drain(&mut self) {
424 let Some(source) = &self.source else {
425 return;
426 };
427 let mut events = Vec::new();
428 let mut done = false;
429 while events.len() < DRAIN {
430 match source.try_recv() {
431 Ok(event) => events.push(event),
432 Err(TryRecvError::Empty) => break,
433 Err(TryRecvError::Disconnected) => {
434 done = true;
435 break;
436 }
437 }
438 }
439 if done {
440 self.source = None;
441 }
442 for event in events {
443 match event {
444 LogEvent::Record(record) => self.push(record),
445 LogEvent::Continuation(line) => self.push_continuation(line),
446 }
447 }
448 }
449
450 fn is_expanded(&self, entry: usize) -> bool {
451 self.expand_all != self.expanded.contains(&entry)
452 }
453
454 fn height(&self, row: &Row) -> usize {
456 1 + if self.is_expanded(row.entry) {
457 detail_count(self.entry(row.entry))
458 } else {
459 0
460 }
461 }
462
463 fn bottom_offset(&self, page: usize) -> usize {
465 let mut used = 0;
466 let mut offset = self.rows.len();
467 while offset > 0 {
468 let height = self.height(&self.rows[offset - 1]);
469 if used + height > page && used > 0 {
470 break;
471 }
472 used += height;
473 offset -= 1;
474 }
475 offset
476 }
477
478 fn top(&self, page: usize) -> usize {
480 if self.following {
481 self.bottom_offset(page)
482 } else {
483 self.offset.min(self.rows.len().saturating_sub(1))
484 }
485 }
486
487 fn show_cursor(&mut self, page: usize) {
489 if self.cursor < self.offset {
490 self.offset = self.cursor;
491 }
492 loop {
493 let used: usize = self.rows[self.offset..=self.cursor.min(self.rows.len() - 1)]
494 .iter()
495 .map(|row| self.height(row))
496 .sum();
497 if used <= page || self.offset >= self.cursor {
498 break;
499 }
500 self.offset += 1;
501 }
502 }
503
504 fn hold(&mut self, page: usize) {
506 if self.following {
507 self.offset = self.bottom_offset(page);
508 self.following = false;
509 self.unseen = 0;
510 }
511 }
512
513 fn follow(&mut self) {
514 self.following = true;
515 self.paused = false;
516 self.unseen = 0;
517 self.cursor = self.rows.len().saturating_sub(1);
518 }
519
520 fn page(context: &Context<'_>) -> usize {
521 context.height.saturating_sub(1).max(1)
522 }
523
524 fn move_cursor(&mut self, delta: isize, page: usize) {
525 if self.rows.is_empty() {
526 return;
527 }
528 let last = self.rows.len() - 1;
529 let target = self.cursor.saturating_add_signed(delta).min(last);
530 if delta > 0 && target == last && !self.paused {
531 self.follow();
532 return;
533 }
534 self.hold(page);
535 self.cursor = target;
536 self.show_cursor(page);
537 }
538
539 fn handle_filter(&mut self, event: &Event) -> Flow<()> {
540 if let Event::Paste(text) = event {
541 self.filter.insert(&crate::kit::pasted(text, " "));
542 self.refilter();
543 return Flow::Continue;
544 }
545 let Some(key) = event.key() else {
546 return Flow::Continue;
547 };
548 let action = self.filter_keymap.action(key).map(str::to_string);
549 let changed = match action.as_deref() {
550 Some("apply") => {
551 self.typing = false;
552 false
553 }
554 Some("clear") => {
555 self.typing = false;
556 self.filter.set_text("");
557 true
558 }
559 Some("left") => {
560 self.filter.left();
561 false
562 }
563 Some("right") => {
564 self.filter.right();
565 false
566 }
567 Some("home") => {
568 self.filter.home();
569 false
570 }
571 Some("end") => {
572 self.filter.end();
573 false
574 }
575 Some("delete-to-start") => {
576 self.filter.delete_to_start();
577 true
578 }
579 Some("delete-word") => {
580 self.filter.delete_word();
581 true
582 }
583 Some("backspace") => self.filter.backspace(),
584 Some("delete") => self.filter.delete(),
585 _ => match key.code {
586 KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => {
587 let mut buffer = [0; 4];
588 self.filter.insert(c.encode_utf8(&mut buffer));
589 true
590 }
591 _ => false,
592 },
593 };
594 if changed {
595 self.refilter();
596 }
597 Flow::Continue
598 }
599
600 fn row_line(&self, row: &Row, selected: bool, width: usize) -> Vec<Segment> {
602 let record = &self.entry(row.entry).record;
603 let theme = &self.theme;
604 let mut line = Vec::new();
605 if selected {
606 line.push(text(format!("{} ", theme.pointer), &theme.pointer_style));
607 } else {
608 line.push(plain(" ".repeat(cell_len(&theme.pointer) + 1)));
609 }
610 if let Some(time) = self.time_of(record) {
611 line.push(text(format!("{time} "), &theme.hint));
612 }
613 match record.severity() {
614 Some(severity) => line.push(text(
615 format!("{:<5} ", severity.as_str().to_uppercase()),
616 &severity_style(severity),
617 )),
618 None => {
619 if let Some(level) = record.level() {
620 line.push(plain(format!("{} ", one_line(&value_text(level)))));
621 }
622 }
623 }
624 if row.count > 1 {
625 line.push(text(format!("×{} ", row.count), &style("bold")));
626 }
627 let message = record.message().unwrap_or_default();
628 let message_style = if selected {
629 theme.focused.clone()
630 } else {
631 Style::default()
632 };
633 line.push(text(one_line(&message), &message_style));
634 let field = style("cyan");
635 for (key, value) in shown_fields(record) {
636 line.push(plain(" "));
637 line.push(text(format!("{key}="), &field));
638 line.push(plain(one_line(&value_text(value))));
639 }
640 if !record.continuation.is_empty() && !self.is_expanded(row.entry) {
641 let lines = record.continuation.len() + self.entry(row.entry).more;
642 let label = if lines == 1 { "line" } else { "lines" };
643 line.push(text(format!(" (+{lines} {label})"), &theme.hint));
644 }
645 fit(line, width)
646 }
647
648 fn details(&self, row: &Row, width: usize, room: usize) -> Vec<Vec<Segment>> {
651 let entry = self.entry(row.entry);
652 let record = &entry.record;
653 let theme = &self.theme;
654 let indent = cell_len(&theme.pointer) + 3;
655 let mut lines = Vec::new();
656 let field = style("cyan");
657 for (key, value) in record.fields.iter().take(room) {
658 lines.push(fit(
659 vec![
660 plain(" ".repeat(indent)),
661 text(format!("{key}: "), &field),
662 plain(one_line(&value_text(value))),
663 ],
664 width,
665 ));
666 }
667 let more = (entry.more > 0).then(|| format!("… {} more lines", entry.more));
668 let continuations = record.continuation.iter().map(String::as_str);
669 for continuation in continuations
670 .chain(more.as_deref())
671 .take(room.saturating_sub(lines.len()))
672 {
673 lines.push(fit(
674 vec![
675 plain(" ".repeat(indent)),
676 text("│ ", &theme.hint),
677 plain(continuation.replace('\t', " ")),
678 ],
679 width,
680 ));
681 }
682 lines
683 }
684
685 fn time_of(&self, record: &Record) -> Option<String> {
686 match self.time {
687 TimeMode::Hidden => None,
688 TimeMode::Absolute => record.time().map(|time| one_line(&time)),
689 TimeMode::Relative => match (record.instant(), self.origin) {
690 (Some(at), Some(origin)) => Some(format_offset(at.saturating_sub(origin))),
691 _ => record.time().map(|time| one_line(&time)),
692 },
693 }
694 }
695
696 fn status(&self, page: usize, width: usize) -> Vec<Segment> {
697 let theme = &self.theme;
698 if self.typing {
699 let (shown, _) = self.filter.window(width.saturating_sub(3), None);
700 let hint = format!(" {} matching · enter keep · esc clear", self.rows.len());
701 return fit(
702 vec![
703 text("/ ", &theme.prompt),
704 plain(shown),
705 text(hint, &theme.hint),
706 ],
707 width,
708 );
709 }
710 let records = self.entries.len() + self.dropped;
711 let mut status = plural(records, "record");
712 if !self.filter.is_empty() {
713 status.push_str(&format!(
714 " · {} matching {:?}",
715 self.rows.len(),
716 self.filter.text()
717 ));
718 }
719 let top = self.top(page);
720 let state = if self.paused {
721 format!("paused · {} new", self.unseen)
722 } else if self.following {
723 if self.source.is_some() {
724 "following".to_string()
725 } else {
726 "at the end".to_string()
727 }
728 } else if self.unseen > 0 {
729 format!("row {} · {} new · end follows", top + 1, self.unseen)
730 } else {
731 format!("row {} · end follows", top + 1)
732 };
733 status.push_str(&format!(" · {state}"));
734 status.push_str(" · / filter · space pause · enter fields · q quit");
735 fit(vec![text(status, &theme.hint)], width)
736 }
737}
738
739impl Component for LogView {
740 type Output = ();
741
742 fn start(&mut self, _context: &Context<'_>) -> Flow<()> {
743 self.drain();
744 Flow::Continue
745 }
746
747 fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<()> {
748 let page = Self::page(context);
749 if let Event::Tick = event {
750 self.drain();
751 return Flow::Continue;
752 }
753 if self.typing {
754 return self.handle_filter(event);
755 }
756 let Some(key) = event.key() else {
757 return Flow::Continue;
758 };
759 let action = self.keymap.action(key).map(str::to_string);
760 match action.as_deref() {
761 Some("quit") => return Flow::Done(()),
762 Some("filter") => self.typing = true,
763 Some("pause") => {
764 if self.paused {
765 self.follow();
766 } else {
767 self.hold(page);
768 self.paused = true;
769 }
770 }
771 Some("expand") => {
772 if let Some(row) = self.rows.get(self.cursor) {
773 let entry = row.entry;
774 if !self.expanded.remove(&entry) {
775 self.expanded.insert(entry);
776 }
777 if !self.following {
778 self.show_cursor(page);
779 }
780 }
781 }
782 Some("up") => self.move_cursor(-1, page),
783 Some("down") => self.move_cursor(1, page),
784 Some("page-up") => self.move_cursor(-(page as isize), page),
785 Some("page-down") => self.move_cursor(page as isize, page),
786 Some("top") => {
787 self.hold(page);
788 self.cursor = 0;
789 self.offset = 0;
790 }
791 Some("bottom") => self.follow(),
792 _ => return Flow::Ignored,
793 }
794 Flow::Continue
795 }
796
797 fn render(&self, context: &Context<'_>) -> View {
798 let width = context.width;
799 let page = Self::page(context);
800 let mut lines = Vec::new();
801 if self.rows.is_empty() {
802 let message = if self.entries.is_empty() {
803 "no records yet"
804 } else {
805 "no record matches the filter"
806 };
807 lines.push(fit(vec![text(message, &self.theme.hint)], width));
808 }
809 let top = self.top(page);
810 for (index, row) in self.rows.iter().enumerate().skip(top) {
811 if lines.len() >= page {
812 break;
813 }
814 lines.push(self.row_line(row, index == self.cursor, width));
815 if self.is_expanded(row.entry) {
816 let room = page.saturating_sub(lines.len());
817 lines.extend(self.details(row, width, room));
818 }
819 }
820 lines.truncate(page);
821 lines.push(self.status(page, width));
822 let view = View::new(lines);
823 if self.typing {
824 let (_, caret) = self.filter.window(width.saturating_sub(3), None);
825 let row = view.lines.len() - 1;
826 return view.with_cursor(row, 2 + caret);
827 }
828 view
829 }
830
831 fn tick(&self) -> Option<Duration> {
832 self.source.as_ref().map(|_| self.tick)
833 }
834
835 fn keymap(&self) -> Keymap {
836 if self.typing {
837 self.filter_keymap.clone()
838 } else {
839 self.keymap.clone()
840 }
841 }
842
843 fn default_value(&self) -> Option<()> {
844 Some(())
845 }
846
847 fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<()>, NotInteractive> {
849 while self.source.is_some() {
850 self.drain();
851 if self.source.is_some() {
852 std::thread::sleep(self.tick);
853 }
854 }
855 for row in &self.rows {
856 let line: String = self
857 .row_line(row, false, usize::MAX / 2)
858 .iter()
859 .map(|segment| segment.text.as_str())
860 .collect();
861 io.write(line.trim_start());
862 io.write("\n");
863 }
864 Ok(Some(()))
865 }
866}
867
868fn style(definition: &str) -> Style {
869 Style::parse(definition).expect("a built-in style")
870}
871
872fn severity_style(severity: Severity) -> Style {
873 style(match severity {
874 Severity::Fatal | Severity::Error => "bold red",
875 Severity::Warn => "yellow",
876 Severity::Info => "green",
877 Severity::Debug | Severity::Trace => "dim",
878 })
879}
880
881fn plural(count: usize, one: &str) -> String {
882 if count == 1 {
883 format!("1 {one}")
884 } else {
885 format!("{count} {one}s")
886 }
887}
888
889fn one_line(text: &str) -> String {
891 if !text.contains(['\n', '\r', '\t']) {
892 return text.to_string();
893 }
894 text.replace("\r\n", "↵")
895 .replace(['\n', '\r'], "↵")
896 .replace('\t', " ")
897}
898
899fn value_text(value: &Value) -> String {
901 match value {
902 Value::String(text) => text.clone(),
903 Value::Null => "null".into(),
904 Value::Bool(v) => v.to_string(),
905 Value::Integer(v) => v.to_string(),
906 Value::Unsigned(v) => v.to_string(),
907 Value::Integer128(v) => v.to_string(),
908 Value::Unsigned128(v) => v.to_string(),
909 Value::Float(v) => v.to_string(),
910 Value::List(items) => {
911 let items: Vec<String> = items.iter().map(value_text).collect();
912 format!("[{}]", items.join(", "))
913 }
914 Value::Map(entries) => {
915 let entries: Vec<String> = entries
916 .iter()
917 .map(|(key, value)| format!("{key}: {}", value_text(value)))
918 .collect();
919 format!("{{{}}}", entries.join(", "))
920 }
921 }
922}
923
924fn shown_fields(record: &Record) -> impl Iterator<Item = (&str, &Value)> {
927 let message = record.first_of(MESSAGE_KEYS).map(|(key, _)| key);
928 let level = record
929 .severity()
930 .and(record.first_of(LEVEL_KEYS).map(|(key, _)| key));
931 let time = record.first_of(TIME_KEYS).map(|(key, _)| key);
932 record.fields.iter().filter_map(move |(key, value)| {
933 let key = key.as_str();
934 (Some(key) != message && Some(key) != level && Some(key) != time).then_some((key, value))
935 })
936}
937
938fn detail_count(entry: &Entry) -> usize {
940 entry.record.fields.len() + entry.record.continuation.len() + usize::from(entry.more > 0)
941}
942
943fn search_text(record: &Record) -> String {
946 let mut search = String::new();
947 if let Some(time) = record.time() {
948 search.push_str(&time);
949 search.push(' ');
950 }
951 if let Some(level) = record.level() {
952 search.push_str(&value_text(level));
953 search.push(' ');
954 }
955 search.push_str(&record.message().unwrap_or_default());
956 for (key, value) in shown_fields(record) {
957 search.push(' ');
958 search.push_str(key);
959 search.push('=');
960 search.push_str(&value_text(value));
961 }
962 for line in &record.continuation {
963 search.push('\n');
964 search.push_str(line);
965 }
966 search.to_lowercase()
967}
968
969fn words(filter: &str) -> Vec<String> {
971 let mut words = Vec::new();
972 let mut word = String::new();
973 let mut quote = None;
974 for c in filter.chars() {
975 match (quote, c) {
976 (None, '"' | '\'') => {
977 quote = Some(c);
978 word.push(c);
979 }
980 (Some(open), c) if c == open => {
981 quote = None;
982 word.push(c);
983 }
984 (None, c) if c.is_whitespace() => {
985 if !word.is_empty() {
986 words.push(std::mem::take(&mut word));
987 }
988 }
989 (_, c) => word.push(c),
990 }
991 }
992 if !word.is_empty() {
993 words.push(word);
994 }
995 words
996 .into_iter()
997 .map(|word| {
998 let bare = word.len() >= 2
1000 && (word.starts_with('"') && word.ends_with('"')
1001 || word.starts_with('\'') && word.ends_with('\''));
1002 if bare {
1003 word[1..word.len() - 1].to_string()
1004 } else {
1005 word
1006 }
1007 })
1008 .collect()
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use crate::Outcome;
1015 use crate::headless::{self, Script};
1016 use std::sync::mpsc;
1017
1018 fn record(level: &str, message: &str, second: u32) -> Record {
1019 Record::new("json")
1020 .field(
1021 "time",
1022 Value::String(format!("2026-10-18T10:00:{second:02}Z")),
1023 )
1024 .field("level", Value::String(level.into()))
1025 .field("msg", Value::String(message.into()))
1026 }
1027
1028 fn run(view: LogView, script: Script, rows: u16) -> headless::Record {
1029 let (outcome, record) = headless::run(view, script, 80, rows);
1030 assert_eq!(outcome.unwrap(), Outcome::Done(()));
1031 record
1032 }
1033
1034 #[test]
1035 fn rows_show_time_level_message_and_fields() {
1036 let view = LogView::new().records([record("warn", "slow", 0)
1037 .field("service", Value::String("api".into()))
1038 .field("latency_ms", Value::Integer(812))]);
1039 let frames = run(view, Script::new().keys("q"), 5);
1040 let frame = frames.last_frame();
1041 assert!(
1042 frame.contains("❯ 2026-10-18T10:00:00Z WARN slow service=api latency_ms=812"),
1043 "{frame}"
1044 );
1045 assert!(frame.contains("1 record · at the end"), "{frame}");
1046 }
1047
1048 #[test]
1049 fn repeats_fold_into_one_row_with_a_count() {
1050 let view = LogView::new().records([
1051 record("error", "refused", 0),
1052 record("error", "refused", 1),
1053 record("error", "refused", 2),
1054 record("info", "ok", 3),
1055 ]);
1056 assert_eq!(view.rows(), 2);
1057 let frames = run(view, Script::new().keys("q"), 6);
1058 assert!(
1059 frames.last_frame().contains("ERROR ×3 refused"),
1060 "{}",
1061 frames.last_frame()
1062 );
1063 let flat = LogView::new()
1064 .collapse(false)
1065 .records([record("error", "refused", 0), record("error", "refused", 1)]);
1066 assert_eq!(flat.rows(), 2);
1067 }
1068
1069 #[test]
1070 fn the_filter_line_takes_words_and_predicates() {
1071 let records = [
1072 record("info", "GET /a", 0).field("status", Value::Integer(200)),
1073 record("warn", "GET /b", 1).field("status", Value::Integer(404)),
1074 record("error", "POST /c", 2).field("status", Value::Integer(503)),
1075 ];
1076 let view = LogView::new().records(records.clone());
1077 let frames = run(
1078 view,
1079 Script::new()
1080 .keys("/")
1081 .text("status>=400 get")
1082 .keys("enter q"),
1083 8,
1084 );
1085 let frame = frames.last_frame();
1086 assert!(frame.contains("GET /b"), "{frame}");
1087 assert!(!frame.contains("POST /c"), "{frame}");
1088 assert!(!frame.contains("GET /a"), "{frame}");
1089 assert!(frame.contains("1 matching"), "{frame}");
1090 let view = LogView::new().records(records);
1092 let frames = run(
1093 view,
1094 Script::new().keys("/").text("post").keys("escape q"),
1095 8,
1096 );
1097 let typed = frames
1098 .frames
1099 .iter()
1100 .find(|frame| frame.contains("/ post"))
1101 .expect("the filter line shows what is typed");
1102 assert!(!typed.contains("GET /a"), "{typed}");
1103 assert!(frames.last_frame().contains("GET /a"));
1104 assert!(frames.last_frame().contains("3 records"));
1105 }
1106
1107 #[test]
1108 fn enter_shows_the_fields_and_continuation_lines() {
1109 let mut failed = record("error", "failed", 0).field("trace_id", Value::String("t1".into()));
1110 failed
1111 .continuation
1112 .push("Traceback (most recent call last):".into());
1113 let view = LogView::new().records([failed]);
1114 let frames = run(view, Script::new().keys("enter q"), 10);
1115 let frame = frames.last_frame();
1116 assert!(frame.contains("trace_id: t1"), "{frame}");
1117 assert!(
1118 frame.contains("│ Traceback (most recent call last):"),
1119 "{frame}"
1120 );
1121 let first = &frames.frames[0];
1122 assert!(first.contains("(+1 line)"), "{first}");
1123 }
1124
1125 #[test]
1126 fn continuation_lines_are_capped_and_counted() {
1127 let mut view = LogView::new().records([record("error", "failed", 0)]);
1128 let extra = 5;
1129 for n in 0..Assembler::CONTINUATION_LIMIT + extra {
1130 view.push_continuation(format!("line {n}"));
1131 }
1132 let entry = view.entries.back().unwrap();
1133 assert_eq!(
1134 entry.record.continuation.len(),
1135 Assembler::CONTINUATION_LIMIT
1136 );
1137 assert_eq!(entry.more, extra);
1138 let row = view.rows[0];
1139 let details = view.details(&row, 80, usize::MAX);
1140 let last: String = details
1141 .last()
1142 .unwrap()
1143 .iter()
1144 .map(|s| s.text.as_str())
1145 .collect();
1146 assert!(last.contains("… 5 more lines"), "{last}");
1147 assert_eq!(view.details(&row, 80, 4).len(), 4);
1149 let frames = run(view, Script::new().keys("enter q"), 10);
1150 let frame = frames.last_frame();
1151 assert!(frame.contains("line 0"), "{frame}");
1152 let first = &frames.frames[0];
1153 assert!(first.contains("(+1005 lines)"), "{first}");
1154 }
1155
1156 #[test]
1157 fn relative_time_before_the_origin_does_not_overflow() {
1158 let mut view = LogView::new();
1159 for micros in [i64::MAX, i64::MIN] {
1162 let time = Value::Integer128(i128::from(micros) * 1_000);
1163 view.push(Record::new("json").field("time", time));
1164 }
1165 let record = view.entries.back().unwrap().record.clone();
1166 assert_eq!(record.instant(), Some(i64::MIN));
1167 view.time = TimeMode::Relative;
1168 assert!(view.time_of(&record).is_some());
1169 }
1170
1171 #[test]
1172 fn a_channel_sink_feeds_the_view_with_events() {
1173 use rich_ext::adapters::EventSink;
1174 use rich_ext::event::{EventContext, Message, Severity, StructuredEvent};
1175 use rich_ext::log_stream::ChannelSink;
1176
1177 let (sink, receiver) = ChannelSink::<LogEvent>::bounded(8);
1178 let event =
1179 StructuredEvent::new(Message::Literal("disk full".into())).context(EventContext {
1180 severity: Some(Severity::Error),
1181 target: Some("store".into()),
1182 ..Default::default()
1183 });
1184 sink.emit(event).unwrap();
1185 let view = LogView::new().source(receiver);
1186 let frames = run(
1187 view,
1188 Script::new().wait(Duration::from_millis(150)).keys("q"),
1189 4,
1190 );
1191 drop(sink);
1192 let frame = frames.last_frame();
1193 assert!(frame.contains("ERROR disk full"), "{frame}");
1194 assert!(frame.contains("target=store"), "{frame}");
1195 }
1196
1197 #[test]
1198 fn it_follows_a_source_and_pauses_on_space() {
1199 let (sender, receiver) = mpsc::channel();
1200 for second in 0..3 {
1201 sender
1202 .send(LogEvent::Record(record(
1203 "info",
1204 &format!("n{second}"),
1205 second,
1206 )))
1207 .unwrap();
1208 }
1209 let view = LogView::new().source(receiver);
1210 let script = Script::new()
1214 .wait(Duration::from_millis(150))
1215 .keys("space")
1216 .wait(Duration::from_millis(150))
1217 .keys("space q");
1218 let frames = run(view, script, 3);
1219 drop(sender);
1220 let frame = frames.last_frame();
1221 assert!(
1222 frame.contains("n2") && frame.contains("following"),
1223 "{frame}"
1224 );
1225 assert!(
1226 frames.frames.iter().any(|f| f.contains("paused · 0 new")),
1227 "{:#?}",
1228 frames.frames
1229 );
1230 }
1231
1232 #[test]
1233 fn a_live_source_keeps_the_end_in_view_until_the_cursor_moves_up() {
1234 let mut view = LogView::new();
1235 for second in 0..20 {
1236 view.push(record("info", &format!("n{second}"), second));
1237 }
1238 assert!(view.is_following());
1239 let frames = run(view, Script::new().keys("up up q"), 6);
1240 let followed = &frames.frames[0];
1241 assert!(followed.contains("n19"), "{followed}");
1242 let held = frames.last_frame();
1243 assert!(held.contains("❯") && held.contains("n17"), "{held}");
1244 assert!(held.contains("end follows"), "{held}");
1245 let mut view = LogView::new();
1247 for second in 0..5 {
1248 view.push(record("info", &format!("n{second}"), second));
1249 }
1250 view.hold(3);
1251 view.push(record("info", "later", 9));
1252 assert_eq!(view.unseen, 1);
1253 view.follow();
1254 assert_eq!(view.cursor, view.rows() - 1);
1255 }
1256
1257 #[test]
1258 fn relative_times_count_from_the_first_record() {
1259 let view = LogView::new()
1260 .time(TimeMode::Relative)
1261 .records([record("info", "a", 0), record("info", "b", 5)]);
1262 let frames = run(view, Script::new().keys("q"), 5);
1263 assert!(
1264 frames.last_frame().contains("+5.000s INFO b"),
1265 "{}",
1266 frames.last_frame()
1267 );
1268 }
1269
1270 #[test]
1271 fn the_oldest_records_drop_past_the_limit() {
1272 let mut view = LogView::new().limit(10).collapse(false);
1273 for n in 0..30 {
1274 view.push(record("info", &format!("n{n}"), n % 60));
1275 }
1276 assert!(view.len() <= 11, "{}", view.len());
1277 assert_eq!(view.len() + view.dropped(), 30);
1278 assert_eq!(view.rows(), view.len());
1279 }
1280
1281 #[test]
1282 fn filter_words_keep_quotes_together() {
1283 assert_eq!(
1284 words(r#"msg~"timed out" api 'two words'"#),
1285 [r#"msg~"timed out""#, "api", "two words"]
1286 );
1287 assert!(words(" ").is_empty());
1288 }
1289
1290 #[test]
1291 fn without_a_terminal_it_writes_the_rows() {
1292 struct Lines(String);
1293 impl LineIo for Lines {
1294 fn write(&mut self, text: &str) {
1295 self.0.push_str(text);
1296 }
1297 fn read_line(&mut self) -> Option<String> {
1298 None
1299 }
1300 }
1301 let mut view = LogView::new().records([record("info", "a", 0), record("warn", "b", 1)]);
1302 let mut io = Lines(String::new());
1303 assert_eq!(view.prompt(&mut io), Ok(Some(())));
1304 assert_eq!(
1305 io.0,
1306 "2026-10-18T10:00:00Z INFO a\n2026-10-18T10:00:01Z WARN b\n"
1307 );
1308 }
1309}