use regex::Regex;
use super::app::RowKey;
use super::level::{Level, level_of};
use super::tail::{Stream, TailLine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Axis {
Stream,
Level,
Match,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchKind {
Literal,
Regex,
Invalid,
}
enum Matcher {
Literal(String),
Regex(Regex),
Invalid,
}
impl Matcher {
fn parse(text: &str) -> Self {
match delimited_regex(text) {
Some(pattern) => Regex::new(pattern).map_or(Self::Invalid, Self::Regex),
None => Self::Literal(text.to_string()),
}
}
fn kind(&self) -> MatchKind {
match self {
Self::Literal(_) => MatchKind::Literal,
Self::Regex(_) => MatchKind::Regex,
Self::Invalid => MatchKind::Invalid,
}
}
fn is_match(&self, haystack: &str) -> bool {
match self {
Self::Literal(pattern) => haystack.contains(pattern.as_str()),
Self::Regex(re) => re.is_match(haystack),
Self::Invalid => false,
}
}
fn ranges(&self, haystack: &str) -> Vec<(usize, usize)> {
match self {
Self::Literal(pattern) if !pattern.is_empty() => haystack
.match_indices(pattern.as_str())
.map(|(start, matched)| (start, start + matched.len()))
.collect(),
Self::Literal(_) | Self::Invalid => Vec::new(),
Self::Regex(re) => re
.find_iter(haystack)
.map(|m| (m.start(), m.end()))
.collect(),
}
}
}
fn delimited_regex(text: &str) -> Option<&str> {
let inner = text.strip_prefix('/')?.strip_suffix('/')?;
(!inner.is_empty()).then_some(inner)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Filters {
pub stream: Option<Stream>,
pub min_level: Option<Level>,
pub matcher: Option<String>,
order: Vec<Axis>,
}
impl Filters {
fn note_axis(&mut self, axis: Axis, now_set: bool) {
let already_set = self.order.contains(&axis);
if now_set && !already_set {
self.order.push(axis);
} else if !now_set && already_set {
self.order.retain(|set| *set != axis);
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.stream.is_none() && self.min_level.is_none() && self.matcher.is_none()
}
#[must_use]
pub fn match_kind(&self) -> Option<MatchKind> {
self.matcher
.as_deref()
.map(|text| Matcher::parse(text).kind())
}
#[must_use]
pub fn match_ranges(&self, haystack: &str) -> Vec<(usize, usize)> {
self.matcher
.as_deref()
.map(|text| Matcher::parse(text).ranges(haystack))
.unwrap_or_default()
}
fn keeps(&self, line: &TailLine, matcher: Option<&Matcher>) -> bool {
if let Some(stream) = self.stream
&& line.stream != stream
{
return false;
}
if let Some(min) = self.min_level {
if let Some(level) = level_of(&line.text)
&& level < min
{
return false;
}
}
if let Some(matcher) = matcher
&& !matcher.is_match(&line.text)
{
return false;
}
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BleatsPane {
sheep: RowKey,
filters: Filters,
match_snapshot: Option<Option<String>>,
scroll_offset: usize,
following: bool,
body_rows: usize,
width: u16,
wrap: bool,
}
impl BleatsPane {
#[must_use]
pub fn new(sheep: RowKey) -> Self {
Self {
sheep,
filters: Filters::default(),
match_snapshot: None,
scroll_offset: 0,
following: true,
body_rows: 0,
width: 0,
wrap: false,
}
}
#[must_use]
pub fn sheep(&self) -> &RowKey {
&self.sheep
}
#[must_use]
pub fn filters(&self) -> &Filters {
&self.filters
}
pub fn set_stream(&mut self, stream: Option<Stream>) {
self.filters.note_axis(Axis::Stream, stream.is_some());
self.filters.stream = stream;
}
pub fn set_min_level(&mut self, level: Option<Level>) {
self.filters.note_axis(Axis::Level, level.is_some());
self.filters.min_level = level;
}
pub fn set_match(&mut self, text: String) {
let matcher = (!text.is_empty()).then_some(text);
self.filters.note_axis(Axis::Match, matcher.is_some());
self.filters.matcher = matcher;
}
pub fn begin_match_edit(&mut self) {
self.match_snapshot = Some(self.filters.matcher.clone());
}
#[must_use]
pub fn match_editing(&self) -> Option<&str> {
self.match_snapshot
.as_ref()
.map(|_| self.filters.matcher.as_deref().unwrap_or(""))
}
pub fn commit_match_edit(&mut self) {
self.match_snapshot = None;
}
pub fn abandon_match_edit(&mut self) {
if let Some(previous) = self.match_snapshot.take() {
self.set_match(previous.unwrap_or_default());
}
}
#[must_use]
pub fn visible<'a>(&self, lines: &'a [TailLine]) -> Vec<&'a TailLine> {
let matcher = self.filters.matcher.as_deref().map(Matcher::parse);
lines
.iter()
.filter(|line| self.filters.keeps(line, matcher.as_ref()))
.collect()
}
#[must_use]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
#[must_use]
pub fn following(&self) -> bool {
self.following
}
#[must_use]
pub(crate) fn body_rows(&self) -> usize {
self.body_rows
}
pub fn set_rows(&mut self, rows: usize) {
self.body_rows = rows;
}
#[must_use]
pub(crate) fn width(&self) -> u16 {
self.width
}
pub fn set_width(&mut self, width: u16) {
self.width = width;
}
#[must_use]
pub fn wrapped(&self) -> bool {
self.wrap
}
pub fn toggle_wrap(&mut self) {
self.wrap = !self.wrap;
}
pub fn scroll_up(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_add(amount);
self.following = false;
}
pub fn clamp_scroll(&mut self, ceiling: usize) {
self.scroll_offset = self.scroll_offset.min(ceiling);
}
pub fn scroll_down(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
pub fn page_down(&mut self, amount: usize) {
self.scroll_down(amount.max(1));
}
pub fn jump_to_end(&mut self) {
self.scroll_offset = 0;
self.following = true;
}
pub fn toggle_follow(&mut self) {
self.following = !self.following;
if self.following {
self.scroll_offset = 0;
}
}
pub fn match_next(&mut self) {
if self.filters.matcher.is_none() {
return;
}
self.scroll_down(1);
self.following = false;
}
pub fn drop_newest_chip(&mut self) -> bool {
let Some(axis) = self.filters.order.pop() else {
return false;
};
match axis {
Axis::Stream => self.filters.stream = None,
Axis::Level => self.filters.min_level = None,
Axis::Match => self.filters.matcher = None,
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(stream: Stream, text: &str) -> TailLine {
TailLine {
stream,
text: text.to_string(),
}
}
#[test]
fn a_line_with_no_level_survives_a_minimum() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_min_level(Some(Level::Warn));
let lines = vec![
line(Stream::Out, "listening on 8080"),
line(Stream::Out, "INFO routine chatter"),
line(Stream::Out, "ERROR pool exhausted"),
];
let kept: Vec<&str> = pane
.visible(&lines)
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["listening on 8080", "ERROR pool exhausted"]);
}
#[test]
fn the_three_axes_compose_with_and() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_match("pool".to_string());
let lines = vec![
line(Stream::Err, "ERROR pool exhausted"), line(Stream::Out, "ERROR pool exhausted"), line(Stream::Err, "INFO pool warming"), line(Stream::Err, "ERROR disk full"), ];
assert_eq!(pane.visible(&lines).len(), 1);
}
#[test]
fn esc_drops_the_newest_chip_then_reports_there_are_none_left() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
assert!(pane.drop_newest_chip(), "the level chip was newest");
assert!(pane.filters().min_level.is_none());
assert!(pane.filters().stream.is_some(), "the older chip stays");
assert!(pane.drop_newest_chip(), "the stream chip goes next");
assert!(!pane.drop_newest_chip(), "nothing left to drop");
}
#[test]
fn clearing_an_older_axis_directly_removes_it_from_the_drop_order() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_stream(None);
assert!(pane.drop_newest_chip(), "level is the only chip left");
assert!(pane.filters().min_level.is_none());
assert!(
!pane.drop_newest_chip(),
"stream was cleared directly, not through drop_newest_chip, and \
must not still be sitting in the order"
);
}
#[test]
fn re_setting_an_already_set_axis_does_not_reorder_it() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_stream(Some(Stream::Out));
assert!(pane.drop_newest_chip(), "level is still the newest chip");
assert!(pane.filters().min_level.is_none());
assert!(
pane.filters().stream.is_some(),
"the re-set stream chip is still the older one"
);
}
#[test]
fn a_slash_delimited_matcher_is_read_as_a_regex() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/poo+l/".to_string());
let lines = vec![
line(Stream::Out, "pool exhausted"),
line(Stream::Out, "pooool exhausted"),
line(Stream::Out, "pol exhausted"),
line(Stream::Out, "kennel"),
];
let kept: Vec<&str> = pane
.visible(&lines)
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["pool exhausted", "pooool exhausted"]);
}
#[test]
fn an_invalid_regex_matches_no_line_and_reports_itself_invalid() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/pool(/".to_string());
let lines = vec![line(Stream::Out, "pool exhausted")];
assert!(pane.visible(&lines).is_empty());
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Invalid));
}
#[test]
fn a_plain_matcher_is_read_literally_not_as_a_regex() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("get index.html".to_string());
let lines = vec![
line(Stream::Out, "GET get index.html 200"),
line(Stream::Out, "GET get indexXhtml 200"),
];
let kept: Vec<&str> = pane
.visible(&lines)
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["GET get index.html 200"]);
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Literal));
}
#[test]
fn match_ranges_reports_every_hit_a_regex_matcher_finds() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/po+l/".to_string());
let ranges = pane.filters().match_ranges("pool then pol then pooool");
let hits: Vec<&str> = ranges
.iter()
.map(|&(start, end)| &"pool then pol then pooool"[start..end])
.collect();
assert_eq!(hits, vec!["pool", "pol", "pooool"]);
}
}