use serde::{Deserialize, Serialize}; use winnow::{ ModalResult, Parser, combinator::{alt, delimited, preceded, repeat}, token::{take_till, take_while}, }; /// Represents a parsed search query. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SearchQuery { FullText(String), FieldMatch { field: String, value: String, }, And(Vec), Or(Vec), Not(Box), Prefix(String), Fuzzy(String), TypeFilter(String), TagFilter(String), /// Range query: field:start..end (inclusive) RangeQuery { field: String, start: Option, end: Option, }, /// Comparison query: field:>value, field:=value, field:<=value CompareQuery { field: String, op: CompareOp, value: i64, }, /// Date query: created:today, modified:last-week, etc. DateQuery { field: String, value: DateValue, }, } /// Comparison operators for range queries. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CompareOp { GreaterThan, GreaterOrEqual, LessThan, LessOrEqual, } /// Date values for date-based queries. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DateValue { Today, Yesterday, ThisWeek, LastWeek, ThisMonth, LastMonth, ThisYear, LastYear, /// Days ago: last-7d, last-30d DaysAgo(u32), } /// Request for executing a search. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchRequest { pub query: SearchQuery, pub sort: SortOrder, pub pagination: crate::model::Pagination, } /// Results of a search operation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResults { pub items: Vec, pub total_count: u64, } /// Sorting options for search results. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[derive(Default)] pub enum SortOrder { #[default] Relevance, DateAsc, DateDesc, NameAsc, NameDesc, SizeAsc, SizeDesc, } fn ws<'i>(input: &mut &'i str) -> ModalResult<&'i str> { take_while(0.., ' ').parse_next(input) } fn quoted_string(input: &mut &str) -> ModalResult { delimited('"', take_till(0.., '"'), '"') .map(|s: &str| s.to_string()) .parse_next(input) } fn bare_word(input: &mut &str) -> ModalResult { take_while(1.., |c: char| !c.is_whitespace() && c != ')' && c != '(') .map(|s: &str| s.to_string()) .parse_next(input) } fn word_or_quoted(input: &mut &str) -> ModalResult { alt((quoted_string, bare_word)).parse_next(input) } fn not_expr(input: &mut &str) -> ModalResult { preceded(('-', ws), atom) .map(|q| SearchQuery::Not(Box::new(q))) .parse_next(input) } /// Parse a date value like "today", "yesterday", "last-week", "last-30d" fn parse_date_value(s: &str) -> Option { match s.to_lowercase().as_str() { "today" => Some(DateValue::Today), "yesterday" => Some(DateValue::Yesterday), "this-week" | "thisweek" => Some(DateValue::ThisWeek), "last-week" | "lastweek" => Some(DateValue::LastWeek), "this-month" | "thismonth" => Some(DateValue::ThisMonth), "last-month" | "lastmonth" => Some(DateValue::LastMonth), "this-year" | "thisyear" => Some(DateValue::ThisYear), "last-year" | "lastyear" => Some(DateValue::LastYear), other => { // Try to parse "last-Nd" format (e.g., "last-7d", "last-30d") if let Some(rest) = other.strip_prefix("last-") && let Some(days_str) = rest.strip_suffix('d') && let Ok(days) = days_str.parse::() { return Some(DateValue::DaysAgo(days)); } None }, } } /// Parse size strings like "10MB", "1GB", "500KB" to bytes /// /// Returns `None` if the input is invalid or if the value would overflow. fn parse_size_value(s: &str) -> Option { let s = s.to_uppercase(); let (num_str, multiplier): (&str, i64) = if let Some(n) = s.strip_suffix("GB") { (n, 1024 * 1024 * 1024) } else if let Some(n) = s.strip_suffix("MB") { (n, 1024 * 1024) } else if let Some(n) = s.strip_suffix("KB") { (n, 1024) } else if let Some(n) = s.strip_suffix('B') { (n, 1) } else { (s.as_str(), 1) }; let num: i64 = num_str.parse().ok()?; num.checked_mul(multiplier) } fn field_match(input: &mut &str) -> ModalResult { let field_name = take_while(1.., |c: char| c.is_alphanumeric() || c == '_') .map(|s: &str| s.to_string()); (field_name, ':', word_or_quoted) .map(|(field, _, value)| { // Handle special field types match field.as_str() { "type" => return SearchQuery::TypeFilter(value), "tag" => return SearchQuery::TagFilter(value), _ => {}, } // Check for range queries: field:start..end if value.contains("..") { let parts: Vec<&str> = value.split("..").collect(); if parts.len() == 2 { let start = if parts[0].is_empty() { None } else if field == "size" { parse_size_value(parts[0]) } else { parts[0].parse().ok() }; let end = if parts[1].is_empty() { None } else if field == "size" { parse_size_value(parts[1]) } else { parts[1].parse().ok() }; return SearchQuery::RangeQuery { field, start, end }; } } // Check for comparison queries: >=, <=, >, < if let Some(rest) = value.strip_prefix(">=") { let val = if field == "size" { parse_size_value(rest).unwrap_or(0) } else { rest.parse().unwrap_or(0) }; return SearchQuery::CompareQuery { field, op: CompareOp::GreaterOrEqual, value: val, }; } if let Some(rest) = value.strip_prefix("<=") { let val = if field == "size" { parse_size_value(rest).unwrap_or(0) } else { rest.parse().unwrap_or(0) }; return SearchQuery::CompareQuery { field, op: CompareOp::LessOrEqual, value: val, }; } if let Some(rest) = value.strip_prefix('>') { let val = if field == "size" { parse_size_value(rest).unwrap_or(0) } else { rest.parse().unwrap_or(0) }; return SearchQuery::CompareQuery { field, op: CompareOp::GreaterThan, value: val, }; } if let Some(rest) = value.strip_prefix('<') { let val = if field == "size" { parse_size_value(rest).unwrap_or(0) } else { rest.parse().unwrap_or(0) }; return SearchQuery::CompareQuery { field, op: CompareOp::LessThan, value: val, }; } // Check for date queries on created/modified fields if (field == "created" || field == "modified") && let Some(date_val) = parse_date_value(&value) { return SearchQuery::DateQuery { field, value: date_val, }; } // Default: simple field match SearchQuery::FieldMatch { field, value } }) .parse_next(input) } fn prefix_expr(input: &mut &str) -> ModalResult { let word = take_while(1.., |c: char| { !c.is_whitespace() && c != ')' && c != '(' && c != '*' }) .map(|s: &str| s.to_string()); (word, '*') .map(|(w, _)| SearchQuery::Prefix(w)) .parse_next(input) } fn fuzzy_expr(input: &mut &str) -> ModalResult { let word = take_while(1.., |c: char| { !c.is_whitespace() && c != ')' && c != '(' && c != '~' }) .map(|s: &str| s.to_string()); (word, '~') .map(|(w, _)| SearchQuery::Fuzzy(w)) .parse_next(input) } fn paren_expr(input: &mut &str) -> ModalResult { delimited(('(', ws), or_expr, (ws, ')')).parse_next(input) } fn not_or_keyword(input: &mut &str) -> ModalResult<()> { if let Some(rest) = input.strip_prefix("OR") && (rest.is_empty() || rest.starts_with(' ') || rest.starts_with(')')) { return Err(winnow::error::ErrMode::Backtrack( winnow::error::ContextError::new(), )); } Ok(()) } fn full_text(input: &mut &str) -> ModalResult { not_or_keyword.parse_next(input)?; word_or_quoted.map(SearchQuery::FullText).parse_next(input) } fn atom(input: &mut &str) -> ModalResult { alt(( paren_expr, not_expr, field_match, prefix_expr, fuzzy_expr, full_text, )) .parse_next(input) } fn and_expr(input: &mut &str) -> ModalResult { let first = atom.parse_next(input)?; let rest: Vec = repeat(0.., preceded(ws, atom)).parse_next(input)?; if rest.is_empty() { Ok(first) } else { let mut terms = vec![first]; terms.extend(rest); Ok(SearchQuery::And(terms)) } } fn or_expr(input: &mut &str) -> ModalResult { let first = and_expr.parse_next(input)?; let rest: Vec = repeat(0.., preceded((ws, "OR", ws), and_expr)).parse_next(input)?; if rest.is_empty() { Ok(first) } else { let mut terms = vec![first]; terms.extend(rest); Ok(SearchQuery::Or(terms)) } } /// Parses a search query string into a structured query. /// /// Supports full-text search, field matches, operators (AND/OR/NOT), /// prefixes, fuzzy matching, and type/tag filters. /// /// # Arguments /// /// * `input` - Raw query string /// /// # Returns /// /// Parsed query tree /// /// # Errors /// /// Returns `SearchParse` error for invalid syntax pub fn parse_search_query(input: &str) -> crate::error::Result { let trimmed = input.trim(); if trimmed.is_empty() { return Ok(SearchQuery::FullText(String::new())); } let mut input = trimmed; or_expr .parse_next(&mut input) .map_err(|e| crate::error::PinakesError::SearchParse(format!("{e}"))) } #[cfg(test)] mod tests { use super::*; #[test] fn test_simple_text() { let q = parse_search_query("hello").unwrap(); assert_eq!(q, SearchQuery::FullText("hello".into())); } #[test] fn test_field_match() { let q = parse_search_query("artist:Beatles").unwrap(); assert_eq!(q, SearchQuery::FieldMatch { field: "artist".into(), value: "Beatles".into(), }); } #[test] fn test_type_filter() { let q = parse_search_query("type:pdf").unwrap(); assert_eq!(q, SearchQuery::TypeFilter("pdf".into())); } #[test] fn test_tag_filter() { let q = parse_search_query("tag:music").unwrap(); assert_eq!(q, SearchQuery::TagFilter("music".into())); } #[test] fn test_and_implicit() { let q = parse_search_query("hello world").unwrap(); assert_eq!( q, SearchQuery::And(vec![ SearchQuery::FullText("hello".into()), SearchQuery::FullText("world".into()), ]) ); } #[test] fn test_or() { let q = parse_search_query("hello OR world").unwrap(); assert_eq!( q, SearchQuery::Or(vec![ SearchQuery::FullText("hello".into()), SearchQuery::FullText("world".into()), ]) ); } #[test] fn test_not() { let q = parse_search_query("-excluded").unwrap(); assert_eq!( q, SearchQuery::Not(Box::new(SearchQuery::FullText("excluded".into()))) ); } #[test] fn test_prefix() { let q = parse_search_query("hel*").unwrap(); assert_eq!(q, SearchQuery::Prefix("hel".into())); } #[test] fn test_fuzzy() { let q = parse_search_query("hello~").unwrap(); assert_eq!(q, SearchQuery::Fuzzy("hello".into())); } #[test] fn test_quoted() { let q = parse_search_query("\"hello world\"").unwrap(); assert_eq!(q, SearchQuery::FullText("hello world".into())); } #[test] fn test_range_query_year() { let q = parse_search_query("year:2020..2023").unwrap(); assert_eq!(q, SearchQuery::RangeQuery { field: "year".into(), start: Some(2020), end: Some(2023), }); } #[test] fn test_range_query_open_start() { let q = parse_search_query("year:..2023").unwrap(); assert_eq!(q, SearchQuery::RangeQuery { field: "year".into(), start: None, end: Some(2023), }); } #[test] fn test_range_query_open_end() { let q = parse_search_query("year:2020..").unwrap(); assert_eq!(q, SearchQuery::RangeQuery { field: "year".into(), start: Some(2020), end: None, }); } #[test] fn test_compare_greater_than() { let q = parse_search_query("year:>2020").unwrap(); assert_eq!(q, SearchQuery::CompareQuery { field: "year".into(), op: CompareOp::GreaterThan, value: 2020, }); } #[test] fn test_compare_less_or_equal() { let q = parse_search_query("year:<=2023").unwrap(); assert_eq!(q, SearchQuery::CompareQuery { field: "year".into(), op: CompareOp::LessOrEqual, value: 2023, }); } #[test] fn test_size_compare_mb() { let q = parse_search_query("size:>10MB").unwrap(); assert_eq!(q, SearchQuery::CompareQuery { field: "size".into(), op: CompareOp::GreaterThan, value: 10 * 1024 * 1024, }); } #[test] fn test_size_range_gb() { let q = parse_search_query("size:1GB..2GB").unwrap(); assert_eq!(q, SearchQuery::RangeQuery { field: "size".into(), start: Some(1024 * 1024 * 1024), end: Some(2 * 1024 * 1024 * 1024), }); } #[test] fn test_date_query_today() { let q = parse_search_query("created:today").unwrap(); assert_eq!(q, SearchQuery::DateQuery { field: "created".into(), value: DateValue::Today, }); } #[test] fn test_date_query_last_week() { let q = parse_search_query("modified:last-week").unwrap(); assert_eq!(q, SearchQuery::DateQuery { field: "modified".into(), value: DateValue::LastWeek, }); } #[test] fn test_date_query_days_ago() { let q = parse_search_query("created:last-30d").unwrap(); assert_eq!(q, SearchQuery::DateQuery { field: "created".into(), value: DateValue::DaysAgo(30), }); } }