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}