use regex::{Regex, RegexBuilder};
use crate::columns::JobColumn;
use crate::job::Job;
const REGEX_METACHARACTERS: &[char] = &[
'.', '*', '+', '?', '^', '$', '|', '(', ')', '[', ']', '{', '}', '\\',
];
#[derive(Debug, Clone)]
enum Matcher {
Substring(String),
Regex(Regex),
}
#[derive(Debug, Clone)]
pub struct JobFilter {
text: String,
columns: Vec<JobColumn>,
matcher: Matcher,
}
impl JobFilter {
pub fn new(text: &str, columns: &[JobColumn]) -> Option<JobFilter> {
if text.is_empty() {
return None;
}
Some(JobFilter {
text: text.to_string(),
columns: columns.to_vec(),
matcher: build_matcher(text),
})
}
pub fn text(&self) -> &str {
&self.text
}
pub fn matches(&self, job: &Job) -> bool {
self.columns.iter().any(|column| {
if self.matches_text(&column.cell(job)) {
return true;
}
*column == JobColumn::Status && self.matches_text(job.status.abbrev())
})
}
fn matches_text(&self, text: &str) -> bool {
match &self.matcher {
Matcher::Substring(needle) => text.to_lowercase().contains(needle),
Matcher::Regex(regex) => regex.is_match(text),
}
}
}
fn build_matcher(text: &str) -> Matcher {
if text.contains(REGEX_METACHARACTERS) {
if let Ok(regex) = RegexBuilder::new(text).case_insensitive(true).build() {
return Matcher::Regex(regex);
}
}
Matcher::Substring(text.to_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::job::JobStatus;
fn train_job() -> Job {
Job::new(
"424242",
"train_model",
JobStatus::Running,
"12:34:56",
"gpu",
2,
"/work",
"run.sh",
None,
)
}
fn filter(text: &str) -> JobFilter {
JobFilter::new(text, &JobColumn::defaults()).unwrap()
}
#[test]
fn test_empty_input_is_no_filter() {
assert!(JobFilter::new("", &JobColumn::defaults()).is_none());
}
#[test]
fn test_substring_matches_across_configured_columns() {
let job = train_job();
assert!(filter("train").matches(&job));
assert!(filter("gpu").matches(&job));
assert!(filter("4242").matches(&job));
assert!(filter("Running").matches(&job));
assert!(!filter("cpu").matches(&job));
}
#[test]
fn test_matching_is_case_insensitive() {
let job = train_job();
assert!(filter("TRAIN").matches(&job));
assert!(filter("Gpu").matches(&job));
assert!(filter("rUnNiNg").matches(&job));
}
#[test]
fn test_status_abbreviation_matches_the_status_column() {
let mut job = train_job();
job.status = JobStatus::Pending;
assert!(filter("Pending").matches(&job));
assert!(filter("pd").matches(&job));
assert!(!filter("R ").matches(&job));
}
#[test]
fn test_only_configured_columns_are_matched() {
let mut job = train_job();
job.account = "physics".to_string();
assert!(!filter("physics").matches(&job));
let columns = vec![JobColumn::Id, JobColumn::Account];
assert!(JobFilter::new("physics", &columns).unwrap().matches(&job));
}
#[test]
fn test_input_with_metacharacters_is_matched_as_regex() {
let job = train_job();
assert!(filter("gpu|cpu").matches(&job));
assert!(filter("^TRAIN.*model$").matches(&job));
assert!(!filter("^model").matches(&job));
assert!(!filter("^cpu$").matches(&job));
}
#[test]
fn test_valid_regex_input_is_not_matched_literally() {
let mut job = train_job();
job.name = "a+b".to_string();
assert!(!filter("a+b").matches(&job));
job.name = "aab".to_string();
assert!(filter("a+b").matches(&job));
}
#[test]
fn test_invalid_regex_falls_back_to_literal_substring() {
let mut job = train_job();
job.name = "c++_build".to_string();
assert!(filter("c++").matches(&job));
assert!(!filter("c++x").matches(&job));
job.name = "array[5".to_string();
assert!(filter("array[5").matches(&job));
}
}