use crate::models::log_entry::LogEntry;
use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeZone};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FilterOp {
Equals,
NotEquals,
Contains,
NotContains,
Before,
After,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FilterField {
Message,
Component,
Thread,
Timestamp,
Severity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FilterClause {
pub field: FilterField,
pub op: FilterOp,
pub value: String,
}
struct CompiledClause<'a> {
clause: &'a FilterClause,
timestamp_target: Option<i64>,
needle_lower: String,
}
fn compile_clauses<'a>(
clauses: &'a [FilterClause],
) -> Result<Vec<CompiledClause<'a>>, crate::error::AppError> {
clauses
.iter()
.map(|clause| {
let needs_timestamp_parse = matches!(clause.field, FilterField::Timestamp)
&& !matches!(clause.op, FilterOp::Contains | FilterOp::NotContains);
let timestamp_target = if needs_timestamp_parse {
Some(parse_filter_timestamp_millis(&clause.value)?)
} else {
None
};
Ok(CompiledClause {
clause,
timestamp_target,
needle_lower: clause.value.to_lowercase(),
})
})
.collect()
}
#[tauri::command]
pub fn apply_filter(
entries: Vec<LogEntry>,
clauses: Vec<FilterClause>,
) -> Result<Vec<u64>, crate::error::AppError> {
if clauses.is_empty() {
return Ok(entries.iter().map(|e| e.id).collect());
}
let compiled = compile_clauses(&clauses)?;
let mut matching_ids = Vec::new();
for entry in &entries {
if compiled.iter().all(|c| matches_clause(entry, c)) {
matching_ids.push(entry.id);
}
}
Ok(matching_ids)
}
fn matches_clause(entry: &LogEntry, compiled: &CompiledClause) -> bool {
let CompiledClause {
clause,
timestamp_target,
needle_lower,
} = compiled;
match clause.field {
FilterField::Message => match_string(&entry.message, &clause.op, needle_lower),
FilterField::Component => {
let comp = entry.component.as_deref().unwrap_or("");
match_string(comp, &clause.op, needle_lower)
}
FilterField::Thread => {
let thread_str = entry.thread.map(|t| t.to_string()).unwrap_or_default();
match_string(&thread_str, &clause.op, needle_lower)
}
FilterField::Timestamp => {
let ts = entry.timestamp.unwrap_or(0);
match_timestamp(ts, &clause.op, *timestamp_target)
}
FilterField::Severity => {
let sev_str = match &entry.severity {
crate::models::log_entry::Severity::Error => "Error",
crate::models::log_entry::Severity::Warning => "Warning",
crate::models::log_entry::Severity::Info => "Info",
crate::models::log_entry::Severity::Success => "Success",
};
match_string(sev_str, &clause.op, needle_lower)
}
}
}
fn match_string(haystack: &str, op: &FilterOp, needle_lower: &str) -> bool {
let hay_lower = haystack.to_lowercase();
match op {
FilterOp::Equals => hay_lower == needle_lower,
FilterOp::NotEquals => hay_lower != needle_lower,
FilterOp::Contains => hay_lower.contains(needle_lower),
FilterOp::NotContains => !hay_lower.contains(needle_lower),
FilterOp::Before | FilterOp::After => true,
}
}
fn match_timestamp(ts: i64, op: &FilterOp, target: Option<i64>) -> bool {
match op {
FilterOp::Contains | FilterOp::NotContains => true,
FilterOp::Before => target.is_some_and(|t| ts < t),
FilterOp::After => target.is_some_and(|t| ts > t),
FilterOp::Equals => target.is_some_and(|t| ts == t),
FilterOp::NotEquals => target.is_some_and(|t| ts != t),
}
}
fn parse_filter_timestamp_millis(value: &str) -> Result<i64, crate::error::AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(invalid_timestamp_filter_value(value));
}
if let Ok(epoch_millis) = trimmed.parse::<i64>() {
return Ok(epoch_millis);
}
if let Ok(parsed) = DateTime::parse_from_rfc3339(trimmed) {
return Ok(parsed.timestamp_millis());
}
for format in [
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
"%m/%d/%Y %H:%M:%S%.f",
"%m/%d/%Y %H:%M:%S",
"%m/%d/%Y %I:%M:%S %p",
] {
if let Ok(parsed) = NaiveDateTime::parse_from_str(trimmed, format) {
return naive_to_local_millis(parsed)
.ok_or_else(|| invalid_timestamp_filter_value(value));
}
}
for format in ["%Y-%m-%d", "%m/%d/%Y"] {
if let Ok(parsed) = NaiveDate::parse_from_str(trimmed, format) {
let start_of_day = parsed
.and_hms_opt(0, 0, 0)
.ok_or_else(|| invalid_timestamp_filter_value(value))?;
return naive_to_local_millis(start_of_day)
.ok_or_else(|| invalid_timestamp_filter_value(value));
}
}
Err(invalid_timestamp_filter_value(value))
}
fn naive_to_local_millis(naive: NaiveDateTime) -> Option<i64> {
match Local.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => Some(dt.timestamp_millis()),
chrono::LocalResult::Ambiguous(earlier, _) => Some(earlier.timestamp_millis()),
chrono::LocalResult::None => None,
}
}
fn invalid_timestamp_filter_value(value: &str) -> crate::error::AppError {
crate::error::AppError::InvalidInput(format!(
"Invalid timestamp filter value '{value}'. Use epoch milliseconds, YYYY-MM-DD, MM/DD/YYYY, or an ISO-8601 date/time."
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamp_filter_accepts_plain_iso_date() {
let parsed = parse_filter_timestamp_millis("2026-04-01").expect("date should parse");
let naive = NaiveDate::from_ymd_opt(2026, 4, 1)
.expect("valid date")
.and_hms_opt(0, 0, 0)
.expect("valid time");
let expected = naive_to_local_millis(naive).expect("local datetime should resolve");
assert_eq!(parsed, expected);
}
#[test]
fn timestamp_filter_accepts_rfc3339_datetime() {
let parsed =
parse_filter_timestamp_millis("2026-04-01T12:30:00Z").expect("datetime should parse");
let expected = DateTime::parse_from_rfc3339("2026-04-01T12:30:00Z")
.expect("valid datetime")
.timestamp_millis();
assert_eq!(parsed, expected);
}
#[test]
fn timestamp_filter_accepts_whole_second_datetimes() {
let naive = NaiveDate::from_ymd_opt(2026, 4, 1)
.expect("valid date")
.and_hms_opt(12, 30, 0)
.expect("valid time");
let expected = naive_to_local_millis(naive).expect("local datetime should resolve");
for input in [
"2026-04-01 12:30:00",
"2026-04-01T12:30:00",
"04/01/2026 12:30:00",
] {
let parsed = parse_filter_timestamp_millis(input)
.unwrap_or_else(|e| panic!("'{input}' should parse: {e}"));
assert_eq!(parsed, expected, "input={input}");
}
}
#[test]
fn timestamp_filter_naive_input_is_local_time() {
let naive = NaiveDate::from_ymd_opt(2026, 4, 1)
.expect("valid date")
.and_hms_opt(12, 30, 0)
.expect("valid time");
let local_millis = Local
.from_local_datetime(&naive)
.single()
.expect("local datetime should resolve unambiguously")
.timestamp_millis();
assert_eq!(
parse_filter_timestamp_millis("2026-04-01 12:30:00").unwrap(),
local_millis,
);
}
#[test]
fn timestamp_filter_rejects_invalid_values() {
let clauses = vec![FilterClause {
field: FilterField::Timestamp,
op: FilterOp::Before,
value: "yesterday".into(),
}];
let error = compile_clauses(&clauses)
.err()
.expect("invalid value should fail");
assert!(error.to_string().contains("Invalid timestamp filter value"));
}
#[test]
fn timestamp_before_uses_parsed_date() {
let target = parse_filter_timestamp_millis("2026-04-01").expect("date should parse");
assert!(match_timestamp(target - 1, &FilterOp::Before, Some(target)));
assert!(!match_timestamp(target, &FilterOp::Before, Some(target)));
}
#[test]
fn compile_clauses_parses_each_timestamp_value_once() {
let clauses = vec![FilterClause {
field: FilterField::Timestamp,
op: FilterOp::Before,
value: "2026-04-01".into(),
}];
let compiled = compile_clauses(&clauses).expect("clause should compile");
assert_eq!(compiled.len(), 1);
assert!(compiled[0].timestamp_target.is_some());
}
#[test]
fn compile_clauses_skips_parse_for_substring_ops_on_timestamps() {
let clauses = vec![FilterClause {
field: FilterField::Timestamp,
op: FilterOp::Contains,
value: "not-a-date".into(),
}];
let compiled =
compile_clauses(&clauses).expect("substring op should not require parseable value");
assert!(compiled[0].timestamp_target.is_none());
}
}