nex-cli 1.0.0

A keyboard-first launcher for Windows
Documentation
use crate::config::{Config, WebSearchProvider};
use crate::model::{normalize_for_search, SearchItem};
use crate::uninstall_registry::{has_uninstall_intent, search_uninstall_actions};

pub const ACTION_OPEN_LOGS_ID: &str = "__nex_action_open_logs__";
pub const ACTION_REBUILD_INDEX_ID: &str = "__nex_action_rebuild_index__";
pub const ACTION_CLEAR_CLIPBOARD_ID: &str = "__nex_action_clear_clipboard__";
pub const ACTION_OPEN_CONFIG_ID: &str = "__nex_action_open_config__";
pub const ACTION_DIAGNOSTICS_BUNDLE_ID: &str = "__nex_action_diagnostics_bundle__";
pub const ACTION_TRIM_MEMORY_ID: &str = "__nex_action_trim_memory__";
pub const ACTION_CHECK_UPDATES_ID: &str = "__nex_action_check_updates__";
pub const ACTION_WEB_SEARCH_PREFIX: &str = "__nex_action_web_search__:";

#[derive(Debug, Clone, Copy)]
pub struct BuiltInAction {
    pub id: &'static str,
    pub title: &'static str,
    pub subtitle: &'static str,
    pub keywords: &'static [&'static str],
}

pub fn built_in_actions() -> &'static [BuiltInAction] {
    &[
        BuiltInAction {
            id: ACTION_OPEN_LOGS_ID,
            title: "Open Nex Logs Folder",
            subtitle: "Open logs directory in File Explorer",
            keywords: &["logs", "log", "debug"],
        },
        BuiltInAction {
            id: ACTION_REBUILD_INDEX_ID,
            title: "Rebuild Search Index",
            subtitle: "Force a full refresh of indexed items",
            keywords: &["rebuild", "index", "refresh"],
        },
        BuiltInAction {
            id: ACTION_CLEAR_CLIPBOARD_ID,
            title: "Clear Clipboard History",
            subtitle: "Delete local clipboard history entries",
            keywords: &["clipboard", "clear", "history"],
        },
        BuiltInAction {
            id: ACTION_OPEN_CONFIG_ID,
            title: "Open Nex Config",
            subtitle: "Open config.toml",
            keywords: &["config", "settings", "preferences"],
        },
        BuiltInAction {
            id: ACTION_DIAGNOSTICS_BUNDLE_ID,
            title: "Create Diagnostics Bundle",
            subtitle: "Export logs and sanitized config for support",
            keywords: &["diagnostics", "support", "bundle", "debug"],
        },
        BuiltInAction {
            id: ACTION_CHECK_UPDATES_ID,
            title: "Check for Updates",
            subtitle: "Run the stable Windows updater",
            keywords: &["update", "upgrade", "stable", "install latest"],
        },
        BuiltInAction {
            id: ACTION_TRIM_MEMORY_ID,
            title: "Trim Memory Now",
            subtitle: "Clear overlay icon/query caches and log memory snapshot",
            keywords: &["memory", "trim", "cache", "compact"],
        },
    ]
}

pub fn search_actions(query: &str, limit: usize) -> Vec<SearchItem> {
    search_actions_with_mode(query, limit, false, &Config::default())
}

pub fn search_actions_with_mode(
    query: &str,
    limit: usize,
    command_mode: bool,
    cfg: &Config,
) -> Vec<SearchItem> {
    if limit == 0 {
        return Vec::new();
    }
    let trimmed_query = query.trim();
    let normalized = normalize_for_search(trimmed_query);
    let mut out = Vec::new();
    let uninstall_intent = cfg.uninstall_actions_enabled && has_uninstall_intent(trimmed_query);

    if command_mode {
        if !uninstall_intent {
            if let Some(web_action) = dynamic_provider_web_search_action(trimmed_query, cfg) {
                out.push(web_action);
                if out.len() >= limit {
                    return out;
                }
            }
        }

        let remaining = limit.saturating_sub(out.len());
        if remaining > 0 && cfg.uninstall_actions_enabled {
            let uninstall_actions = search_uninstall_actions(trimmed_query, remaining);
            out.extend(uninstall_actions);
            if out.len() >= limit {
                return out;
            }
        }
    }

    for action in built_in_actions() {
        if !normalized.is_empty() {
            let title_match = normalize_for_search(action.title).contains(&normalized);
            let keyword_match = action
                .keywords
                .iter()
                .any(|kw| normalize_for_search(kw).contains(&normalized));
            if !title_match && !keyword_match {
                continue;
            }
        }
        out.push(SearchItem::new(
            action.id,
            "action",
            action.title,
            action.subtitle,
        ));
        if out.len() >= limit {
            break;
        }
    }

    out
}

pub fn provider_web_search_url(cfg: &Config, query: &str) -> Option<String> {
    let encoded = url_encode_component(query.trim());
    let url = match cfg.web_search_provider {
        WebSearchProvider::Duckduckgo => format!("https://duckduckgo.com/?q={encoded}"),
        WebSearchProvider::Google => format!("https://www.google.com/search?q={encoded}"),
        WebSearchProvider::Bing => format!("https://www.bing.com/search?q={encoded}"),
        WebSearchProvider::Brave => format!("https://search.brave.com/search?q={encoded}"),
        WebSearchProvider::Startpage => {
            format!("https://www.startpage.com/sp/search?query={encoded}")
        }
        WebSearchProvider::Ecosia => format!("https://www.ecosia.org/search?q={encoded}"),
        WebSearchProvider::Yahoo => format!("https://search.yahoo.com/search?p={encoded}"),
        WebSearchProvider::Custom => {
            let template = cfg.web_search_custom_template.trim();
            if template.is_empty() || !template.contains("{query}") {
                return None;
            }
            template.replace("{query}", &encoded)
        }
    };
    Some(url)
}

fn dynamic_provider_web_search_action(query: &str, cfg: &Config) -> Option<SearchItem> {
    let trimmed = query.trim();
    if trimmed.is_empty() {
        return None;
    }
    let url = provider_web_search_url(cfg, trimmed)?;
    let id = format!("{ACTION_WEB_SEARCH_PREFIX}{trimmed}");
    Some(SearchItem::new(
        &id,
        "action",
        &format!("Search Web for \"{trimmed}\""),
        &url,
    ))
}

fn url_encode_component(input: &str) -> String {
    let mut out = String::new();
    for byte in input.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
            out.push(byte as char);
        } else if byte == b' ' {
            out.push('+');
        } else {
            out.push('%');
            out.push_str(&format!("{byte:02X}"));
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::{
        search_actions, search_actions_with_mode, ACTION_CHECK_UPDATES_ID, ACTION_WEB_SEARCH_PREFIX,
    };
    use crate::config::{Config, WebSearchProvider};

    #[test]
    fn filters_actions_by_query() {
        let actions = search_actions("diag", 10);
        assert!(actions
            .iter()
            .any(|action| action.id == "__nex_action_diagnostics_bundle__"));
    }

    #[test]
    fn command_mode_includes_web_search_action() {
        let cfg = Config::default();
        let actions = search_actions_with_mode("rust icons", 10, true, &cfg);
        assert!(actions
            .iter()
            .any(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
    }

    #[test]
    fn non_command_mode_omits_web_search_action() {
        let cfg = Config::default();
        let actions = search_actions_with_mode("rust icons", 10, false, &cfg);
        assert!(!actions
            .iter()
            .any(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
    }

    #[test]
    fn command_mode_respects_configured_provider() {
        let mut cfg = Config::default();
        cfg.web_search_provider = WebSearchProvider::Google;

        let actions = search_actions_with_mode("rust icons", 10, true, &cfg);
        let provider = actions
            .iter()
            .find(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX))
            .expect("provider web action should exist");
        assert!(provider.path.contains("google.com/search?q="));
    }

    #[test]
    fn uninstall_intent_hides_web_action() {
        let cfg = Config::default();
        let actions = search_actions_with_mode("u notepad", 20, true, &cfg);
        assert!(!actions
            .iter()
            .any(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
    }

    #[test]
    fn built_in_actions_include_check_for_updates() {
        let cfg = Config::default();
        let actions = search_actions_with_mode("update", 10, true, &cfg);
        assert!(actions
            .iter()
            .any(|action| action.id == ACTION_CHECK_UPDATES_ID));
    }
}