aven-core 0.1.12

Core library for the Aven local-first task manager
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTaskSearchQuery {
    pub trimmed: String,
    pub fts_match: Option<String>,
    #[allow(dead_code)]
    pub phrases: Vec<String>,
    #[allow(dead_code)]
    pub tokens: Vec<String>,
    #[allow(dead_code)]
    pub active_prefix: Option<String>,
    pub ref_query: Option<ParsedRefSearchQuery>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedRefSearchQuery {
    pub normalized_prefix: Option<String>,
    pub normalized_suffix: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TermKind {
    Phrase,
    Bare,
}

pub fn parse_task_search_query(input: &str) -> ParsedTaskSearchQuery {
    let trimmed = input.trim().to_string();
    let ends_in_whitespace = input.ends_with(char::is_whitespace);
    let mut phrases = Vec::new();
    let mut bare_terms = Vec::new();
    let mut current = String::new();
    let mut in_quote = false;
    let mut current_is_phrase = false;
    let mut final_term_kind = None;

    for ch in trimmed.chars() {
        if ch == '"' {
            final_term_kind = flush_term(
                &mut current,
                current_is_phrase,
                &mut phrases,
                &mut bare_terms,
            );
            in_quote = !in_quote;
            current_is_phrase = in_quote;
        } else if ch.is_whitespace() && !in_quote {
            final_term_kind = flush_term(
                &mut current,
                current_is_phrase,
                &mut phrases,
                &mut bare_terms,
            );
            current_is_phrase = false;
        } else {
            current.push(ch);
            current_is_phrase = in_quote;
        }
    }
    if let Some(kind) = flush_term(
        &mut current,
        current_is_phrase,
        &mut phrases,
        &mut bare_terms,
    ) {
        final_term_kind = Some(kind);
    }

    let active_prefix = if matches!(final_term_kind, Some(TermKind::Bare)) && !ends_in_whitespace {
        bare_terms.pop()
    } else {
        None
    };

    let tokens = bare_terms;
    let fts_match = build_fts_match(&phrases, &tokens, active_prefix.as_deref());
    let ref_query = parse_ref_query(&trimmed);

    ParsedTaskSearchQuery {
        trimmed,
        fts_match,
        phrases,
        tokens,
        active_prefix,
        ref_query,
    }
}

fn sanitize_term(term: &str) -> Option<String> {
    let sanitized = term
        .chars()
        .filter_map(|ch| match ch {
            '"' | '*' => None,
            ch if ch.is_control() => Some(' '),
            ch => Some(ch),
        })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ");
    if sanitized.is_empty() {
        None
    } else {
        Some(sanitized)
    }
}

fn flush_term(
    current: &mut String,
    is_phrase: bool,
    phrases: &mut Vec<String>,
    tokens: &mut Vec<String>,
) -> Option<TermKind> {
    if current.is_empty() {
        return None;
    }
    let sanitized = sanitize_term(current);
    current.clear();
    if let Some(term) = sanitized {
        if is_phrase {
            phrases.push(term);
            Some(TermKind::Phrase)
        } else {
            tokens.push(term);
            Some(TermKind::Bare)
        }
    } else {
        None
    }
}

fn build_fts_match(
    phrases: &[String],
    tokens: &[String],
    active_prefix: Option<&str>,
) -> Option<String> {
    let mut parts = Vec::new();
    for phrase in phrases {
        parts.push(format!("\"{}\"", phrase));
    }
    for token in tokens {
        parts.push(format!("\"{}\"", token));
    }
    if let Some(prefix) = active_prefix {
        parts.push(format!("\"{}\"*", prefix));
    }
    if parts.is_empty() {
        None
    } else {
        Some(parts.join(" "))
    }
}

fn parse_ref_query(input: &str) -> Option<ParsedRefSearchQuery> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return None;
    }
    let has_whitespace = trimmed.contains(char::is_whitespace);
    let has_ref_marker = trimmed.starts_with('/');
    let raw = trimmed.strip_prefix('/').unwrap_or(trimmed);
    let groups = raw
        .split(|ch: char| !ch.is_ascii_alphanumeric())
        .filter(|group| !group.is_empty())
        .collect::<Vec<_>>();
    if groups.is_empty() {
        return None;
    }
    if groups.len() >= 2 && groups[0].chars().all(|c| c.is_ascii_alphabetic()) {
        let suffix = groups[1..].join("");
        if suffix.len() >= 3 && (!has_whitespace || has_ref_marker || suffix_has_digit(&suffix)) {
            return Some(ParsedRefSearchQuery {
                normalized_prefix: Some(normalize_ref_string(groups[0])),
                normalized_suffix: normalize_ref_string(&suffix),
            });
        }
    }
    if has_whitespace {
        return None;
    }
    let suffix = groups.join("");
    if suffix.len() >= 3 {
        return Some(ParsedRefSearchQuery {
            normalized_prefix: None,
            normalized_suffix: normalize_ref_string(&suffix),
        });
    }
    None
}

fn suffix_has_digit(input: &str) -> bool {
    input.chars().any(|ch| ch.is_ascii_digit())
}

fn normalize_ref_string(input: &str) -> String {
    input
        .chars()
        .map(|ch| match ch.to_ascii_uppercase() {
            'O' => '0',
            'I' | 'L' => '1',
            c => c,
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn task_search_parser_empty_input_has_no_match_expression() {
        let parsed = parse_task_search_query("   ");
        assert!(parsed.trimmed.is_empty());
        assert_eq!(parsed.fts_match, None);
        assert!(parsed.phrases.is_empty());
        assert!(parsed.tokens.is_empty());
        assert_eq!(parsed.active_prefix, None);
        assert_eq!(parsed.ref_query, None);
    }

    #[test]
    fn task_search_parser_builds_safe_match_for_special_characters() {
        let parsed = parse_task_search_query("\"a*b\" AND (c OR d):");
        let fts_match = parsed.fts_match.as_deref().unwrap();
        assert!(fts_match.contains("\"ab\""));
        assert!(fts_match.contains("\"AND\""));
        assert!(fts_match.contains("\"(c\""));
        assert!(fts_match.contains("\"OR\""));
        assert!(fts_match.contains("\"d):\"*"));
        assert!(!fts_match.contains("a*b"));
    }

    #[test]
    fn task_search_parser_keeps_quoted_phrase_atomic() {
        let parsed = parse_task_search_query("\"pager rotation\" security");
        assert_eq!(parsed.phrases, vec!["pager rotation".to_string()]);
        assert!(parsed.tokens.is_empty());
        assert_eq!(parsed.active_prefix.as_deref(), Some("security"));
        assert_eq!(
            parsed.fts_match.as_deref(),
            Some("\"pager rotation\" \"security\"*")
        );
    }

    #[test]
    fn task_search_parser_marks_active_final_token() {
        let parsed = parse_task_search_query("ios auth");
        assert_eq!(parsed.tokens, vec!["ios".to_string()]);
        assert_eq!(parsed.active_prefix.as_deref(), Some("auth"));
        assert_eq!(parsed.fts_match.as_deref(), Some("\"ios\" \"auth\"*"));

        let complete = parse_task_search_query("ios auth ");
        assert_eq!(complete.tokens, vec!["ios".to_string(), "auth".to_string()]);
        assert_eq!(complete.active_prefix, None);
        assert_eq!(complete.fts_match.as_deref(), Some("\"ios\" \"auth\""));
    }

    #[test]
    fn task_search_parser_identifies_ref_shapes() {
        let suffix = parse_task_search_query("7KQ9").ref_query.unwrap();
        assert_eq!(suffix.normalized_prefix, None);
        assert_eq!(suffix.normalized_suffix, "7KQ9");

        let qualified = parse_task_search_query("/APP-7OKI").ref_query.unwrap();
        assert_eq!(qualified.normalized_prefix.as_deref(), Some("APP"));
        assert_eq!(qualified.normalized_suffix, "70K1");

        assert_eq!(parse_task_search_query("release cleanup").ref_query, None);
    }

    #[test]
    fn task_search_parser_identifies_punctuation_insensitive_ref_shapes() {
        let qualified = parse_task_search_query("/APP.7OKI").ref_query.unwrap();
        assert_eq!(qualified.normalized_prefix.as_deref(), Some("APP"));
        assert_eq!(qualified.normalized_suffix, "70K1");

        let spaced = parse_task_search_query("APP 7OKI").ref_query.unwrap();
        assert_eq!(spaced.normalized_prefix.as_deref(), Some("APP"));
        assert_eq!(spaced.normalized_suffix, "70K1");

        let suffix = parse_task_search_query("7KQ-9").ref_query.unwrap();
        assert_eq!(suffix.normalized_prefix, None);
        assert_eq!(suffix.normalized_suffix, "7KQ9");

        let durable = parse_task_search_query("7KQ9A1X4MV2P8D6R")
            .ref_query
            .unwrap();
        assert_eq!(durable.normalized_suffix, "7KQ9A1X4MV2P8D6R");
    }

    #[tokio::test]
    async fn task_search_parser_match_expression_compiles_in_fts5() {
        use sqlx::{Connection, SqliteConnection};

        let mut conn = SqliteConnection::connect("sqlite::memory:").await.unwrap();
        sqlx::query("CREATE VIRTUAL TABLE docs USING fts5(body)")
            .execute(&mut conn)
            .await
            .unwrap();

        for input in [
            "\"",
            "\"\"",
            "(",
            ")",
            "a*b",
            "\"(",
            "AND OR NOT",
            ":",
            "/",
            "-",
            "a:b",
            "\"unfinished",
            "x OR y",
            "\"pager rotation\" security",
            "foo \"*\"",
        ] {
            let parsed = parse_task_search_query(input);
            if let Some(fts_match) = parsed.fts_match {
                sqlx::query("SELECT rowid FROM docs WHERE docs MATCH ?")
                    .bind(&fts_match)
                    .fetch_all(&mut conn)
                    .await
                    .unwrap_or_else(|err| {
                        panic!("input {input:?} produced invalid MATCH {fts_match:?}: {err}")
                    });
            }
        }
    }
}