nex-cli 1.0.0

A keyboard-first launcher for Windows
Documentation
use crate::config::SearchMode;
use crate::model::normalize_for_search;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeFilterWindow {
    Today,
    Week,
    Month,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedQuery {
    pub raw: String,
    pub free_text: String,
    pub mode_override: Option<SearchMode>,
    pub kind_filter: Option<String>,
    pub extension_filter: Option<String>,
    pub include_groups: Vec<Vec<String>>,
    pub exclude_terms: Vec<String>,
    pub modified_within: Option<TimeFilterWindow>,
    pub created_within: Option<TimeFilterWindow>,
    pub command_mode: bool,
}

impl ParsedQuery {
    pub fn parse(query: &str, dsl_enabled: bool) -> Self {
        let raw = query.trim().to_string();
        if raw.is_empty() {
            return Self {
                raw,
                free_text: String::new(),
                mode_override: None,
                kind_filter: None,
                extension_filter: None,
                include_groups: Vec::new(),
                exclude_terms: Vec::new(),
                modified_within: None,
                created_within: None,
                command_mode: false,
            };
        }

        if !dsl_enabled {
            return Self {
                free_text: raw.clone(),
                raw,
                mode_override: None,
                kind_filter: None,
                extension_filter: None,
                include_groups: Vec::new(),
                exclude_terms: Vec::new(),
                modified_within: None,
                created_within: None,
                command_mode: false,
            };
        }

        let mut command_mode = false;
        let mut working = raw.clone();
        if let Some(rest) = working.strip_prefix('>') {
            command_mode = true;
            working = rest.trim_start().to_string();
        }

        let tokens = tokenize(&working);
        let mut mode_override = if command_mode {
            Some(SearchMode::Actions)
        } else {
            None
        };
        let mut kind_filter: Option<String> = None;
        let mut extension_filter: Option<String> = None;
        let mut include_groups: Vec<Vec<String>> = vec![Vec::new()];
        let mut exclude_terms = Vec::new();
        let mut free_terms = Vec::new();
        let mut expect_not = false;
        let mut modified_within = None;
        let mut created_within = None;

        for token in tokens {
            let token_trimmed = token.trim();
            if token_trimmed.is_empty() {
                continue;
            }

            let upper = token_trimmed.to_ascii_uppercase();
            if upper == "AND" {
                continue;
            }
            if upper == "OR" {
                include_groups.push(Vec::new());
                expect_not = false;
                continue;
            }
            if upper == "NOT" {
                expect_not = true;
                continue;
            }

            if let Some(mode) = parse_mode_token(token_trimmed) {
                mode_override = Some(mode);
                expect_not = false;
                continue;
            }

            if let Some(value) = parse_prefixed(token_trimmed, "mode:") {
                if let Some(mode) = SearchMode::parse(value) {
                    mode_override = Some(mode);
                }
                expect_not = false;
                continue;
            }
            if let Some(value) = parse_prefixed(token_trimmed, "kind:") {
                let normalized = value.trim().to_ascii_lowercase();
                if !normalized.is_empty() {
                    kind_filter = Some(normalized);
                }
                expect_not = false;
                continue;
            }
            if let Some(value) = parse_prefixed(token_trimmed, "ext:")
                .or_else(|| parse_prefixed(token_trimmed, "extension:"))
            {
                let normalized = normalize_extension_filter(value);
                if !normalized.is_empty() {
                    extension_filter = Some(normalized);
                }
                expect_not = false;
                continue;
            }
            if let Some(value) = parse_prefixed(token_trimmed, "modified:") {
                modified_within = parse_time_filter(value);
                expect_not = false;
                continue;
            }
            if let Some(value) = parse_prefixed(token_trimmed, "created:") {
                created_within = parse_time_filter(value);
                expect_not = false;
                continue;
            }

            let is_negative_literal = token_trimmed.starts_with('-') && token_trimmed.len() > 1;
            let target = if is_negative_literal {
                &token_trimmed[1..]
            } else {
                token_trimmed
            };
            let normalized = normalize_for_search(target);
            if normalized.is_empty() {
                expect_not = false;
                continue;
            }

            if expect_not || is_negative_literal {
                exclude_terms.push(normalized.clone());
            } else {
                if include_groups.is_empty() {
                    include_groups.push(Vec::new());
                }
                if let Some(group) = include_groups.last_mut() {
                    group.push(normalized.clone());
                }
                free_terms.push(target.to_string());
            }
            expect_not = false;
        }

        include_groups.retain(|group| !group.is_empty());
        let free_text = free_terms.join(" ");

        Self {
            raw,
            free_text,
            mode_override,
            kind_filter,
            extension_filter,
            include_groups,
            exclude_terms,
            modified_within,
            created_within,
            command_mode,
        }
    }
}

fn normalize_extension_filter(value: &str) -> String {
    value
        .trim()
        .trim_start_matches('.')
        .chars()
        .flat_map(|ch| ch.to_lowercase())
        .collect()
}

fn parse_prefixed<'a>(token: &'a str, prefix: &str) -> Option<&'a str> {
    token
        .strip_prefix(prefix)
        .or_else(|| token.strip_prefix(&prefix.to_ascii_uppercase()))
}

fn parse_mode_token(token: &str) -> Option<SearchMode> {
    let token = token.trim();
    if !token.starts_with('@') {
        return None;
    }
    SearchMode::parse(token.trim_start_matches('@'))
}

fn parse_time_filter(value: &str) -> Option<TimeFilterWindow> {
    match value.trim().to_ascii_lowercase().as_str() {
        "today" => Some(TimeFilterWindow::Today),
        "week" | "last_week" => Some(TimeFilterWindow::Week),
        "month" | "last_month" => Some(TimeFilterWindow::Month),
        _ => None,
    }
}

fn tokenize(input: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;

    for ch in input.chars() {
        if ch == '"' {
            in_quotes = !in_quotes;
            continue;
        }

        if ch.is_whitespace() && !in_quotes {
            if !current.is_empty() {
                tokens.push(std::mem::take(&mut current));
            }
            continue;
        }

        current.push(ch);
    }

    if !current.is_empty() {
        tokens.push(current);
    }

    tokens
}

#[cfg(test)]
mod tests {
    use super::{ParsedQuery, TimeFilterWindow};
    use crate::config::SearchMode;

    #[test]
    fn parses_mode_kind_and_filters() {
        let parsed = ParsedQuery::parse(
            r#"@apps kind:file ext:md report OR notes NOT draft -temp modified:week"#,
            true,
        );
        assert_eq!(parsed.mode_override, Some(SearchMode::Apps));
        assert_eq!(parsed.kind_filter.as_deref(), Some("file"));
        assert_eq!(parsed.extension_filter.as_deref(), Some("md"));
        assert_eq!(parsed.modified_within, Some(TimeFilterWindow::Week));
        assert_eq!(parsed.include_groups.len(), 2);
        assert!(parsed.exclude_terms.contains(&"draft".to_string()));
        assert!(parsed.exclude_terms.contains(&"temp".to_string()));
    }

    #[test]
    fn parses_command_mode_prefix() {
        let parsed = ParsedQuery::parse(">logs", true);
        assert!(parsed.command_mode);
        assert_eq!(parsed.mode_override, Some(SearchMode::Actions));
        assert_eq!(parsed.free_text, "logs");
    }

    #[test]
    fn disables_dsl_when_disabled() {
        let parsed = ParsedQuery::parse("kind:file notes", false);
        assert_eq!(parsed.free_text, "kind:file notes");
        assert!(parsed.mode_override.is_none());
        assert!(parsed.include_groups.is_empty());
    }
}