yarp-cli 0.3.0

Prune Pi command output and archive tool calls locally
Documentation
use crate::rule_pack::{LinePattern, PatternCase, PatternKind, PatternTrim};

use super::bounded::LineView;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AnsiState {
    Ground,
    Escape,
    Csi,
    Osc,
    OscEscape,
}

#[derive(Debug)]
pub struct AnsiStripper {
    state: AnsiState,
}

impl AnsiStripper {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            state: AnsiState::Ground,
        }
    }

    pub fn push_byte(&mut self, byte: u8) -> Option<u8> {
        if matches!(byte, b'\r' | b'\n') {
            return Some(byte);
        }
        match self.state {
            AnsiState::Ground if byte == 0x1b => self.state = AnsiState::Escape,
            AnsiState::Ground => return Some(byte),
            AnsiState::Escape => {
                self.state = match byte {
                    b'[' => AnsiState::Csi,
                    b']' => AnsiState::Osc,
                    _ => AnsiState::Ground,
                };
            }
            AnsiState::Csi if (0x40..=0x7e).contains(&byte) => {
                self.state = AnsiState::Ground;
            }
            AnsiState::Osc if byte == 0x07 => self.state = AnsiState::Ground,
            AnsiState::Osc if byte == 0x1b => self.state = AnsiState::OscEscape,
            AnsiState::Csi | AnsiState::Osc => {}
            AnsiState::OscEscape if byte == b'\\' => self.state = AnsiState::Ground,
            AnsiState::OscEscape if byte == 0x1b => {}
            AnsiState::OscEscape => self.state = AnsiState::Osc,
        }
        None
    }
}

#[must_use]
pub fn line_filter_keeps(line: &LineView, drop: &[LinePattern], keep: &[LinePattern]) -> bool {
    if drop.iter().any(|pattern| pattern_matches(pattern, line)) {
        return false;
    }
    keep.is_empty() || keep.iter().any(|pattern| pattern_matches(pattern, line)) || line.truncated
}

#[must_use]
pub fn pattern_matches(pattern: &LinePattern, line: &LineView) -> bool {
    match pattern.kind {
        PatternKind::Exact => line.full_bytes().is_some_and(|body| {
            compare(
                apply_trim(strip_line_ending(body), pattern.trim),
                pattern.value.as_bytes(),
                pattern.case,
                PatternKind::Exact,
            )
        }),
        PatternKind::Prefix | PatternKind::Contains => {
            let body = strip_line_ending(&line.prefix);
            let body = if line.truncated {
                match pattern.trim {
                    PatternTrim::None => body,
                    PatternTrim::Start | PatternTrim::Both => trim_start(body),
                }
            } else {
                apply_trim(body, pattern.trim)
            };
            compare(body, pattern.value.as_bytes(), pattern.case, pattern.kind)
        }
        PatternKind::Suffix => {
            let tail = line.tail_bytes();
            let body = strip_line_ending(&tail);
            let body = if line.truncated {
                match pattern.trim {
                    PatternTrim::Both => trim_end(body),
                    PatternTrim::None | PatternTrim::Start => body,
                }
            } else {
                apply_trim(body, pattern.trim)
            };
            compare(
                body,
                pattern.value.as_bytes(),
                pattern.case,
                PatternKind::Suffix,
            )
        }
    }
}

fn compare(body: &[u8], value: &[u8], case: PatternCase, kind: PatternKind) -> bool {
    match case {
        PatternCase::Sensitive => match kind {
            PatternKind::Exact => body == value,
            PatternKind::Prefix => body.starts_with(value),
            PatternKind::Suffix => body.ends_with(value),
            PatternKind::Contains => body.windows(value.len()).any(|window| window == value),
        },
        PatternCase::AsciiInsensitive => match kind {
            PatternKind::Exact => body.len() == value.len() && ascii_equal(body, value),
            PatternKind::Prefix => body
                .get(..value.len())
                .is_some_and(|candidate| ascii_equal(candidate, value)),
            PatternKind::Suffix => body
                .get(body.len().saturating_sub(value.len())..)
                .is_some_and(|candidate| {
                    candidate.len() == value.len() && ascii_equal(candidate, value)
                }),
            PatternKind::Contains => contains_ascii_insensitive(body, value),
        },
    }
}

fn contains_ascii_insensitive(body: &[u8], value: &[u8]) -> bool {
    !value.is_empty()
        && body
            .windows(value.len())
            .any(|candidate| ascii_equal(candidate, value))
}

fn ascii_equal(left: &[u8], right: &[u8]) -> bool {
    left.iter()
        .zip(right)
        .all(|(left, right)| left.eq_ignore_ascii_case(right))
}

fn apply_trim(body: &[u8], trim: PatternTrim) -> &[u8] {
    match trim {
        PatternTrim::None => body,
        PatternTrim::Start => trim_start(body),
        PatternTrim::Both => trim_end(trim_start(body)),
    }
}

fn trim_start(mut body: &[u8]) -> &[u8] {
    while body
        .first()
        .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
    {
        body = &body[1..];
    }
    body
}

fn trim_end(mut body: &[u8]) -> &[u8] {
    while body.last().is_some_and(|byte| matches!(byte, b' ' | b'\t')) {
        body = &body[..body.len() - 1];
    }
    body
}

fn strip_line_ending(body: &[u8]) -> &[u8] {
    body.strip_suffix(b"\r\n")
        .or_else(|| body.strip_suffix(b"\n"))
        .unwrap_or(body)
}

#[cfg(test)]
mod tests {
    use std::collections::VecDeque;

    use crate::rule_pack::{PatternCase, PatternKind, PatternTrim};

    use super::*;

    fn line(value: &[u8], truncated: bool) -> LineView {
        LineView {
            prefix: value.to_vec(),
            tail: VecDeque::from(value.to_vec()),
            line_ending: if value.ends_with(b"\n") {
                b"\n".to_vec()
            } else {
                Vec::new()
            },
            total_bytes: value.len() + usize::from(truncated),
            truncated,
        }
    }

    #[test]
    fn strips_chunked_ansi_sequences() {
        let mut stripper = AnsiStripper::new();
        let output = [b"before \x1b[3".as_slice(), b"1mred\x1b[0m after\n"]
            .into_iter()
            .flatten()
            .filter_map(|byte| stripper.push_byte(*byte))
            .collect::<Vec<_>>();
        assert_eq!(output, b"before red after\n");
    }

    #[test]
    fn matches_literal_patterns_without_regex() {
        let pattern = LinePattern {
            kind: PatternKind::Prefix,
            value: "warning:".to_owned(),
            case: PatternCase::AsciiInsensitive,
            trim: PatternTrim::Start,
        };
        assert!(pattern_matches(
            &pattern,
            &line(b"  WARNING: detail\n", false)
        ));
    }

    #[test]
    fn uncertain_truncated_keep_patterns_fail_open() {
        let keep = [LinePattern {
            kind: PatternKind::Suffix,
            value: "summary".to_owned(),
            case: PatternCase::Sensitive,
            trim: PatternTrim::None,
        }];
        assert!(line_filter_keeps(&line(b"long prefix", true), &[], &keep));
    }
}