v0.0.5
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+21 -80
View File
@@ -11,11 +11,6 @@ use crate::db;
use crate::error::AppError;
use crate::search::engine::{SearchEngine, SearchQuery};
// ---------------------------------------------------------------------------
// Search query parser
// ---------------------------------------------------------------------------
/// Intermediate result of parsing structured operators out of a query string.
struct ParsedQuery {
text: String,
subject_only: Option<String>,
@@ -29,11 +24,6 @@ struct ParsedQuery {
flagged: Option<bool>,
}
/// Parse a search query string extracting structured operators like `from:`,
/// `to:`, `subject:`, `in:`/`folder:`, `date:`, `after:`, `before:`, and
/// `has:attachment`. Remaining text becomes the free-text query.
///
/// Supports quoted values: `from:"John Doe"`, `subject:"meeting notes"`.
fn parse_search_query(input: &str) -> ParsedQuery {
let mut text_parts: Vec<String> = Vec::new();
let mut subject_only: Option<String> = None;
@@ -88,7 +78,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
flagged = Some(false)
}
_ => {
// Unknown operator, treat as plain text
text_parts.push(token);
}
}
@@ -111,9 +100,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
}
}
/// Tokenize a query string, respecting quoted values attached to operators.
/// For example: `from:"John Doe" hello world` yields
/// `["from:John Doe", "hello", "world"]`.
fn tokenize_query(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
@@ -126,11 +112,10 @@ fn tokenize_query(input: &str) -> Vec<String> {
}
chars.next();
} else if ch == '"' {
// Start of a quoted section
chars.next(); // consume opening quote
chars.next();
while let Some(&qch) = chars.peek() {
if qch == '"' {
chars.next(); // consume closing quote
chars.next();
break;
}
current.push(qch);
@@ -149,12 +134,11 @@ fn tokenize_query(input: &str) -> Vec<String> {
tokens
}
/// Split a token into (operator, value) if it matches `operator:value` pattern.
fn split_operator(token: &str) -> Option<(String, String)> {
if let Some(colon_pos) = token.find(':') {
let op = &token[..colon_pos];
let val = &token[colon_pos + 1..];
// Only treat as operator if the operator part is alphabetic and value is non-empty
if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() {
return Some((op.to_string(), val.to_string()));
}
@@ -162,15 +146,12 @@ fn split_operator(token: &str) -> Option<(String, String)> {
None
}
/// Parse a `YYYY-MM-DD` date string into a start-of-day and end-of-day epoch
/// (UTC) pair.
fn parse_date_range(date_str: &str) -> Option<(i64, i64)> {
let start = parse_date_start(date_str)?;
let end = start + 86_400 - 1; // end of day
let end = start + 86_400 - 1;
Some((start, end))
}
/// Parse a `YYYY-MM-DD` date string into a start-of-day epoch (UTC).
fn parse_date_start(date_str: &str) -> Option<i64> {
let parts: Vec<&str> = date_str.split('-').collect();
if parts.len() != 3 {
@@ -184,16 +165,12 @@ fn parse_date_start(date_str: &str) -> Option<i64> {
return None;
}
// Convert to Unix epoch using a simplified calculation (UTC).
// Days from year 1970 to the given date.
let mut total_days: i64 = 0;
// Days from full years
for y in 1970..year {
total_days += if is_leap_year(y) { 366 } else { 365 };
}
// Days from full months in the target year
let days_in_months = [
31,
28 + i64::from(is_leap_year(year)),
@@ -221,44 +198,37 @@ fn is_leap_year(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
// ---------------------------------------------------------------------------
// Query parameters
// ---------------------------------------------------------------------------
/// Query parameters for `GET /api/search`.
#[derive(Deserialize)]
pub struct SearchParams {
/// The search text (required, must not be empty).
pub q: Option<String>,
/// Optional folder filter.
pub folder: Option<String>,
/// Optional subject filter.
pub subject: Option<String>,
/// Optional from address filter.
pub from: Option<String>,
/// Optional to address filter.
pub to: Option<String>,
/// Optional date range start (Unix epoch seconds).
pub date_from: Option<i64>,
/// Optional date range end (Unix epoch seconds).
pub date_to: Option<i64>,
/// Optional attachment filter.
pub has_attachment: Option<bool>,
/// Optional unread/read filter.
pub unread: Option<bool>,
/// Optional flagged/unflagged filter.
pub flagged: Option<bool>,
/// Maximum number of results (default 50).
#[serde(default = "default_limit")]
pub limit: usize,
/// Offset for pagination (default 0).
#[serde(default)]
pub offset: usize,
/// Sort order: "date_desc" (default) or "date_asc".
#[serde(default = "default_sort")]
pub sort: String,
/// Materialize every matching cached result. Intended for explicit
/// "select all search results" bulk actions, not normal rendering.
#[serde(default)]
pub all: bool,
}
@@ -271,11 +241,6 @@ fn default_limit() -> usize {
10_000
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
/// Response envelope for `GET /api/search`.
#[derive(Serialize)]
pub struct SearchResponse {
pub results: Vec<SearchResultItem>,
@@ -283,7 +248,6 @@ pub struct SearchResponse {
pub query: String,
}
/// A single search result item enriched with message metadata from SQLite.
#[derive(Serialize)]
pub struct SearchResultItem {
pub uid: u32,
@@ -299,21 +263,12 @@ pub struct SearchResultItem {
pub snippet: String,
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
/// `GET /api/search?q=text&folder=INBOX&from=alice&date_from=...&date_to=...&has_attachment=true&limit=50&offset=0`
///
/// Searches the user's messages using both SQLite (for header fields) and
/// Tantivy (for body text). Results are merged and deduplicated.
pub async fn search_messages(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(search_engine): Extension<Arc<SearchEngine>>,
Query(params): Query<SearchParams>,
) -> Result<Response, AppError> {
// Validate that `q` is provided and non-empty.
let query_text = params.q.as_deref().unwrap_or("").trim().to_string();
let has_direct_filters = params.folder.is_some()
@@ -336,7 +291,6 @@ pub async fn search_messages(
let requested_limit = params.limit.clamp(1, 10_000);
let search_offset = params.offset;
// Parse structured operators from the query string.
let parsed = parse_search_query(&query_text);
let folder = params.folder.or(parsed.folder);
@@ -349,7 +303,6 @@ pub async fn search_messages(
let unread = params.unread.or(parsed.unread);
let flagged = params.flagged.or(parsed.flagged);
// Run SQLite queries in spawn_blocking to avoid blocking the Tokio executor.
let data_dir = config.data_dir.clone();
let user_hash = session.user_hash.clone();
let text_clone = parsed.text.clone();
@@ -385,7 +338,6 @@ pub async fn search_messages(
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
.map_err(|e| AppError::InternalError(format!("Search error: {e}")))?;
// Collect SQLite results as SearchResultItems.
let mut seen = std::collections::HashSet::new();
let mut results: Vec<SearchResultItem> = sqlite_results
.into_iter()
@@ -407,8 +359,6 @@ pub async fn search_messages(
})
.collect();
// Secondary search: Tantivy for body text matches not found by SQLite.
// Only add results up to the limit cap.
if !parsed.text.is_empty()
&& results.len() < limit
&& let Ok(user_index) = search_engine.open_user_index(&session.user_hash)
@@ -425,7 +375,6 @@ pub async fn search_messages(
offset: if select_all { 0 } else { search_offset },
})
{
// Resolve Tantivy UIDs via SQLite in spawn_blocking.
let data_dir2 = config.data_dir.clone();
let user_hash2 = session.user_hash.clone();
let remaining = limit.saturating_sub(results.len());
@@ -480,7 +429,6 @@ pub async fn search_messages(
}
}
// Sort results by date.
let sort_asc = sort_order == "date_asc";
results.sort_by(|a, b| {
let da = crate::db::messages::parse_date_to_epoch_public(&a.date);
@@ -492,7 +440,6 @@ pub async fn search_messages(
}
});
// Cap to requested limit after sorting.
results.truncate(limit);
Ok(Json(SearchResponse {
@@ -566,10 +513,6 @@ pub async fn delete_saved_search(
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
@@ -628,7 +571,7 @@ mod tests {
assert_eq!(p.text, "news");
assert!(p.date_from.is_some());
assert!(p.date_to.is_some());
// 2024-01-15 00:00:00 UTC = 1705276800
assert_eq!(p.date_from, Some(1_705_276_800));
assert_eq!(p.date_to, Some(1_705_276_800 + 86_399));
}
@@ -637,9 +580,9 @@ mod tests {
fn parse_after_before() {
let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello");
assert_eq!(p.text, "hello");
// 2024-01-01 = 1704067200
assert_eq!(p.date_from, Some(1_704_067_200));
// 2024-06-01 = 1717200000
assert_eq!(p.date_to, Some(1_717_200_000));
}
@@ -688,7 +631,6 @@ mod tests {
#[test]
fn parse_empty_value_not_operator() {
// "from:" with no value should not be treated as an operator
let p = parse_search_query("hello from:");
assert_eq!(p.text, "hello from:");
assert!(p.from.is_none());
@@ -696,9 +638,8 @@ mod tests {
#[test]
fn date_parsing_basic() {
// 2024-01-01 00:00:00 UTC
assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200));
// Invalid date
assert_eq!(parse_date_start("not-a-date"), None);
assert_eq!(parse_date_start("2024-13-01"), None);
}