Skip to main content

nex_core/
action_registry.rs

1use crate::config::{Config, WebSearchProvider};
2use crate::model::{normalize_for_search, SearchItem};
3use crate::uninstall_registry::{has_uninstall_intent, search_uninstall_actions};
4
5pub const ACTION_OPEN_LOGS_ID: &str = "__nex_action_open_logs__";
6pub const ACTION_REBUILD_INDEX_ID: &str = "__nex_action_rebuild_index__";
7pub const ACTION_CLEAR_CLIPBOARD_ID: &str = "__nex_action_clear_clipboard__";
8pub const ACTION_OPEN_CONFIG_ID: &str = "__nex_action_open_config__";
9pub const ACTION_DIAGNOSTICS_BUNDLE_ID: &str = "__nex_action_diagnostics_bundle__";
10pub const ACTION_TRIM_MEMORY_ID: &str = "__nex_action_trim_memory__";
11pub const ACTION_CHECK_UPDATES_ID: &str = "__nex_action_check_updates__";
12pub const ACTION_WEB_SEARCH_PREFIX: &str = "__nex_action_web_search__:";
13
14#[derive(Debug, Clone, Copy)]
15pub struct BuiltInAction {
16    pub id: &'static str,
17    pub title: &'static str,
18    pub subtitle: &'static str,
19    pub keywords: &'static [&'static str],
20}
21
22pub fn built_in_actions() -> &'static [BuiltInAction] {
23    &[
24        BuiltInAction {
25            id: ACTION_OPEN_LOGS_ID,
26            title: "Open Nex Logs Folder",
27            subtitle: "Open logs directory in File Explorer",
28            keywords: &["logs", "log", "debug"],
29        },
30        BuiltInAction {
31            id: ACTION_REBUILD_INDEX_ID,
32            title: "Rebuild Search Index",
33            subtitle: "Force a full refresh of indexed items",
34            keywords: &["rebuild", "index", "refresh"],
35        },
36        BuiltInAction {
37            id: ACTION_CLEAR_CLIPBOARD_ID,
38            title: "Clear Clipboard History",
39            subtitle: "Delete local clipboard history entries",
40            keywords: &["clipboard", "clear", "history"],
41        },
42        BuiltInAction {
43            id: ACTION_OPEN_CONFIG_ID,
44            title: "Open Nex Config",
45            subtitle: "Open config.toml",
46            keywords: &["config", "settings", "preferences"],
47        },
48        BuiltInAction {
49            id: ACTION_DIAGNOSTICS_BUNDLE_ID,
50            title: "Create Diagnostics Bundle",
51            subtitle: "Export logs and sanitized config for support",
52            keywords: &["diagnostics", "support", "bundle", "debug"],
53        },
54        BuiltInAction {
55            id: ACTION_CHECK_UPDATES_ID,
56            title: "Check for Updates",
57            subtitle: "Run the stable Windows updater",
58            keywords: &["update", "upgrade", "stable", "install latest"],
59        },
60        BuiltInAction {
61            id: ACTION_TRIM_MEMORY_ID,
62            title: "Trim Memory Now",
63            subtitle: "Clear overlay icon/query caches and log memory snapshot",
64            keywords: &["memory", "trim", "cache", "compact"],
65        },
66    ]
67}
68
69pub fn search_actions(query: &str, limit: usize) -> Vec<SearchItem> {
70    search_actions_with_mode(query, limit, false, &Config::default())
71}
72
73pub fn search_actions_with_mode(
74    query: &str,
75    limit: usize,
76    command_mode: bool,
77    cfg: &Config,
78) -> Vec<SearchItem> {
79    if limit == 0 {
80        return Vec::new();
81    }
82    let trimmed_query = query.trim();
83    let normalized = normalize_for_search(trimmed_query);
84    let mut out = Vec::new();
85    let uninstall_intent = cfg.uninstall_actions_enabled && has_uninstall_intent(trimmed_query);
86
87    if command_mode {
88        if !uninstall_intent {
89            if let Some(web_action) = dynamic_provider_web_search_action(trimmed_query, cfg) {
90                out.push(web_action);
91                if out.len() >= limit {
92                    return out;
93                }
94            }
95        }
96
97        let remaining = limit.saturating_sub(out.len());
98        if remaining > 0 && cfg.uninstall_actions_enabled {
99            let uninstall_actions = search_uninstall_actions(trimmed_query, remaining);
100            out.extend(uninstall_actions);
101            if out.len() >= limit {
102                return out;
103            }
104        }
105    }
106
107    for action in built_in_actions() {
108        if !normalized.is_empty() {
109            let title_match = normalize_for_search(action.title).contains(&normalized);
110            let keyword_match = action
111                .keywords
112                .iter()
113                .any(|kw| normalize_for_search(kw).contains(&normalized));
114            if !title_match && !keyword_match {
115                continue;
116            }
117        }
118        out.push(SearchItem::new(
119            action.id,
120            "action",
121            action.title,
122            action.subtitle,
123        ));
124        if out.len() >= limit {
125            break;
126        }
127    }
128
129    out
130}
131
132pub fn provider_web_search_url(cfg: &Config, query: &str) -> Option<String> {
133    let encoded = url_encode_component(query.trim());
134    let url = match cfg.web_search_provider {
135        WebSearchProvider::Duckduckgo => format!("https://duckduckgo.com/?q={encoded}"),
136        WebSearchProvider::Google => format!("https://www.google.com/search?q={encoded}"),
137        WebSearchProvider::Bing => format!("https://www.bing.com/search?q={encoded}"),
138        WebSearchProvider::Brave => format!("https://search.brave.com/search?q={encoded}"),
139        WebSearchProvider::Startpage => {
140            format!("https://www.startpage.com/sp/search?query={encoded}")
141        }
142        WebSearchProvider::Ecosia => format!("https://www.ecosia.org/search?q={encoded}"),
143        WebSearchProvider::Yahoo => format!("https://search.yahoo.com/search?p={encoded}"),
144        WebSearchProvider::Custom => {
145            let template = cfg.web_search_custom_template.trim();
146            if template.is_empty() || !template.contains("{query}") {
147                return None;
148            }
149            template.replace("{query}", &encoded)
150        }
151    };
152    Some(url)
153}
154
155fn dynamic_provider_web_search_action(query: &str, cfg: &Config) -> Option<SearchItem> {
156    let trimmed = query.trim();
157    if trimmed.is_empty() {
158        return None;
159    }
160    let url = provider_web_search_url(cfg, trimmed)?;
161    let id = format!("{ACTION_WEB_SEARCH_PREFIX}{trimmed}");
162    Some(SearchItem::new(
163        &id,
164        "action",
165        &format!("Search Web for \"{trimmed}\""),
166        &url,
167    ))
168}
169
170fn url_encode_component(input: &str) -> String {
171    let mut out = String::new();
172    for byte in input.bytes() {
173        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
174            out.push(byte as char);
175        } else if byte == b' ' {
176            out.push('+');
177        } else {
178            out.push('%');
179            out.push_str(&format!("{byte:02X}"));
180        }
181    }
182    out
183}
184
185#[cfg(test)]
186mod tests {
187    use super::{
188        search_actions, search_actions_with_mode, ACTION_CHECK_UPDATES_ID,
189        ACTION_WEB_SEARCH_PREFIX,
190    };
191    use crate::config::{Config, WebSearchProvider};
192
193    #[test]
194    fn filters_actions_by_query() {
195        let actions = search_actions("diag", 10);
196        assert!(actions
197            .iter()
198            .any(|action| action.id == "__nex_action_diagnostics_bundle__"));
199    }
200
201    #[test]
202    fn command_mode_includes_web_search_action() {
203        let cfg = Config::default();
204        let actions = search_actions_with_mode("rust icons", 10, true, &cfg);
205        assert!(actions
206            .iter()
207            .any(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
208    }
209
210    #[test]
211    fn non_command_mode_omits_web_search_action() {
212        let cfg = Config::default();
213        let actions = search_actions_with_mode("rust icons", 10, false, &cfg);
214        assert!(!actions
215            .iter()
216            .any(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
217    }
218
219    #[test]
220    fn command_mode_respects_configured_provider() {
221        let mut cfg = Config::default();
222        cfg.web_search_provider = WebSearchProvider::Google;
223
224        let actions = search_actions_with_mode("rust icons", 10, true, &cfg);
225        let provider = actions
226            .iter()
227            .find(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX))
228            .expect("provider web action should exist");
229        assert!(provider.path.contains("google.com/search?q="));
230    }
231
232    #[test]
233    fn uninstall_intent_hides_web_action() {
234        let cfg = Config::default();
235        let actions = search_actions_with_mode("u notepad", 20, true, &cfg);
236        assert!(!actions
237            .iter()
238            .any(|action| action.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
239    }
240
241    #[test]
242    fn built_in_actions_include_check_for_updates() {
243        let cfg = Config::default();
244        let actions = search_actions_with_mode("update", 10, true, &cfg);
245        assert!(actions
246            .iter()
247            .any(|action| action.id == ACTION_CHECK_UPDATES_ID));
248    }
249}