1use crate::action_registry::{
2 search_actions_with_mode, ACTION_CHECK_UPDATES_ID, ACTION_CLEAR_CLIPBOARD_ID,
3 ACTION_DIAGNOSTICS_BUNDLE_ID, ACTION_OPEN_CONFIG_ID, ACTION_OPEN_LOGS_ID,
4 ACTION_REBUILD_INDEX_ID, ACTION_TRIM_MEMORY_ID, ACTION_WEB_SEARCH_PREFIX,
5};
6use crate::clipboard_history;
7use crate::config::{self, Config, ConfigError};
8use crate::core_service::{CoreService, LaunchTarget, ServiceError};
9use crate::hotkey_runtime::HotkeyRuntimeError;
10#[cfg(target_os = "windows")]
11use crate::hotkey_runtime::{default_hotkey_registrar, HotkeyRegistration};
12#[cfg(target_os = "windows")]
13use crate::overlay_state::{HotkeyAction, OverlayState};
14use crate::plugin_sdk::{PluginActionKind, PluginRegistry};
15use crate::query_dsl::ParsedQuery;
16use crate::search::SearchFilter;
17#[cfg(target_os = "windows")]
18use crate::windows_overlay::{
19 is_instance_window_present, signal_existing_instance_quit, signal_existing_instance_show,
20 NativeOverlayShell, OverlayEvent, OverlayRow, OverlayRowRole,
21};
22use std::collections::{HashMap, VecDeque};
23use std::sync::atomic::{AtomicBool, Ordering};
24#[cfg(target_os = "windows")]
25use std::sync::{Arc, Mutex};
26use std::time::Instant;
27#[cfg(target_os = "windows")]
28use std::time::{Duration, SystemTime};
29
30#[cfg(target_os = "windows")]
31const STATUS_ROW_NO_RESULTS: &str = "No results";
32#[cfg(target_os = "windows")]
33const STATUS_ROW_NO_COMMAND_RESULTS: &str = "No command matches";
34#[cfg(target_os = "windows")]
35const STATUS_ROW_TYPE_TO_SEARCH: &str = "Start typing to search";
36#[cfg(target_os = "windows")]
37const STATUS_ROW_INDEXING: &str = "Indexing in background...";
38#[cfg(target_os = "windows")]
39const STATUS_TEXT_INDEX_READY: &str = "Index ready";
40const QUERY_PROFILE_LOG_THRESHOLD_MS: u128 = 35;
41const SHORT_QUERY_APP_BIAS_MAX_LEN: usize = 2;
42const INDEXED_PREFIX_CACHE_MIN_QUERY_LEN: usize = 1;
43const INDEXED_PREFIX_CACHE_MIN_SEED_LIMIT: usize = 120;
44const INDEXED_PREFIX_CACHE_MAX_SEED_LIMIT: usize = 480;
45const QUERY_PROFILE_STATUS_SAMPLE_WINDOW: usize = 400;
46const FINAL_QUERY_CACHE_MAX_ENTRIES: usize = 32;
47const ADAPTIVE_INDEXED_LATENCY_WINDOW: usize = 24;
48#[cfg(target_os = "windows")]
49const QUEUED_DISCOVERY_REINDEX_DEBOUNCE_MS: u64 = 1200;
50#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
51const UNINSTALL_QUERY_RESULT_LIMIT: usize = 160;
52#[cfg(target_os = "windows")]
53const CONFIG_RELOAD_POLL_INTERVAL: Duration = Duration::from_millis(500);
54const ACTION_UNINSTALL_CONFIRM_ID: &str = "action:uninstall:confirm";
55const ACTION_UNINSTALL_CANCEL_ID: &str = "action:uninstall:cancel";
56static STDIO_LOGGING_ENABLED: AtomicBool = AtomicBool::new(true);
57#[cfg(target_os = "windows")]
58const CURRENT_RUNTIME_EXE_NAME: &str = "nex.exe";
59#[cfg(target_os = "windows")]
60const LEGACY_RUNTIME_EXE_NAMES: &[&str] = &["nex-core.exe", "swiftfind-core.exe"];
61const CURRENT_LOG_PREFIX: &str = "[nex]";
62const LEGACY_LOG_PREFIXES: &[&str] = &["[nex-core]", "[swiftfind-core]"];
63
64fn env_var_with_legacy(current: &str, legacy: &str) -> Result<String, std::env::VarError> {
65 std::env::var(current).or_else(|_| std::env::var(legacy))
66}
67
68#[cfg(target_os = "windows")]
69fn runtime_executable_names() -> impl Iterator<Item = &'static str> {
70 std::iter::once(CURRENT_RUNTIME_EXE_NAME).chain(LEGACY_RUNTIME_EXE_NAMES.iter().copied())
71}
72
73fn runtime_log_prefixes() -> impl Iterator<Item = &'static str> {
74 std::iter::once(CURRENT_LOG_PREFIX).chain(LEGACY_LOG_PREFIXES.iter().copied())
75}
76
77fn runtime_log_marker(prefix: &str, marker: &str) -> String {
78 format!("{prefix} {marker}")
79}
80
81fn rfind_runtime_log_marker(content: &str, marker: &str) -> Option<usize> {
82 runtime_log_prefixes()
83 .filter_map(|prefix| content.rfind(&runtime_log_marker(prefix, marker)))
84 .max()
85}
86
87fn line_contains_runtime_log_marker(line: &str, marker: &str) -> bool {
88 runtime_log_prefixes().any(|prefix| line.contains(&runtime_log_marker(prefix, marker)))
89}
90
91#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
92fn hotkey_registration_recovery_message(hotkey: &str, config_path: &std::path::Path) -> String {
93 let suggestions = crate::settings::suggested_hotkey_presets(hotkey, 3);
94 if suggestions.is_empty() {
95 return format!(
96 "Hotkey '{hotkey}' is unavailable. Open {} and choose a different modifier+key combination.",
97 config_path.display()
98 );
99 }
100
101 format!(
102 "Hotkey '{hotkey}' is unavailable. Try {}. Edit {} to change it.",
103 suggestions.join(", "),
104 config_path.display()
105 )
106}
107
108#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
109fn hotkey_registration_status_text(hotkey: &str) -> String {
110 let suggestions = crate::settings::suggested_hotkey_presets(hotkey, 2);
111 if suggestions.is_empty() {
112 return format!("Hotkey unavailable: {hotkey}. Open config from the tray.");
113 }
114
115 format!(
116 "Hotkey unavailable: {hotkey}. Try {}.",
117 suggestions.join(" or ")
118 )
119}
120
121fn launch_stable_updater() -> Result<std::path::PathBuf, String> {
122 let script_path = crate::updater::launch_updater(crate::updater::UpdateChannel::Stable)
123 .map_err(|error| error.to_string())?;
124 log_info(&format!(
125 "[nex] updater_launch channel=stable script={}",
126 script_path.display()
127 ));
128 Ok(script_path)
129}
130
131#[derive(Debug, Clone, Default)]
132struct OverlaySearchSession {
133 indexed_prefix_cache: Option<IndexedPrefixCache>,
134 final_query_cache: HashMap<String, Vec<crate::model::SearchItem>>,
135 final_query_cache_lru: VecDeque<String>,
136 indexed_latency_ms: VecDeque<u128>,
137}
138
139impl OverlaySearchSession {
140 fn clear(&mut self) {
141 self.indexed_prefix_cache = None;
142 self.final_query_cache.clear();
143 self.final_query_cache_lru.clear();
144 self.indexed_latency_ms.clear();
145 }
146}
147
148#[derive(Debug, Clone)]
149struct IndexedPrefixCache {
150 normalized_query: String,
151 indexed_filter: SearchFilter,
152 seed_items: Vec<crate::model::SearchItem>,
153}
154
155#[cfg(target_os = "windows")]
156#[derive(Debug, Clone)]
157struct PendingUninstallConfirmation {
158 uninstall_action: crate::model::SearchItem,
159 previous_results: Vec<crate::model::SearchItem>,
160 previous_selected_index: usize,
161 previous_command_mode: bool,
162}
163
164#[cfg(target_os = "windows")]
165#[derive(Debug)]
166struct RuntimeConfigWatcher {
167 path: std::path::PathBuf,
168 last_checked: Instant,
169 last_modified: Option<SystemTime>,
170}
171
172#[cfg(target_os = "windows")]
173#[derive(Debug)]
174struct BackgroundIndexRefresh {
175 completed: Arc<AtomicBool>,
176 result: Arc<Mutex<Option<Result<crate::core_service::IndexRefreshReport, String>>>>,
177 cache_applied: bool,
178 initial_cache_empty: bool,
179 pending_discovery_reindex: bool,
180 pending_discovery_reindex_due_at: Option<Instant>,
181 pending_discovery_reindex_requests: usize,
182 ready_notice_pending: bool,
183 started_at: Instant,
184 startup_started_at: Instant,
185}
186
187#[derive(Debug)]
188pub enum RuntimeError {
189 Args(String),
190 Config(ConfigError),
191 Service(ServiceError),
192 Hotkey(HotkeyRuntimeError),
193 Overlay(String),
194 Startup(crate::startup::StartupError),
195 Io(std::io::Error),
196}
197
198impl std::fmt::Display for RuntimeError {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self {
201 Self::Args(error) => write!(f, "argument error: {error}"),
202 Self::Config(error) => write!(f, "config error: {error}"),
203 Self::Service(error) => write!(f, "service error: {error}"),
204 Self::Hotkey(error) => write!(f, "hotkey runtime error: {error:?}"),
205 Self::Overlay(error) => write!(f, "overlay error: {error}"),
206 Self::Startup(error) => write!(f, "startup error: {error}"),
207 Self::Io(error) => write!(f, "io error: {error}"),
208 }
209 }
210}
211
212impl std::error::Error for RuntimeError {}
213
214impl From<ConfigError> for RuntimeError {
215 fn from(value: ConfigError) -> Self {
216 Self::Config(value)
217 }
218}
219
220impl From<ServiceError> for RuntimeError {
221 fn from(value: ServiceError) -> Self {
222 Self::Service(value)
223 }
224}
225
226impl From<HotkeyRuntimeError> for RuntimeError {
227 fn from(value: HotkeyRuntimeError) -> Self {
228 Self::Hotkey(value)
229 }
230}
231
232impl From<crate::startup::StartupError> for RuntimeError {
233 fn from(value: crate::startup::StartupError) -> Self {
234 Self::Startup(value)
235 }
236}
237
238impl From<std::io::Error> for RuntimeError {
239 fn from(value: std::io::Error) -> Self {
240 Self::Io(value)
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum RuntimeCommand {
246 Run,
247 Status,
248 StatusJson,
249 Quit,
250 Restart,
251 EnsureConfig,
252 SyncStartup,
253 SetLaunchAtStartup(bool),
254 DiagnosticsBundle,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct RuntimeOptions {
259 pub command: RuntimeCommand,
260 pub background: bool,
261}
262
263impl Default for RuntimeOptions {
264 fn default() -> Self {
265 Self {
266 command: RuntimeCommand::Run,
267 background: false,
268 }
269 }
270}
271
272pub fn parse_cli_args(args: &[String]) -> Result<RuntimeOptions, String> {
273 let mut options = RuntimeOptions::default();
274 for arg in args {
275 if let Some(value) = arg.strip_prefix("--set-launch-at-startup=") {
276 let enabled = match value.trim().to_ascii_lowercase().as_str() {
277 "true" | "1" | "yes" | "on" => true,
278 "false" | "0" | "no" | "off" => false,
279 _ => {
280 return Err(format!(
281 "invalid value for --set-launch-at-startup: {value} (expected true/false)"
282 ));
283 }
284 };
285 options.command = RuntimeCommand::SetLaunchAtStartup(enabled);
286 continue;
287 }
288
289 match arg.as_str() {
290 "--background" => options.background = true,
291 "--foreground" => options.background = false,
292 "--status" => options.command = RuntimeCommand::Status,
293 "--status-json" => options.command = RuntimeCommand::StatusJson,
294 "--quit" => options.command = RuntimeCommand::Quit,
295 "--restart" => options.command = RuntimeCommand::Restart,
296 "--ensure-config" => options.command = RuntimeCommand::EnsureConfig,
297 "--sync-startup" => options.command = RuntimeCommand::SyncStartup,
298 "--diagnostics-bundle" => options.command = RuntimeCommand::DiagnosticsBundle,
299 "--help" | "-h" => {
300 return Err(
301 "usage: nex [--background|--foreground] [--status|--status-json|--quit|--restart|--ensure-config|--sync-startup|--set-launch-at-startup=true|false|--diagnostics-bundle]".to_string(),
302 )
303 }
304 unknown => return Err(format!("unknown argument: {unknown}")),
305 }
306 }
307
308 if options.command != RuntimeCommand::Run && options.background {
309 return Err("background mode is only valid with normal run mode".to_string());
310 }
311
312 Ok(options)
313}
314
315pub fn run() -> Result<(), RuntimeError> {
316 run_with_options(RuntimeOptions::default())
317}
318
319pub fn run_with_options(options: RuntimeOptions) -> Result<(), RuntimeError> {
320 configure_stdio_logging(options);
321
322 if let Err(error) = crate::logging::init() {
323 log_warn(&format!("[nex] logging init warning: {error}"));
324 }
325
326 #[cfg(target_os = "windows")]
327 if options.background && options.command == RuntimeCommand::Run {
328 return spawn_background_process();
329 }
330
331 match options.command {
332 RuntimeCommand::Status => return command_status(),
333 RuntimeCommand::StatusJson => return command_status_json(),
334 RuntimeCommand::Quit => return command_quit(),
335 RuntimeCommand::Restart => return command_restart(),
336 RuntimeCommand::EnsureConfig => return command_ensure_config(),
337 RuntimeCommand::SyncStartup => return command_sync_startup(),
338 RuntimeCommand::SetLaunchAtStartup(enabled) => {
339 return command_set_launch_at_startup(enabled);
340 }
341 RuntimeCommand::DiagnosticsBundle => return command_diagnostics_bundle(),
342 RuntimeCommand::Run => {}
343 }
344
345 #[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
346 let startup_started_at = Instant::now();
347 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
348 let mut runtime_config = config::load(None)?;
349 if !runtime_config.config_path.exists() {
350 config::write_user_template(&runtime_config, &runtime_config.config_path)?;
351 log_info(&format!(
352 "[nex] wrote user config template to {}",
353 runtime_config.config_path.display()
354 ));
355 }
356 log_info(&format!(
357 "[nex] startup mode={} hotkey={} config_path={} index_db_path={}",
358 runtime_mode(),
359 runtime_config.hotkey,
360 runtime_config.config_path.display(),
361 runtime_config.index_db_path.display(),
362 ));
363
364 let service = CoreService::new(runtime_config.clone())?.with_runtime_providers();
365 #[cfg(target_os = "windows")]
366 let mut background_index_refresh = {
367 let initial_cached_items = service.cached_items_len();
368 log_info(&format!(
369 "[nex] startup cached_items={} (async indexing scheduled)",
370 initial_cached_items
371 ));
372 log_info(&format!(
373 "[nex] startup_phase phase=indexing_started elapsed_ms={} initial_cache_empty={} cached_items={}",
374 startup_started_at.elapsed().as_millis(),
375 initial_cached_items == 0,
376 initial_cached_items
377 ));
378 start_background_index_refresh(
379 &runtime_config,
380 initial_cached_items == 0,
381 startup_started_at,
382 )
383 };
384 #[cfg(not(target_os = "windows"))]
385 {
386 let index_report = service.rebuild_index_incremental_with_report()?;
387 log_info(&format!(
388 "[nex] startup indexed_items={} discovered={} upserted={} removed={}",
389 index_report.indexed_total,
390 index_report.discovered_total,
391 index_report.upserted_total,
392 index_report.removed_total,
393 ));
394 for provider in &index_report.providers {
395 log_info(&format!(
396 "[nex] index_provider name={} discovered={} upserted={} removed={} skipped={} elapsed_ms={}",
397 provider.provider,
398 provider.discovered,
399 provider.upserted,
400 provider.removed,
401 provider.skipped,
402 provider.elapsed_ms,
403 ));
404 }
405 }
406 #[cfg(target_os = "windows")]
407 let mut plugin_registry = PluginRegistry::load_from_config(&runtime_config);
408 #[cfg(target_os = "windows")]
409 {
410 for warning in &plugin_registry.load_warnings {
411 log_warn(&format!("[nex] plugin_warning {warning}"));
412 }
413 log_info(&format!(
414 "[nex] plugins loaded provider_items={} action_items={}",
415 plugin_registry.provider_items.len(),
416 plugin_registry.action_items.len()
417 ));
418 }
419
420 #[cfg(target_os = "windows")]
421 {
422 unsafe {
424 let _ = windows_sys::Win32::UI::HiDpi::SetProcessDpiAwarenessContext(
425 windows_sys::Win32::UI::HiDpi::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
426 );
427 }
428
429 if let Ok(exe) = std::env::current_exe() {
430 if let Err(error) = crate::startup::set_enabled(runtime_config.launch_at_startup, &exe)
431 {
432 log_warn(&format!("[nex] startup sync warning: {error}"));
433 }
434 }
435
436 let _single_instance = match acquire_single_instance_guard() {
437 Ok(guard) => guard,
438 Err(error) => return Err(RuntimeError::Overlay(error)),
439 };
440 if _single_instance.is_none() {
441 let _ = signal_existing_instance_show();
442 log_info("[nex] runtime already active; signaled existing instance");
443 return Ok(());
444 }
445
446 let mut overlay_state = OverlayState::default();
447 let overlay = NativeOverlayShell::create().map_err(RuntimeError::Overlay)?;
448 overlay.set_help_config_path(runtime_config.config_path.to_string_lossy().as_ref());
449 overlay.set_hotkey_hint(&runtime_config.hotkey);
450 overlay.set_performance_tuning(
451 runtime_config.idle_cache_trim_ms,
452 runtime_config.active_memory_target_mb,
453 );
454 overlay.set_game_mode_enabled(runtime_config.game_mode_enabled);
455 log_info("[nex] native overlay shell initialized (hidden)");
456 log_info(&format!(
457 "[nex] startup_phase phase=overlay_ready elapsed_ms={}",
458 startup_started_at.elapsed().as_millis()
459 ));
460
461 let mut registrar = default_hotkey_registrar();
462 let hotkey_issue_status = match registrar.register_hotkey(&runtime_config.hotkey) {
463 Ok(registration) => {
464 log_registration(®istration);
465 overlay.set_hotkey_issue_active(false);
466 None
467 }
468 Err(error) => {
469 let recovery_message = hotkey_registration_recovery_message(
470 &runtime_config.hotkey,
471 &runtime_config.config_path,
472 );
473 let suggested = crate::settings::suggested_hotkey_presets(
474 &runtime_config.hotkey,
475 3,
476 )
477 .join("|");
478 log_warn(&format!(
479 "[nex] hotkey_registration_issue hotkey={} suggestions={} error={:?}",
480 runtime_config.hotkey, suggested, error
481 ));
482 log_warn(&format!("[nex] {recovery_message}"));
483 overlay.set_hotkey_issue_active(true);
484 Some(hotkey_registration_status_text(&runtime_config.hotkey))
485 }
486 };
487 log_info(&format!(
488 "[nex] startup_phase phase=hotkey_ready elapsed_ms={} hotkey={}",
489 startup_started_at.elapsed().as_millis(),
490 runtime_config.hotkey
491 ));
492 log_info("[nex] event loop running (native overlay)");
493
494 let mut max_results = runtime_config.max_results as usize;
495 let mut config_watcher = RuntimeConfigWatcher {
496 path: runtime_config.config_path.clone(),
497 last_checked: Instant::now(),
498 last_modified: config_file_modified_time(runtime_config.config_path.as_path()),
499 };
500 let mut current_results: Vec<crate::model::SearchItem> = Vec::new();
501 let mut suppressed_uninstall_titles: Vec<String> = Vec::new();
502 let mut pending_uninstall_confirmation: Option<PendingUninstallConfirmation> = None;
503 let mut pending_delayed_query: Option<String> = None;
504 let mut selected_index = 0_usize;
505 let mut last_query = String::new();
506 let mut search_session = OverlaySearchSession::default();
507
508 overlay
509 .run_message_loop_with_events(|event| {
510 maybe_apply_runtime_config_reload(
511 &overlay,
512 &service,
513 &mut runtime_config,
514 &mut plugin_registry,
515 &mut search_session,
516 &mut pending_uninstall_confirmation,
517 &mut max_results,
518 &mut config_watcher,
519 &mut background_index_refresh,
520 );
521 maybe_apply_background_index_refresh(
522 &service,
523 &mut background_index_refresh,
524 &runtime_config,
525 );
526 maybe_show_background_index_ready_notice(&overlay, &mut background_index_refresh);
527 match event {
528 OverlayEvent::Hotkey(_) => {
529 log_info("[nex] hotkey_event received");
530 let overlay_visible = overlay.is_visible();
531 overlay_state.set_visible(overlay_visible);
532 if !overlay_visible
533 && should_suppress_hotkey_for_game_mode(&runtime_config)
534 {
535 log_info(
536 "[nex] hotkey ignored because game mode is active for the foreground app",
537 );
538 return;
539 }
540 let action = overlay_state.on_hotkey(overlay.has_focus());
541 match action {
542 HotkeyAction::ShowAndFocus | HotkeyAction::FocusExisting => {
543 reconcile_suppressed_uninstall_titles(
544 &mut suppressed_uninstall_titles,
545 );
546 overlay.show_and_focus();
547 if runtime_config.clipboard_enabled {
548 let _ =
549 clipboard_history::maybe_capture_latest(&runtime_config);
550 }
551 if overlay.query_text().trim().is_empty() {
552 set_idle_overlay_state(&overlay);
553 if let Some(issue) = hotkey_issue_status.as_deref() {
554 overlay.set_status_text(issue);
555 }
556 maybe_show_background_index_ready_notice(
557 &overlay,
558 &mut background_index_refresh,
559 );
560 }
561 }
562 HotkeyAction::Hide => {
563 pending_delayed_query = None;
564 overlay.cancel_query_delay();
565 overlay.hide();
566 reset_overlay_session(
567 &overlay,
568 &mut current_results,
569 &mut selected_index,
570 );
571 pending_uninstall_confirmation = None;
572 last_query.clear();
573 search_session.clear();
574 maybe_apply_background_index_refresh(
575 &service,
576 &mut background_index_refresh,
577 &runtime_config,
578 );
579 }
580 }
581 }
582 OverlayEvent::ExternalShow => {
583 overlay_state.set_visible(overlay.is_visible());
584 reconcile_suppressed_uninstall_titles(&mut suppressed_uninstall_titles);
585 overlay.show_and_focus();
586 if runtime_config.clipboard_enabled {
587 let _ = clipboard_history::maybe_capture_latest(&runtime_config);
588 }
589 if overlay.query_text().trim().is_empty() {
590 set_idle_overlay_state(&overlay);
591 if let Some(issue) = hotkey_issue_status.as_deref() {
592 overlay.set_status_text(issue);
593 }
594 maybe_show_background_index_ready_notice(
595 &overlay,
596 &mut background_index_refresh,
597 );
598 }
599 }
600 OverlayEvent::ExternalQuit => {
601 pending_delayed_query = None;
602 overlay.cancel_query_delay();
603 overlay.hide_now();
604 last_query.clear();
605 search_session.clear();
606 unsafe {
607 windows_sys::Win32::UI::WindowsAndMessaging::PostQuitMessage(0);
608 }
609 }
610 OverlayEvent::TrayToggleGameMode => {
611 toggle_game_mode_from_tray(&overlay, &mut runtime_config);
612 }
613 OverlayEvent::TrayCheckForUpdates => {
614 match launch_stable_updater() {
615 Ok(_) => overlay.set_status_text("Updater launched"),
616 Err(error) => {
617 log_warn(&format!(
618 "[nex] updater launch failed from tray: {error}"
619 ));
620 overlay.set_status_text("Could not launch updater");
621 }
622 }
623 }
624 OverlayEvent::Escape => {
625 if overlay_state.on_escape() {
626 pending_delayed_query = None;
627 overlay.cancel_query_delay();
628 overlay.hide_now();
629 reset_overlay_session(
630 &overlay,
631 &mut current_results,
632 &mut selected_index,
633 );
634 pending_uninstall_confirmation = None;
635 last_query.clear();
636 search_session.clear();
637 }
638 }
639 OverlayEvent::QueryChanged(query) => {
640 if runtime_config.search_query_results_with_delay {
641 pending_delayed_query = Some(query);
642 overlay
643 .schedule_query_delay(runtime_config.search_delay_time_ms as u32);
644 } else {
645 pending_delayed_query = None;
646 overlay.cancel_query_delay();
647 apply_query_change(
648 query,
649 &overlay,
650 &service,
651 &runtime_config,
652 &plugin_registry,
653 max_results,
654 &background_index_refresh,
655 &mut search_session,
656 &mut pending_uninstall_confirmation,
657 &suppressed_uninstall_titles,
658 &mut current_results,
659 &mut selected_index,
660 &mut last_query,
661 );
662 }
663 }
664 OverlayEvent::QueryDelayElapsed => {
665 if let Some(query) = pending_delayed_query.take() {
666 apply_query_change(
667 query,
668 &overlay,
669 &service,
670 &runtime_config,
671 &plugin_registry,
672 max_results,
673 &background_index_refresh,
674 &mut search_session,
675 &mut pending_uninstall_confirmation,
676 &suppressed_uninstall_titles,
677 &mut current_results,
678 &mut selected_index,
679 &mut last_query,
680 );
681 }
682 }
683 OverlayEvent::MoveSelection(direction) => {
684 if current_results.is_empty() {
685 return;
686 }
687
688 selected_index =
689 next_selection_index(selected_index, current_results.len(), direction);
690 overlay.set_selected_index(selected_index);
691 }
692 OverlayEvent::Submit => {
693 if let Some(query) = pending_delayed_query.take() {
694 overlay.cancel_query_delay();
695 apply_query_change(
696 query,
697 &overlay,
698 &service,
699 &runtime_config,
700 &plugin_registry,
701 max_results,
702 &background_index_refresh,
703 &mut search_session,
704 &mut pending_uninstall_confirmation,
705 &suppressed_uninstall_titles,
706 &mut current_results,
707 &mut selected_index,
708 &mut last_query,
709 );
710 }
711 if current_results.is_empty() {
712 if overlay.query_text().trim().is_empty() {
713 set_idle_overlay_state(&overlay);
714 overlay.show_placeholder_hint(STATUS_ROW_TYPE_TO_SEARCH);
715 } else if should_show_indexing_status(&background_index_refresh) {
716 set_status_row_overlay_state(&overlay, STATUS_ROW_INDEXING);
717 } else {
718 let parsed_query = ParsedQuery::parse(
719 overlay.query_text().trim(),
720 runtime_config.search_dsl_enabled,
721 );
722 set_status_row_overlay_state(
723 &overlay,
724 if parsed_query.command_mode {
725 STATUS_ROW_NO_COMMAND_RESULTS
726 } else {
727 STATUS_ROW_NO_RESULTS
728 },
729 );
730 }
731 return;
732 }
733
734 if let Some(list_selection) = overlay.selected_index() {
735 selected_index = list_selection.min(current_results.len() - 1);
736 }
737
738 let selected = ¤t_results[selected_index];
739 if pending_uninstall_confirmation.is_some() {
740 let selected_id = selected.id.clone();
741 if selected_id == ACTION_UNINSTALL_CONFIRM_ID {
742 let Some(pending) = pending_uninstall_confirmation.take() else {
743 return;
744 };
745 overlay.hide_now();
746 overlay_state.on_escape();
747 match execute_action_selection(
748 &service,
749 &runtime_config,
750 &plugin_registry,
751 &pending.uninstall_action,
752 ) {
753 Ok(()) => {
754 track_uninstall_title_suppression(
755 &mut suppressed_uninstall_titles,
756 pending.uninstall_action.title.as_str(),
757 );
758 overlay.set_status_text("");
759 reset_overlay_session(
760 &overlay,
761 &mut current_results,
762 &mut selected_index,
763 );
764 last_query.clear();
765 search_session.clear();
766 }
767 Err(error) => {
768 if should_suppress_failed_uninstall(error.as_str()) {
769 track_uninstall_title_suppression(
770 &mut suppressed_uninstall_titles,
771 pending.uninstall_action.title.as_str(),
772 );
773 current_results = pending.previous_results;
774 filter_suppressed_uninstall_results(
775 &mut current_results,
776 &suppressed_uninstall_titles,
777 );
778 selected_index = pending
779 .previous_selected_index
780 .min(current_results.len().saturating_sub(1));
781 if current_results.is_empty() {
782 set_status_row_overlay_state(
783 &overlay,
784 if pending.previous_command_mode {
785 STATUS_ROW_NO_COMMAND_RESULTS
786 } else {
787 STATUS_ROW_NO_RESULTS
788 },
789 );
790 } else {
791 let rows = overlay_rows(
792 ¤t_results,
793 pending.previous_command_mode,
794 );
795 overlay.set_results(&rows, selected_index);
796 }
797 overlay.set_status_text(
798 "Uninstall entry is stale and was hidden",
799 );
800 } else {
801 pending_uninstall_confirmation = Some(pending);
802 overlay.show_and_focus();
803 overlay
804 .set_status_text(&format!("Launch error: {error}"));
805 }
806 }
807 }
808 return;
809 }
810
811 if selected_id == ACTION_UNINSTALL_CANCEL_ID {
812 let Some(pending) = pending_uninstall_confirmation.take() else {
813 return;
814 };
815 current_results = pending.previous_results;
816 selected_index = pending
817 .previous_selected_index
818 .min(current_results.len().saturating_sub(1));
819 if current_results.is_empty() {
820 set_status_row_overlay_state(
821 &overlay,
822 if pending.previous_command_mode {
823 STATUS_ROW_NO_COMMAND_RESULTS
824 } else {
825 STATUS_ROW_NO_RESULTS
826 },
827 );
828 } else {
829 let rows = overlay_rows(
830 ¤t_results,
831 pending.previous_command_mode,
832 );
833 overlay.set_results(&rows, selected_index);
834 }
835 overlay.set_status_text("");
836 return;
837 }
838
839 pending_uninstall_confirmation = None;
840 }
841
842 let selected_is_uninstall = selected
843 .id
844 .starts_with(crate::uninstall_registry::ACTION_UNINSTALL_PREFIX);
845
846 if selected_is_uninstall {
847 let parsed_query = ParsedQuery::parse(
848 overlay.query_text().trim(),
849 runtime_config.search_dsl_enabled,
850 );
851 pending_uninstall_confirmation = Some(PendingUninstallConfirmation {
852 uninstall_action: selected.clone(),
853 previous_results: current_results.clone(),
854 previous_selected_index: selected_index,
855 previous_command_mode: parsed_query.command_mode,
856 });
857 current_results = uninstall_confirmation_results(selected);
858 selected_index = 0;
859 let rows = overlay_rows(¤t_results, true);
860 overlay.set_results(&rows, selected_index);
861 overlay.set_status_text("");
862 return;
863 }
864
865 if selected.id == ACTION_TRIM_MEMORY_ID {
866 search_session.clear();
867 overlay.trim_runtime_memory();
868 overlay.set_status_text("Memory caches trimmed");
869 return;
870 }
871
872 match launch_overlay_selection(
873 &service,
874 &runtime_config,
875 &plugin_registry,
876 ¤t_results,
877 selected_index,
878 last_query.as_str(),
879 ) {
880 Ok(()) => {
881 overlay.set_status_text("");
882 overlay.hide_now();
883 overlay_state.on_escape();
884 reset_overlay_session(
885 &overlay,
886 &mut current_results,
887 &mut selected_index,
888 );
889 pending_uninstall_confirmation = None;
890 last_query.clear();
891 search_session.clear();
892 }
893 Err(error) => {
894 overlay.set_status_text(&format!("Launch error: {error}"));
895 }
896 }
897 }
898 }
899 })
900 .map_err(RuntimeError::Overlay)?;
901 registrar.unregister_all()?;
902 Ok(())
903 }
904
905 #[cfg(not(target_os = "windows"))]
906 {
907 log_info("[nex] non-windows runtime mode: no global hotkey loop");
908 Ok(())
909 }
910}
911
912fn command_ensure_config() -> Result<(), RuntimeError> {
913 let cfg = config::load(None)?;
914 if !cfg.config_path.exists() {
915 config::write_user_template(&cfg, &cfg.config_path)?;
916 log_info(&format!(
917 "[nex] wrote user config template to {}",
918 cfg.config_path.display()
919 ));
920 }
921 log_info(&format!(
922 "[nex] config ready at {}",
923 cfg.config_path.display()
924 ));
925 Ok(())
926}
927
928fn command_sync_startup() -> Result<(), RuntimeError> {
929 #[cfg(target_os = "windows")]
930 {
931 let cfg = config::load(None)?;
932 let exe = std::env::current_exe()?;
933 crate::startup::set_enabled(cfg.launch_at_startup, &exe)?;
934 log_info(&format!(
935 "[nex] startup registration synced: enabled={}",
936 cfg.launch_at_startup
937 ));
938 return Ok(());
939 }
940
941 #[cfg(not(target_os = "windows"))]
942 {
943 log_info("[nex] startup sync is unsupported on this platform");
944 Ok(())
945 }
946}
947
948fn command_set_launch_at_startup(enabled: bool) -> Result<(), RuntimeError> {
949 let mut cfg = config::load(None)?;
950 cfg.launch_at_startup = enabled;
951 config::save(&cfg)?;
952
953 #[cfg(target_os = "windows")]
954 {
955 let exe = std::env::current_exe()?;
956 crate::startup::set_enabled(enabled, &exe)?;
957 }
958
959 log_info(&format!(
960 "[nex] launch_at_startup updated: enabled={} (can be changed in config)",
961 enabled
962 ));
963 Ok(())
964}
965
966fn command_status() -> Result<(), RuntimeError> {
967 #[cfg(target_os = "windows")]
968 {
969 let state = inspect_runtime_process_state();
970 let running = state.has_overlay_window;
971 log_info(&format!(
972 "[nex] status: {}",
973 if running {
974 "running"
975 } else if !state.other_runtime_pids.is_empty() {
976 "degraded (process without overlay window)"
977 } else {
978 "stopped"
979 }
980 ));
981 if !state.other_runtime_pids.is_empty() {
982 log_warn(&format!(
983 "[nex] status detected runtime_pids_without_window={:?} recommendation=run --restart",
984 state.other_runtime_pids
985 ));
986 }
987 if let Some(snapshot) = load_status_diagnostics_snapshot() {
988 if let Some(line) = snapshot.hotkey_registration_issue_line {
989 log_warn(&format!("[nex] status last_hotkey_issue {line}"));
990 }
991 if let Some(line) = snapshot.overlay_ready_line {
992 log_info(&format!("[nex] status last_overlay_ready {line}"));
993 }
994 if let Some(line) = snapshot.hotkey_ready_line {
995 log_info(&format!("[nex] status last_hotkey_ready {line}"));
996 }
997 if let Some(line) = snapshot.indexing_started_line {
998 log_info(&format!("[nex] status last_indexing_started {line}"));
999 }
1000 if let Some(line) = snapshot.indexing_completed_line {
1001 log_info(&format!("[nex] status last_indexing_completed {line}"));
1002 }
1003 if let Some(line) = snapshot.cache_applied_line {
1004 log_info(&format!("[nex] status last_cache_applied {line}"));
1005 }
1006 if let Some(line) = snapshot.startup_index_line {
1007 log_info(&format!("[nex] status last_indexing {line}"));
1008 }
1009 if let Some(line) = snapshot.last_provider_line {
1010 log_info(&format!("[nex] status last_provider {line}"));
1011 }
1012 if let Some(line) = snapshot.last_provider_freshness_line {
1013 log_info(&format!("[nex] status last_provider_freshness {line}"));
1014 }
1015 if let Some(line) = snapshot.last_stale_prune_line {
1016 log_info(&format!("[nex] status last_stale_prune {line}"));
1017 }
1018 if let Some(line) = snapshot.last_cache_compaction_line {
1019 log_info(&format!("[nex] status last_cache_compaction {line}"));
1020 }
1021 if let Some(line) = snapshot.last_icon_cache_line {
1022 log_info(&format!("[nex] status last_icon_cache {line}"));
1023 }
1024 if let Some(line) = snapshot.last_overlay_tuning_line {
1025 log_info(&format!(
1026 "[nex] status last_overlay_tuning {line}"
1027 ));
1028 }
1029 if let Some(line) = snapshot.last_memory_snapshot_line {
1030 log_info(&format!(
1031 "[nex] status last_memory_snapshot {line}"
1032 ));
1033 }
1034 if let Some(line) = snapshot.last_config_reload_line {
1035 log_info(&format!(
1036 "[nex] status last_config_reload {line}"
1037 ));
1038 }
1039 }
1040 if let Some(report) = load_query_profile_status_report() {
1041 if let Some(recent) = report.recent {
1042 log_info(&format!(
1043 "[nex] status query_latency_recent samples={} p50_ms={} p95_ms={} p99_ms={} max_ms={} avg_ms={} indexed_p95_ms={} short_q_samples={} short_q_p95_ms={} short_q_app_bias_rate={}%",
1044 recent.samples,
1045 recent.p50_total_ms,
1046 recent.p95_total_ms,
1047 recent.p99_total_ms,
1048 recent.max_total_ms,
1049 recent.avg_total_ms,
1050 recent.p95_indexed_ms,
1051 recent.short_query_samples,
1052 recent.short_query_p95_total_ms,
1053 recent.short_query_app_bias_rate_pct
1054 ));
1055 }
1056 if let Some(historical) = report.historical {
1057 log_info(&format!(
1058 "[nex] status query_latency_historical samples={} p50_ms={} p95_ms={} p99_ms={} max_ms={} avg_ms={} indexed_p95_ms={} short_q_samples={} short_q_p95_ms={} short_q_app_bias_rate={}%",
1059 historical.samples,
1060 historical.p50_total_ms,
1061 historical.p95_total_ms,
1062 historical.p99_total_ms,
1063 historical.max_total_ms,
1064 historical.avg_total_ms,
1065 historical.p95_indexed_ms,
1066 historical.short_query_samples,
1067 historical.short_query_p95_total_ms,
1068 historical.short_query_app_bias_rate_pct
1069 ));
1070 }
1071 log_info(&format!(
1072 "[nex] status query_guard recent_skipped_symbol_queries={} historical_skipped_symbol_queries={}",
1073 report.recent_skipped_symbol_queries, report.historical_skipped_symbol_queries
1074 ));
1075 }
1076 return Ok(());
1077 }
1078
1079 #[cfg(not(target_os = "windows"))]
1080 {
1081 log_info("[nex] status: unsupported on this platform");
1082 Ok(())
1083 }
1084}
1085
1086fn command_status_json() -> Result<(), RuntimeError> {
1087 #[cfg(target_os = "windows")]
1088 {
1089 let state = inspect_runtime_process_state();
1090 let lifecycle = if state.has_overlay_window {
1091 "running"
1092 } else if !state.other_runtime_pids.is_empty() {
1093 "degraded"
1094 } else {
1095 "stopped"
1096 };
1097
1098 let snapshot = load_status_diagnostics_snapshot();
1099 let report = load_query_profile_status_report();
1100 let diagnostics = snapshot
1101 .as_ref()
1102 .map(build_status_diagnostics_json)
1103 .unwrap_or_else(|| serde_json::json!({}));
1104
1105 let payload = serde_json::json!({
1106 "runtime_state": lifecycle,
1107 "has_overlay_window": state.has_overlay_window,
1108 "other_runtime_pids": state.other_runtime_pids,
1109 "diagnostics": diagnostics,
1110 "query_latency": report.map(query_profile_report_json),
1111 });
1112 let encoded = serde_json::to_string_pretty(&payload)
1113 .map_err(|error| RuntimeError::Args(format!("status-json encode error: {error}")))?;
1114 println!("{encoded}");
1115 return Ok(());
1116 }
1117
1118 #[cfg(not(target_os = "windows"))]
1119 {
1120 let payload = serde_json::json!({
1121 "runtime_state": "unsupported_platform",
1122 "has_overlay_window": false,
1123 "other_runtime_pids": Vec::<u32>::new(),
1124 "diagnostics": serde_json::json!({}),
1125 "query_latency": serde_json::Value::Null,
1126 });
1127 let encoded = serde_json::to_string_pretty(&payload)
1128 .map_err(|error| RuntimeError::Args(format!("status-json encode error: {error}")))?;
1129 println!("{encoded}");
1130 Ok(())
1131 }
1132}
1133
1134fn command_diagnostics_bundle() -> Result<(), RuntimeError> {
1135 let cfg = config::load(None)?;
1136 let output_dir = write_diagnostics_bundle(&cfg)?;
1137 log_info(&format!(
1138 "[nex] diagnostics bundle written to {}",
1139 output_dir.display()
1140 ));
1141 Ok(())
1142}
1143
1144#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1145#[derive(Debug, Default, Clone, PartialEq, Eq)]
1146struct StatusDiagnosticsSnapshot {
1147 hotkey_registration_issue_line: Option<String>,
1148 overlay_ready_line: Option<String>,
1149 hotkey_ready_line: Option<String>,
1150 indexing_started_line: Option<String>,
1151 indexing_completed_line: Option<String>,
1152 cache_applied_line: Option<String>,
1153 startup_index_line: Option<String>,
1154 last_provider_line: Option<String>,
1155 last_provider_freshness_line: Option<String>,
1156 last_stale_prune_line: Option<String>,
1157 last_cache_compaction_line: Option<String>,
1158 last_icon_cache_line: Option<String>,
1159 last_overlay_tuning_line: Option<String>,
1160 last_memory_snapshot_line: Option<String>,
1161 last_config_reload_line: Option<String>,
1162}
1163
1164#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1166struct QueryProfileSample {
1167 total_ms: u128,
1168 indexed_ms: u128,
1169 query_len: usize,
1170 short_app_bias: bool,
1171}
1172
1173#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1175struct QueryProfileSummary {
1176 samples: usize,
1177 p50_total_ms: u128,
1178 p95_total_ms: u128,
1179 p99_total_ms: u128,
1180 max_total_ms: u128,
1181 avg_total_ms: u128,
1182 p95_indexed_ms: u128,
1183 short_query_samples: usize,
1184 short_query_p95_total_ms: u128,
1185 short_query_app_bias_rate_pct: u8,
1186}
1187
1188#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1189#[derive(Debug, Clone, PartialEq, Eq)]
1190struct QueryProfileStatusReport {
1191 recent: Option<QueryProfileSummary>,
1192 historical: Option<QueryProfileSummary>,
1193 recent_skipped_symbol_queries: usize,
1194 historical_skipped_symbol_queries: usize,
1195}
1196
1197#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1198fn load_status_diagnostics_snapshot() -> Option<StatusDiagnosticsSnapshot> {
1199 let content = crate::logging::candidate_log_paths()
1200 .into_iter()
1201 .find_map(|log_path| std::fs::read_to_string(log_path).ok())?;
1202 parse_status_diagnostics_snapshot(&content)
1203}
1204
1205#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1206fn load_query_profile_status_report() -> Option<QueryProfileStatusReport> {
1207 let content = crate::logging::candidate_log_paths()
1208 .into_iter()
1209 .find_map(|log_path| std::fs::read_to_string(log_path).ok())?;
1210 summarize_query_profile_status_report(&content)
1211}
1212
1213#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1214fn parse_status_diagnostics_snapshot(content: &str) -> Option<StatusDiagnosticsSnapshot> {
1215 let hotkey_registration_issue_line =
1216 latest_line_with_token(content, "hotkey_registration_issue ");
1217 let overlay_ready_line = latest_line_with_token(content, "startup_phase phase=overlay_ready ");
1218 let hotkey_ready_line = latest_line_with_token(content, "startup_phase phase=hotkey_ready ");
1219 let indexing_started_line =
1220 latest_line_with_token(content, "startup_phase phase=indexing_started ");
1221 let indexing_completed_line =
1222 latest_line_with_token(content, "startup_phase phase=indexing_completed ");
1223 let cache_applied_line = latest_line_with_token(content, "startup_phase phase=cache_applied ");
1224 let startup_index_line = latest_line_with_token(content, "startup indexed_items=");
1225 let last_provider_line = latest_line_with_token(content, "index_provider name=");
1226 let last_provider_freshness_line = latest_line_with_token(content, "provider_freshness ");
1227 let last_stale_prune_line = latest_line_with_token(content, "stale_prune ");
1228 let last_cache_compaction_line = latest_line_with_token(content, "cache_compaction ");
1229 let last_icon_cache_line = latest_line_with_token(content, "overlay_icon_cache reason=");
1230 let last_overlay_tuning_line = latest_line_with_token(content, "overlay_tuning ");
1231 let last_memory_snapshot_line = latest_line_with_token(content, "memory_snapshot reason=");
1232 let last_config_reload_line = latest_line_with_token(content, "config reloaded ");
1233
1234 if hotkey_registration_issue_line.is_none()
1235 && overlay_ready_line.is_none()
1236 && hotkey_ready_line.is_none()
1237 && indexing_started_line.is_none()
1238 && indexing_completed_line.is_none()
1239 && cache_applied_line.is_none()
1240 && startup_index_line.is_none()
1241 && last_provider_line.is_none()
1242 && last_provider_freshness_line.is_none()
1243 && last_stale_prune_line.is_none()
1244 && last_cache_compaction_line.is_none()
1245 && last_icon_cache_line.is_none()
1246 && last_overlay_tuning_line.is_none()
1247 && last_memory_snapshot_line.is_none()
1248 && last_config_reload_line.is_none()
1249 {
1250 return None;
1251 }
1252
1253 Some(StatusDiagnosticsSnapshot {
1254 hotkey_registration_issue_line,
1255 overlay_ready_line,
1256 hotkey_ready_line,
1257 indexing_started_line,
1258 indexing_completed_line,
1259 cache_applied_line,
1260 startup_index_line,
1261 last_provider_line,
1262 last_provider_freshness_line,
1263 last_stale_prune_line,
1264 last_cache_compaction_line,
1265 last_icon_cache_line,
1266 last_overlay_tuning_line,
1267 last_memory_snapshot_line,
1268 last_config_reload_line,
1269 })
1270}
1271
1272#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1273fn latest_line_with_token(content: &str, token: &str) -> Option<String> {
1274 content
1275 .lines()
1276 .rev()
1277 .find(|line| line.contains(token))
1278 .map(str::to_string)
1279}
1280
1281#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1282fn summarize_query_profile_status_report(content: &str) -> Option<QueryProfileStatusReport> {
1283 let recent_samples = parse_recent_query_profile_samples(content);
1284 let historical_samples = parse_query_profile_samples(content);
1285 let recent = summarize_query_profile_samples(&recent_samples);
1286 let historical = summarize_query_profile_samples(&historical_samples);
1287 if recent.is_none() && historical.is_none() {
1288 return None;
1289 }
1290
1291 let recent_lines = recent_runtime_log_slice(content);
1292 let recent_skipped_symbol_queries = count_skipped_symbol_query_guards(recent_lines);
1293 let historical_skipped_symbol_queries = count_skipped_symbol_query_guards(content);
1294
1295 Some(QueryProfileStatusReport {
1296 recent,
1297 historical,
1298 recent_skipped_symbol_queries,
1299 historical_skipped_symbol_queries,
1300 })
1301}
1302
1303#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1304fn build_status_diagnostics_json(snapshot: &StatusDiagnosticsSnapshot) -> serde_json::Value {
1305 let hotkey_issue = build_phase_status_json(snapshot.hotkey_registration_issue_line.as_ref());
1306 let overlay_ready = build_phase_status_json(snapshot.overlay_ready_line.as_ref());
1307 let hotkey_ready = build_phase_status_json(snapshot.hotkey_ready_line.as_ref());
1308 let indexing_started = build_phase_status_json(snapshot.indexing_started_line.as_ref());
1309 let indexing_completed = build_phase_status_json(snapshot.indexing_completed_line.as_ref());
1310 let cache_applied = build_phase_status_json(snapshot.cache_applied_line.as_ref());
1311 let startup_indexing = snapshot
1312 .startup_index_line
1313 .as_ref()
1314 .and_then(|line| parse_key_value_tokens(line));
1315 let provider = snapshot
1316 .last_provider_line
1317 .as_ref()
1318 .and_then(|line| parse_key_value_tokens(line));
1319 let provider_freshness = snapshot
1320 .last_provider_freshness_line
1321 .as_ref()
1322 .and_then(|line| parse_key_value_tokens(line));
1323 let stale_prune = snapshot
1324 .last_stale_prune_line
1325 .as_ref()
1326 .and_then(|line| parse_key_value_tokens(line));
1327 let cache_compaction = snapshot
1328 .last_cache_compaction_line
1329 .as_ref()
1330 .and_then(|line| parse_key_value_tokens(line));
1331 let icon_cache = snapshot
1332 .last_icon_cache_line
1333 .as_ref()
1334 .and_then(|line| parse_key_value_tokens(line));
1335 let overlay_tuning = snapshot
1336 .last_overlay_tuning_line
1337 .as_ref()
1338 .and_then(|line| parse_key_value_tokens(line));
1339 let memory_snapshot = snapshot
1340 .last_memory_snapshot_line
1341 .as_ref()
1342 .and_then(|line| parse_key_value_tokens(line));
1343 let config_reload = snapshot
1344 .last_config_reload_line
1345 .as_ref()
1346 .and_then(|line| parse_key_value_tokens(line));
1347 let config_reload_epoch_secs = snapshot
1348 .last_config_reload_line
1349 .as_ref()
1350 .and_then(|line| parse_log_line_epoch_secs(line));
1351
1352 serde_json::json!({
1353 "startup_lifecycle": {
1354 "overlay_ready": overlay_ready,
1355 "hotkey_ready": hotkey_ready,
1356 "indexing_started": indexing_started,
1357 "indexing_completed": indexing_completed,
1358 "cache_applied": cache_applied,
1359 },
1360 "hotkey_issue": hotkey_issue,
1361 "startup_indexing": startup_indexing,
1362 "provider": provider,
1363 "provider_freshness": provider_freshness,
1364 "stale_prune": stale_prune,
1365 "cache_compaction": cache_compaction,
1366 "icon_cache": icon_cache,
1367 "overlay_tuning": overlay_tuning,
1368 "memory_snapshot": memory_snapshot,
1369 "config_reload": config_reload,
1370 "config_reload_epoch_secs": config_reload_epoch_secs,
1371 "raw": {
1372 "hotkey_issue_line": snapshot.hotkey_registration_issue_line,
1373 "overlay_ready_line": snapshot.overlay_ready_line,
1374 "hotkey_ready_line": snapshot.hotkey_ready_line,
1375 "indexing_started_line": snapshot.indexing_started_line,
1376 "indexing_completed_line": snapshot.indexing_completed_line,
1377 "cache_applied_line": snapshot.cache_applied_line,
1378 "startup_indexing_line": snapshot.startup_index_line,
1379 "provider_line": snapshot.last_provider_line,
1380 "provider_freshness_line": snapshot.last_provider_freshness_line,
1381 "stale_prune_line": snapshot.last_stale_prune_line,
1382 "cache_compaction_line": snapshot.last_cache_compaction_line,
1383 "icon_cache_line": snapshot.last_icon_cache_line,
1384 "overlay_tuning_line": snapshot.last_overlay_tuning_line,
1385 "memory_snapshot_line": snapshot.last_memory_snapshot_line,
1386 "config_reload_line": snapshot.last_config_reload_line,
1387 }
1388 })
1389}
1390
1391#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1392fn build_phase_status_json(line: Option<&String>) -> serde_json::Value {
1393 let tokens = line.and_then(|value| parse_key_value_tokens(value));
1394 let epoch_secs = line.and_then(|value| parse_log_line_epoch_secs(value));
1395 serde_json::json!({
1396 "tokens": tokens,
1397 "epoch_secs": epoch_secs,
1398 "line": line.cloned(),
1399 })
1400}
1401
1402#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1403fn query_profile_report_json(report: QueryProfileStatusReport) -> serde_json::Value {
1404 serde_json::json!({
1405 "recent": report.recent.map(query_profile_summary_json),
1406 "historical": report.historical.map(query_profile_summary_json),
1407 "recent_skipped_symbol_queries": report.recent_skipped_symbol_queries,
1408 "historical_skipped_symbol_queries": report.historical_skipped_symbol_queries,
1409 })
1410}
1411
1412#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1413fn query_profile_summary_json(summary: QueryProfileSummary) -> serde_json::Value {
1414 serde_json::json!({
1415 "samples": summary.samples,
1416 "p50_total_ms": summary.p50_total_ms,
1417 "p95_total_ms": summary.p95_total_ms,
1418 "p99_total_ms": summary.p99_total_ms,
1419 "max_total_ms": summary.max_total_ms,
1420 "avg_total_ms": summary.avg_total_ms,
1421 "p95_indexed_ms": summary.p95_indexed_ms,
1422 "short_query_samples": summary.short_query_samples,
1423 "short_query_p95_total_ms": summary.short_query_p95_total_ms,
1424 "short_query_app_bias_rate_pct": summary.short_query_app_bias_rate_pct,
1425 })
1426}
1427
1428#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1429fn parse_log_line_epoch_secs(line: &str) -> Option<u64> {
1430 let trimmed = line.trim();
1431 let start = trimmed.find('[')? + 1;
1432 let end = trimmed[start..].find(']')? + start;
1433 trimmed[start..end].parse::<u64>().ok()
1434}
1435
1436#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1437fn parse_key_value_tokens(line: &str) -> Option<serde_json::Value> {
1438 let mut map = serde_json::Map::new();
1439 for token in line.split_whitespace() {
1440 let Some((key, value)) = token.split_once('=') else {
1441 continue;
1442 };
1443 let key = key.trim().trim_end_matches(':');
1444 if key.is_empty() {
1445 continue;
1446 }
1447 let value = value.trim().trim_end_matches(',');
1448 if value.is_empty() {
1449 continue;
1450 }
1451 if let Ok(number) = value.parse::<u64>() {
1452 map.insert(key.to_string(), serde_json::json!(number));
1453 continue;
1454 }
1455 if let Ok(number) = value.parse::<f64>() {
1456 map.insert(key.to_string(), serde_json::json!(number));
1457 continue;
1458 }
1459 if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") {
1460 map.insert(
1461 key.to_string(),
1462 serde_json::json!(value.eq_ignore_ascii_case("true")),
1463 );
1464 continue;
1465 }
1466 map.insert(
1467 key.to_string(),
1468 serde_json::json!(value.trim_matches('"').to_string()),
1469 );
1470 }
1471 if map.is_empty() {
1472 None
1473 } else {
1474 Some(serde_json::Value::Object(map))
1475 }
1476}
1477
1478#[cfg(test)]
1479fn summarize_query_profiles(content: &str) -> Option<QueryProfileSummary> {
1480 let samples = parse_recent_query_profile_samples(content);
1481 summarize_query_profile_samples(&samples)
1482}
1483
1484#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1485fn summarize_query_profile_samples(samples: &[QueryProfileSample]) -> Option<QueryProfileSummary> {
1486 let mut samples = samples.to_vec();
1487 if samples.is_empty() {
1488 return None;
1489 }
1490
1491 if samples.len() > QUERY_PROFILE_STATUS_SAMPLE_WINDOW {
1492 samples.drain(0..(samples.len() - QUERY_PROFILE_STATUS_SAMPLE_WINDOW));
1493 }
1494 if samples.is_empty() {
1495 return None;
1496 }
1497
1498 let mut total_ms: Vec<u128> = samples.iter().map(|sample| sample.total_ms).collect();
1499 let mut indexed_ms: Vec<u128> = samples.iter().map(|sample| sample.indexed_ms).collect();
1500 let max_total_ms = total_ms.iter().copied().max().unwrap_or(0);
1501 let avg_total_ms = total_ms.iter().sum::<u128>() / (total_ms.len() as u128);
1502 let p50_total_ms = percentile_u128(&mut total_ms, 0.50);
1503 let p95_total_ms = percentile_u128(&mut total_ms, 0.95);
1504 let p99_total_ms = percentile_u128(&mut total_ms, 0.99);
1505 let p95_indexed_ms = percentile_u128(&mut indexed_ms, 0.95);
1506
1507 let short_query_samples: Vec<QueryProfileSample> = samples
1508 .iter()
1509 .copied()
1510 .filter(|sample| sample.query_len <= SHORT_QUERY_APP_BIAS_MAX_LEN)
1511 .collect();
1512 let short_query_samples_count = short_query_samples.len();
1513 let mut short_total_ms: Vec<u128> = short_query_samples
1514 .iter()
1515 .map(|sample| sample.total_ms)
1516 .collect();
1517 let short_query_p95_total_ms = percentile_u128(&mut short_total_ms, 0.95);
1518 let short_query_app_bias_count = short_query_samples
1519 .iter()
1520 .filter(|sample| sample.short_app_bias)
1521 .count();
1522 let short_query_app_bias_rate_pct = if short_query_samples_count == 0 {
1523 0
1524 } else {
1525 ((short_query_app_bias_count * 100) / short_query_samples_count) as u8
1526 };
1527
1528 Some(QueryProfileSummary {
1529 samples: samples.len(),
1530 p50_total_ms,
1531 p95_total_ms,
1532 p99_total_ms,
1533 max_total_ms,
1534 avg_total_ms,
1535 p95_indexed_ms,
1536 short_query_samples: short_query_samples_count,
1537 short_query_p95_total_ms,
1538 short_query_app_bias_rate_pct,
1539 })
1540}
1541
1542#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1543fn recent_runtime_log_slice(content: &str) -> &str {
1544 let Some(pos) = rfind_runtime_log_marker(content, "startup mode=") else {
1545 return content;
1546 };
1547 let line_start = content[..pos].rfind('\n').map(|idx| idx + 1).unwrap_or(pos);
1548 &content[line_start..]
1549}
1550
1551#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1552fn count_skipped_symbol_query_guards(content: &str) -> usize {
1553 content
1554 .lines()
1555 .filter(|line| line.contains("query_guard skip=non_searchable_symbol_only"))
1556 .count()
1557}
1558
1559#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1560fn parse_recent_query_profile_samples(content: &str) -> Vec<QueryProfileSample> {
1561 let lines: Vec<&str> = content.lines().collect();
1562 if lines.is_empty() {
1563 return Vec::new();
1564 }
1565 let start_index = lines
1566 .iter()
1567 .rposition(|line| line_contains_runtime_log_marker(line, "startup mode="))
1568 .unwrap_or(0);
1569 parse_query_profile_samples(&lines[start_index..].join("\n"))
1570}
1571
1572#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1573fn parse_query_profile_samples(content: &str) -> Vec<QueryProfileSample> {
1574 content
1575 .lines()
1576 .filter(|line| line_contains_runtime_log_marker(line, "query_profile "))
1577 .filter_map(|line| {
1578 let total_ms = parse_u128_field(line, "total_ms=")?;
1579 let indexed_ms = parse_u128_field(line, "indexed_ms=").unwrap_or(0);
1580 let query = parse_quoted_field(line, "q=").unwrap_or_default();
1581 let query_len = query.chars().count();
1582 let short_app_bias = parse_bool_field(line, "short_app_bias=").unwrap_or(false);
1583 Some(QueryProfileSample {
1584 total_ms,
1585 indexed_ms,
1586 query_len,
1587 short_app_bias,
1588 })
1589 })
1590 .collect()
1591}
1592
1593#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1594fn parse_u128_field(line: &str, key: &str) -> Option<u128> {
1595 let start = line.find(key)? + key.len();
1596 let tail = &line[start..];
1597 let value = tail
1598 .split_whitespace()
1599 .next()
1600 .map(|part| part.trim_end_matches(','))
1601 .unwrap_or_default();
1602 value.parse::<u128>().ok()
1603}
1604
1605#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1606fn parse_bool_field(line: &str, key: &str) -> Option<bool> {
1607 let start = line.find(key)? + key.len();
1608 let tail = &line[start..];
1609 let value = tail
1610 .split_whitespace()
1611 .next()
1612 .map(|part| part.trim_end_matches(','))
1613 .unwrap_or_default();
1614 match value {
1615 "true" => Some(true),
1616 "false" => Some(false),
1617 _ => None,
1618 }
1619}
1620
1621#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1622fn parse_quoted_field(line: &str, key: &str) -> Option<String> {
1623 let start = line.find(key)? + key.len();
1624 let tail = &line[start..];
1625 if !tail.starts_with('"') {
1626 return None;
1627 }
1628 let end = tail[1..].find('"')?;
1629 Some(tail[1..(1 + end)].to_string())
1630}
1631
1632#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1633fn percentile_u128(values: &mut [u128], percentile: f64) -> u128 {
1634 if values.is_empty() {
1635 return 0;
1636 }
1637 values.sort_unstable();
1638 let last = values.len().saturating_sub(1);
1639 let idx = ((last as f64) * percentile.clamp(0.0, 1.0)).round() as usize;
1640 values[idx.min(last)]
1641}
1642
1643fn command_quit() -> Result<(), RuntimeError> {
1644 #[cfg(target_os = "windows")]
1645 {
1646 match stop_runtime_instance(std::time::Duration::from_secs(3))? {
1647 StopRuntimeOutcome::AlreadyStopped => {
1648 log_info("[nex] quit skipped (not running)");
1649 Ok(())
1650 }
1651 StopRuntimeOutcome::Graceful => {
1652 log_info("[nex] quit completed (graceful)");
1653 Ok(())
1654 }
1655 StopRuntimeOutcome::Forced => {
1656 log_warn("[nex] quit required forced process termination");
1657 Ok(())
1658 }
1659 StopRuntimeOutcome::Failed => Err(RuntimeError::Overlay(
1660 "quit failed: runtime is still active after graceful and forced attempts"
1661 .to_string(),
1662 )),
1663 }
1664 }
1665
1666 #[cfg(not(target_os = "windows"))]
1667 {
1668 log_info("[nex] quit is unsupported on this platform");
1669 Ok(())
1670 }
1671}
1672
1673fn command_restart() -> Result<(), RuntimeError> {
1674 #[cfg(target_os = "windows")]
1675 {
1676 match stop_runtime_instance(std::time::Duration::from_secs(3))? {
1677 StopRuntimeOutcome::Failed => {
1678 return Err(RuntimeError::Overlay(
1679 "restart failed: existing runtime could not be stopped".to_string(),
1680 ));
1681 }
1682 StopRuntimeOutcome::Forced => {
1683 log_warn("[nex] restart required forced process termination");
1684 }
1685 StopRuntimeOutcome::Graceful | StopRuntimeOutcome::AlreadyStopped => {}
1686 }
1687 run_with_options(RuntimeOptions::default())
1688 }
1689
1690 #[cfg(not(target_os = "windows"))]
1691 {
1692 run_with_options(RuntimeOptions::default())
1693 }
1694}
1695
1696#[cfg(target_os = "windows")]
1697#[derive(Debug, Clone, PartialEq, Eq)]
1698struct RuntimeProcessState {
1699 has_overlay_window: bool,
1700 other_runtime_pids: Vec<u32>,
1701}
1702
1703#[cfg(target_os = "windows")]
1704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1705enum StopRuntimeOutcome {
1706 AlreadyStopped,
1707 Graceful,
1708 Forced,
1709 Failed,
1710}
1711
1712#[cfg(target_os = "windows")]
1713fn inspect_runtime_process_state() -> RuntimeProcessState {
1714 RuntimeProcessState {
1715 has_overlay_window: is_instance_window_present(),
1716 other_runtime_pids: runtime_process_pids_excluding_current().unwrap_or_default(),
1717 }
1718}
1719
1720#[cfg(target_os = "windows")]
1721fn stop_runtime_instance(timeout: std::time::Duration) -> Result<StopRuntimeOutcome, RuntimeError> {
1722 let mut state = inspect_runtime_process_state();
1723 if !state.has_overlay_window && state.other_runtime_pids.is_empty() {
1724 return Ok(StopRuntimeOutcome::AlreadyStopped);
1725 }
1726
1727 if state.has_overlay_window {
1728 let _ = signal_existing_instance_quit().map_err(RuntimeError::Overlay)?;
1729 if wait_until_overlay_window_closed(timeout) {
1730 state = inspect_runtime_process_state();
1731 if state.other_runtime_pids.is_empty() {
1732 return Ok(StopRuntimeOutcome::Graceful);
1733 }
1734 }
1735 }
1736
1737 let forced = force_terminate_other_runtime_processes()?;
1738 std::thread::sleep(std::time::Duration::from_millis(250));
1739 let post = inspect_runtime_process_state();
1740 if !post.has_overlay_window && post.other_runtime_pids.is_empty() {
1741 if forced {
1742 Ok(StopRuntimeOutcome::Forced)
1743 } else {
1744 Ok(StopRuntimeOutcome::Graceful)
1745 }
1746 } else {
1747 Ok(StopRuntimeOutcome::Failed)
1748 }
1749}
1750
1751#[cfg(target_os = "windows")]
1752fn wait_until_overlay_window_closed(timeout: std::time::Duration) -> bool {
1753 let start = std::time::Instant::now();
1754 while start.elapsed() < timeout {
1755 if !is_instance_window_present() {
1756 return true;
1757 }
1758 std::thread::sleep(std::time::Duration::from_millis(120));
1759 }
1760 !is_instance_window_present()
1761}
1762
1763#[cfg(target_os = "windows")]
1764fn force_terminate_other_runtime_processes() -> Result<bool, RuntimeError> {
1765 let current_pid = unsafe { windows_sys::Win32::System::Threading::GetCurrentProcessId() };
1766 let mut terminated_any = false;
1767 for exe_name in runtime_executable_names() {
1768 let command = format!(
1769 "taskkill /F /T /FI \"IMAGENAME eq {exe_name}\" /FI \"PID ne {}\" >NUL 2>&1",
1770 current_pid
1771 );
1772 let status = std::process::Command::new("cmd")
1773 .arg("/C")
1774 .arg(command)
1775 .status()
1776 .map_err(RuntimeError::Io)?;
1777 terminated_any |= status.success();
1778 }
1779 Ok(terminated_any)
1780}
1781
1782#[cfg(target_os = "windows")]
1783fn runtime_process_pids_excluding_current() -> Result<Vec<u32>, RuntimeError> {
1784 let current_pid = unsafe { windows_sys::Win32::System::Threading::GetCurrentProcessId() };
1785 let mut pids = Vec::new();
1786 for exe_name in runtime_executable_names() {
1787 let output = std::process::Command::new("cmd")
1788 .arg("/C")
1789 .arg(format!("tasklist /FI \"IMAGENAME eq {exe_name}\" /FO LIST /NH"))
1790 .output()
1791 .map_err(RuntimeError::Io)?;
1792 let stdout = String::from_utf8_lossy(&output.stdout);
1793 pids.extend(parse_tasklist_pid_lines(&stdout));
1794 }
1795 pids.retain(|pid| *pid != current_pid);
1796 pids.sort_unstable();
1797 pids.dedup();
1798 Ok(pids)
1799}
1800
1801#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1802fn parse_tasklist_pid_lines(content: &str) -> Vec<u32> {
1803 content
1804 .lines()
1805 .filter_map(|line| {
1806 let trimmed = line.trim();
1807 if !trimmed.to_ascii_lowercase().starts_with("pid:") {
1808 return None;
1809 }
1810 let value = trimmed.split(':').nth(1)?.trim();
1811 value.parse::<u32>().ok()
1812 })
1813 .collect()
1814}
1815
1816fn write_diagnostics_bundle(cfg: &config::Config) -> Result<std::path::PathBuf, RuntimeError> {
1817 let support_dir = config::stable_app_data_dir().join("support");
1818 std::fs::create_dir_all(&support_dir)?;
1819 let stamp = std::time::SystemTime::now()
1820 .duration_since(std::time::UNIX_EPOCH)
1821 .map(|d| d.as_secs())
1822 .unwrap_or(0);
1823 let bundle_dir = support_dir.join(format!("diagnostics-{stamp}"));
1824 std::fs::create_dir_all(&bundle_dir)?;
1825
1826 let running_state = runtime_state_summary();
1827 let summary = format!(
1828 "nex diagnostics bundle\ngenerated_epoch_secs={stamp}\nruntime_state={running_state}\nconfig_path={}\nindex_db_path={}\nlogs_dir={}\n",
1829 cfg.config_path.display(),
1830 cfg.index_db_path.display(),
1831 crate::logging::logs_dir().display()
1832 );
1833 std::fs::write(bundle_dir.join("summary.txt"), summary)?;
1834
1835 if cfg.config_path.exists() {
1836 let raw_ext = cfg
1837 .config_path
1838 .extension()
1839 .and_then(|ext| ext.to_str())
1840 .filter(|ext| !ext.trim().is_empty())
1841 .unwrap_or("txt");
1842 let _ = std::fs::copy(
1843 &cfg.config_path,
1844 bundle_dir.join(format!("config.raw.{raw_ext}")),
1845 );
1846 }
1847
1848 let sanitized_cfg = serde_json::json!({
1849 "version": cfg.version,
1850 "max_results": cfg.max_results,
1851 "hotkey": cfg.hotkey,
1852 "launch_at_startup": cfg.launch_at_startup,
1853 "search_mode_default": cfg.search_mode_default,
1854 "search_dsl_enabled": cfg.search_dsl_enabled,
1855 "search_query_results_with_delay": cfg.search_query_results_with_delay,
1856 "search_delay_time_ms": cfg.search_delay_time_ms,
1857 "uninstall_actions_enabled": cfg.uninstall_actions_enabled,
1858 "web_search_provider": cfg.web_search_provider,
1859 "clipboard_enabled": cfg.clipboard_enabled,
1860 "clipboard_retention_minutes": cfg.clipboard_retention_minutes,
1861 "clipboard_exclude_sensitive_patterns_count": cfg.clipboard_exclude_sensitive_patterns.len(),
1862 "plugins_enabled": cfg.plugins_enabled,
1863 "plugin_paths_count": cfg.plugin_paths.len(),
1864 "plugins_safe_mode": cfg.plugins_safe_mode,
1865 "game_mode_enabled": cfg.game_mode_enabled,
1866 "idle_cache_trim_ms": cfg.idle_cache_trim_ms,
1867 "active_memory_target_mb": cfg.active_memory_target_mb,
1868 "index_max_items_total": cfg.index_max_items_total,
1869 "index_max_items_per_root": cfg.index_max_items_per_root,
1870 "index_max_items_per_query_seed": cfg.index_max_items_per_query_seed,
1871 "discovery_roots_count": cfg.discovery_roots.len(),
1872 "discovery_exclude_roots_count": cfg.discovery_exclude_roots.len(),
1873 "windows_search_enabled": cfg.windows_search_enabled,
1874 "windows_search_fallback_filesystem": cfg.windows_search_fallback_filesystem,
1875 "show_files": cfg.show_files,
1876 "show_folders": cfg.show_folders
1877 });
1878 let encoded = serde_json::to_string_pretty(&sanitized_cfg)
1879 .map_err(|e| RuntimeError::Args(format!("failed to encode sanitized config: {e}")))?;
1880 std::fs::write(bundle_dir.join("config.sanitized.json"), encoded)?;
1881
1882 copy_recent_logs_to_bundle(&crate::logging::logs_dir(), &bundle_dir.join("logs"))?;
1883
1884 Ok(bundle_dir)
1885}
1886
1887fn copy_recent_logs_to_bundle(
1888 source_logs_dir: &std::path::Path,
1889 target_logs_dir: &std::path::Path,
1890) -> Result<(), RuntimeError> {
1891 std::fs::create_dir_all(target_logs_dir)?;
1892 if !source_logs_dir.exists() {
1893 return Ok(());
1894 }
1895
1896 let mut entries = std::fs::read_dir(source_logs_dir)?
1897 .filter_map(|entry| entry.ok())
1898 .map(|entry| entry.path())
1899 .filter(|path| {
1900 path.file_name()
1901 .and_then(|n| n.to_str())
1902 .map(|n| n.ends_with(".log"))
1903 .unwrap_or(false)
1904 })
1905 .collect::<Vec<_>>();
1906
1907 entries.sort_by_key(|path| {
1908 std::fs::metadata(path)
1909 .and_then(|m| m.modified())
1910 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
1911 });
1912 entries.reverse();
1913
1914 for path in entries.into_iter().take(5) {
1915 if let Some(name) = path.file_name() {
1916 let _ = std::fs::copy(&path, target_logs_dir.join(name));
1917 }
1918 }
1919
1920 Ok(())
1921}
1922
1923fn runtime_state_summary() -> String {
1924 #[cfg(target_os = "windows")]
1925 {
1926 let state = inspect_runtime_process_state();
1927 if state.has_overlay_window {
1928 return "running".to_string();
1929 }
1930 if !state.other_runtime_pids.is_empty() {
1931 return format!(
1932 "degraded(process_without_overlay_window pids={:?})",
1933 state.other_runtime_pids
1934 );
1935 }
1936 "stopped".to_string()
1937 }
1938
1939 #[cfg(not(target_os = "windows"))]
1940 {
1941 "unsupported_platform".to_string()
1942 }
1943}
1944
1945#[cfg(target_os = "windows")]
1946fn spawn_background_process() -> Result<(), RuntimeError> {
1947 use std::os::windows::process::CommandExt;
1948
1949 let exe = std::env::current_exe()?;
1950 let mut command = std::process::Command::new(exe);
1951 command.arg("--foreground");
1952 command.env("NEX_SUPPRESS_STDIO", "1");
1953 command.creation_flags(0x00000008 | 0x00000200 | 0x08000000);
1954 command.stdin(std::process::Stdio::null());
1955 command.stdout(std::process::Stdio::null());
1956 command.stderr(std::process::Stdio::null());
1957 command.spawn()?;
1958 log_info("[nex] background process started");
1959 Ok(())
1960}
1961
1962fn runtime_mode() -> &'static str {
1963 #[cfg(target_os = "windows")]
1964 {
1965 "windows-hotkey-runtime"
1966 }
1967
1968 #[cfg(not(target_os = "windows"))]
1969 {
1970 "non-windows-noop"
1971 }
1972}
1973
1974#[cfg(target_os = "windows")]
1975fn overlay_rows(results: &[crate::model::SearchItem], command_mode: bool) -> Vec<OverlayRow> {
1976 if results.is_empty() {
1977 return Vec::new();
1978 }
1979
1980 if command_mode {
1981 return results
1982 .iter()
1983 .enumerate()
1984 .map(|(index, item)| result_row(item, index, OverlayRowRole::Item, command_mode))
1985 .collect();
1986 }
1987
1988 let mut rows = Vec::new();
1989 rows.push(result_row(
1990 &results[0],
1991 0,
1992 OverlayRowRole::TopHit,
1993 command_mode,
1994 ));
1995
1996 let mut app_indices = Vec::new();
1997 let mut file_indices = Vec::new();
1998 let mut action_indices = Vec::new();
1999 let mut clipboard_indices = Vec::new();
2000 let mut other_indices = Vec::new();
2001
2002 for (index, item) in results.iter().enumerate().skip(1) {
2003 if item.kind.eq_ignore_ascii_case("app") {
2004 app_indices.push(index);
2005 } else if item.kind.eq_ignore_ascii_case("file") || item.kind.eq_ignore_ascii_case("folder")
2006 {
2007 file_indices.push(index);
2008 } else if item.kind.eq_ignore_ascii_case("action") {
2009 action_indices.push(index);
2010 } else if item.kind.eq_ignore_ascii_case("clipboard") {
2011 clipboard_indices.push(index);
2012 } else {
2013 other_indices.push(index);
2014 }
2015 }
2016
2017 append_group_rows(&mut rows, &app_indices, results, command_mode);
2018 append_group_rows(&mut rows, &file_indices, results, command_mode);
2019 append_group_rows(&mut rows, &action_indices, results, command_mode);
2020 append_group_rows(&mut rows, &clipboard_indices, results, command_mode);
2021 append_group_rows(&mut rows, &other_indices, results, command_mode);
2022 rows
2023}
2024
2025#[cfg(target_os = "windows")]
2026fn append_group_rows(
2027 rows: &mut Vec<OverlayRow>,
2028 indices: &[usize],
2029 results: &[crate::model::SearchItem],
2030 command_mode: bool,
2031) {
2032 if indices.is_empty() {
2033 return;
2034 }
2035 for index in indices {
2036 rows.push(result_row(
2037 &results[*index],
2038 *index,
2039 OverlayRowRole::Item,
2040 command_mode,
2041 ));
2042 }
2043}
2044
2045#[cfg(target_os = "windows")]
2046fn result_row(
2047 item: &crate::model::SearchItem,
2048 result_index: usize,
2049 role: OverlayRowRole,
2050 command_mode: bool,
2051) -> OverlayRow {
2052 OverlayRow {
2053 role,
2054 result_index: result_index as i32,
2055 kind: item.kind.clone(),
2056 title: item.title.clone(),
2057 path: overlay_subtitle(item, command_mode),
2058 icon_path: item.path.clone(),
2059 }
2060}
2061
2062#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2063fn dedupe_overlay_results(results: &mut Vec<crate::model::SearchItem>) {
2064 let app_title_keys: std::collections::HashSet<String> = results
2065 .iter()
2066 .filter(|item| item.kind.eq_ignore_ascii_case("app"))
2067 .filter(|item| !should_hide_known_start_menu_doc_sample_entry(item))
2068 .filter_map(|item| {
2069 let key = normalize_title_key(&item.title);
2070 if key.is_empty() {
2071 None
2072 } else {
2073 Some(key)
2074 }
2075 })
2076 .collect();
2077
2078 let mut seen_app_titles = std::collections::HashSet::new();
2079 let mut seen_other_paths = std::collections::HashSet::new();
2080
2081 results.retain(|item| {
2082 if item.kind.eq_ignore_ascii_case("app") {
2083 if should_hide_known_start_menu_doc_sample_entry(item) {
2084 return false;
2085 }
2086 let key = normalize_title_key(&item.title);
2087 if key.is_empty() {
2088 return true;
2089 }
2090 return seen_app_titles.insert(key);
2091 }
2092
2093 if item.kind.eq_ignore_ascii_case("file")
2094 && is_windows_shortcut_path(&item.path)
2095 && app_title_keys.contains(&shortcut_base_title_key(&item.title))
2096 {
2097 return false;
2098 }
2099
2100 let key = normalize_path_key(&item.path);
2101 if key.is_empty() {
2102 return true;
2103 }
2104 seen_other_paths.insert(key)
2105 });
2106}
2107
2108#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2109fn should_hide_known_start_menu_doc_sample_entry(item: &crate::model::SearchItem) -> bool {
2110 if !item.kind.eq_ignore_ascii_case("app") {
2111 return false;
2112 }
2113
2114 let lower = item.title.trim().to_ascii_lowercase();
2115 let path_lower = item.path.trim().replace('/', "\\").to_ascii_lowercase();
2116 let is_shell_appsfolder = path_lower.starts_with("shell:appsfolder\\");
2117
2118 if path_lower.contains("\\windows kits\\10\\shortcuts\\") && path_lower.ends_with(".url") {
2119 return true;
2120 }
2121 if has_non_app_document_extension(path_lower.as_str()) {
2122 return true;
2123 }
2124 if is_shell_appsfolder && path_lower.contains("://") {
2125 return true;
2126 }
2127
2128 if lower.is_empty() {
2129 return false;
2130 }
2131 if has_non_app_document_extension(lower.as_str()) {
2132 return true;
2133 }
2134
2135 let has_docs = lower.contains("documentation") || lower.contains(" docs");
2136 let has_sample = lower.contains("sample");
2137 let has_tools_for = lower.contains("tools for");
2138 let has_help_content = lower.contains("manual")
2139 || lower.contains("faq")
2140 || lower.contains("website")
2141 || lower.contains("web page")
2142 || lower.contains("webpage")
2143 || lower.contains("guide")
2144 || lower.contains("readme")
2145 || lower.contains("release notes")
2146 || lower.contains("changelog");
2147 let has_apps = lower.contains(" app") || lower.contains("apps");
2148 let has_platform =
2149 lower.contains("desktop") || lower.contains("uwp") || lower.contains("winui");
2150
2151 (has_docs && has_apps)
2152 || (has_sample && (has_apps || has_platform))
2153 || (has_tools_for && has_apps && has_platform)
2154 || (has_help_content && (path_lower.ends_with(".lnk") || is_shell_appsfolder))
2155}
2156
2157#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2158fn has_non_app_document_extension(value: &str) -> bool {
2159 let normalized = value.trim().to_ascii_lowercase();
2160 if normalized.is_empty() {
2161 return false;
2162 }
2163 [
2164 ".url", ".pdf", ".htm", ".html", ".xhtml", ".mht", ".mhtml", ".chm", ".txt", ".md", ".rtf",
2165 ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".csv", ".xml", ".json", ".yaml",
2166 ".yml", ".ini", ".log", ".php",
2167 ]
2168 .iter()
2169 .any(|ext| normalized.ends_with(ext))
2170}
2171
2172#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2173fn normalize_title_key(title: &str) -> String {
2174 crate::model::normalize_for_search(title.trim())
2175}
2176
2177#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2178fn shortcut_base_title_key(title: &str) -> String {
2179 let trimmed = title.trim();
2180 if trimmed.len() >= 4 && trimmed[trimmed.len() - 4..].eq_ignore_ascii_case(".lnk") {
2181 normalize_title_key(&trimmed[..trimmed.len() - 4])
2182 } else {
2183 normalize_title_key(trimmed)
2184 }
2185}
2186
2187#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2188fn is_windows_shortcut_path(path: &str) -> bool {
2189 let trimmed = path.trim();
2190 trimmed.len() >= 4 && trimmed[trimmed.len() - 4..].eq_ignore_ascii_case(".lnk")
2191}
2192
2193#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2194fn normalize_path_key(path: &str) -> String {
2195 let trimmed = path.trim();
2196 let mut normalized = String::with_capacity(trimmed.len());
2197 for ch in trimmed.chars() {
2198 if ch == '/' {
2199 normalized.push('\\');
2200 } else if ch.is_ascii_uppercase() {
2201 normalized.push(ch.to_ascii_lowercase());
2202 } else {
2203 normalized.push(ch);
2204 }
2205 }
2206 normalized
2207}
2208
2209#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2210fn track_uninstall_title_suppression(
2211 suppressed_uninstall_titles: &mut Vec<String>,
2212 action_title: &str,
2213) {
2214 let Some(target_title) = uninstall_target_title_from_action_title(action_title) else {
2215 return;
2216 };
2217 if suppressed_uninstall_titles
2218 .iter()
2219 .any(|existing| existing.eq_ignore_ascii_case(target_title.as_str()))
2220 {
2221 return;
2222 }
2223 suppressed_uninstall_titles.push(target_title);
2224}
2225
2226#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2227fn reconcile_suppressed_uninstall_titles(suppressed_uninstall_titles: &mut Vec<String>) {
2228 if suppressed_uninstall_titles.is_empty() {
2229 return;
2230 }
2231
2232 suppressed_uninstall_titles.retain(|title| {
2233 match crate::uninstall_registry::is_display_name_registered(title.as_str()) {
2234 Ok(still_registered) => still_registered,
2237 Err(error) => {
2238 log_warn(&format!(
2239 "[nex] uninstall suppression registry check failed for '{}': {}",
2240 title, error
2241 ));
2242 true
2243 }
2244 }
2245 });
2246}
2247
2248#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2249fn filter_suppressed_uninstall_results(
2250 results: &mut Vec<crate::model::SearchItem>,
2251 suppressed_uninstall_titles: &[String],
2252) {
2253 if results.is_empty() || suppressed_uninstall_titles.is_empty() {
2254 return;
2255 }
2256
2257 let suppressed_keys: Vec<String> = suppressed_uninstall_titles
2258 .iter()
2259 .map(|title| crate::model::normalize_for_search(title.as_str()))
2260 .filter(|key| !key.is_empty())
2261 .collect();
2262 if suppressed_keys.is_empty() {
2263 return;
2264 }
2265
2266 results.retain(|item| {
2267 let title_key = if item.kind.eq_ignore_ascii_case("app") {
2268 item.normalized_title().to_string()
2269 } else if item.kind.eq_ignore_ascii_case("action")
2270 && item
2271 .id
2272 .starts_with(crate::uninstall_registry::ACTION_UNINSTALL_PREFIX)
2273 {
2274 uninstall_target_title_from_action_title(item.title.as_str())
2275 .map(|title| crate::model::normalize_for_search(title.as_str()))
2276 .unwrap_or_default()
2277 } else {
2278 return true;
2279 };
2280 if title_key.is_empty() {
2281 return true;
2282 }
2283
2284 !suppressed_keys
2285 .iter()
2286 .any(|suppressed| uninstall_title_matches(title_key.as_str(), suppressed.as_str()))
2287 });
2288}
2289
2290#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2291fn uninstall_target_title_from_action_title(action_title: &str) -> Option<String> {
2292 let trimmed = action_title.trim();
2293 if trimmed.len() <= "Uninstall ".len() {
2294 return None;
2295 }
2296 if !trimmed
2297 .get(.."Uninstall ".len())
2298 .map(|prefix| prefix.eq_ignore_ascii_case("Uninstall "))
2299 .unwrap_or(false)
2300 {
2301 return None;
2302 }
2303
2304 let target = trimmed["Uninstall ".len()..].trim();
2305 if target.is_empty() {
2306 None
2307 } else {
2308 Some(target.to_string())
2309 }
2310}
2311
2312#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2313fn uninstall_title_matches(app_title_key: &str, suppressed_key: &str) -> bool {
2314 if app_title_key.is_empty() || suppressed_key.is_empty() {
2315 return false;
2316 }
2317 if app_title_key == suppressed_key {
2318 return true;
2319 }
2320
2321 if suppressed_key.len() >= 6
2322 && (app_title_key.starts_with(suppressed_key) || suppressed_key.starts_with(app_title_key))
2323 {
2324 return true;
2325 }
2326
2327 suppressed_key.len() >= 10 && app_title_key.contains(suppressed_key)
2328}
2329
2330#[cfg(target_os = "windows")]
2331fn overlay_subtitle(item: &crate::model::SearchItem, command_mode: bool) -> String {
2332 if command_mode
2333 && item.kind.eq_ignore_ascii_case("action")
2334 && !item
2335 .id
2336 .starts_with(crate::uninstall_registry::ACTION_UNINSTALL_PREFIX)
2337 {
2338 return String::new();
2339 }
2340 if item.kind.eq_ignore_ascii_case("app") {
2341 return item.subtitle.trim().to_string();
2342 }
2343 if item.kind.eq_ignore_ascii_case("action") {
2344 if item.path.trim().is_empty() {
2345 return "Nex action".to_string();
2346 }
2347 return item.path.trim().to_string();
2348 }
2349 abbreviate_path(&item.path)
2350}
2351
2352#[cfg(target_os = "windows")]
2353fn abbreviate_path(path: &str) -> String {
2354 let trimmed = path.trim();
2355 if trimmed.is_empty() {
2356 return String::new();
2357 }
2358 if trimmed.contains("://") {
2359 return trimmed.to_string();
2360 }
2361
2362 let normalized = trimmed.replace('/', "\\");
2363 let mut parts: Vec<&str> = normalized.split('\\').filter(|s| !s.is_empty()).collect();
2364 if parts.is_empty() {
2365 return normalized;
2366 }
2367
2368 if parts.first().is_some_and(|part| part.ends_with(':')) {
2370 parts.remove(0);
2371 }
2372
2373 if parts.is_empty() {
2374 return String::new();
2375 }
2376
2377 let tail_count = parts.len().min(3);
2378 let joined_tail = parts[parts.len() - tail_count..].join("\\");
2379 if parts.len() > 3 {
2380 format!("...\\{joined_tail}")
2381 } else {
2382 joined_tail
2383 }
2384}
2385
2386#[cfg(target_os = "windows")]
2387fn set_idle_overlay_state(overlay: &NativeOverlayShell) {
2388 overlay.clear_placeholder_hint();
2389 overlay.set_results(&[], 0);
2390 overlay.set_status_text("");
2391}
2392
2393#[cfg(target_os = "windows")]
2394fn set_status_row_overlay_state(overlay: &NativeOverlayShell, message: &str) {
2395 overlay.clear_placeholder_hint();
2396 let rows = [OverlayRow {
2397 role: OverlayRowRole::Status,
2398 result_index: -1,
2399 kind: "status".to_string(),
2400 title: message.to_string(),
2401 path: String::new(),
2402 icon_path: String::new(),
2403 }];
2404 overlay.set_results(&rows, 0);
2405 overlay.set_status_text("");
2406}
2407
2408#[cfg(target_os = "windows")]
2409fn reset_overlay_session(
2410 overlay: &NativeOverlayShell,
2411 current_results: &mut Vec<crate::model::SearchItem>,
2412 selected_index: &mut usize,
2413) {
2414 overlay.clear_query_text();
2415 current_results.clear();
2416 *selected_index = 0;
2417 set_idle_overlay_state(overlay);
2418}
2419
2420#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2421fn next_selection_index(current: usize, len: usize, direction: i32) -> usize {
2422 if len == 0 {
2423 return 0;
2424 }
2425
2426 let max = len - 1;
2427 if direction < 0 {
2428 current.saturating_sub(1)
2429 } else if direction > 0 {
2430 (current + 1).min(max)
2431 } else {
2432 current.min(max)
2433 }
2434}
2435
2436#[cfg(target_os = "windows")]
2437fn log_registration(registration: &HotkeyRegistration) {
2438 match registration {
2439 HotkeyRegistration::Native(id) => {
2440 log_info(&format!(
2441 "[nex] hotkey registered native_id={id}"
2442 ));
2443 }
2444 HotkeyRegistration::Noop(label) => {
2445 log_info(&format!("[nex] hotkey registered noop={label}"));
2446 }
2447 }
2448}
2449
2450#[cfg(target_os = "windows")]
2451struct SingleInstanceGuard {
2452 handle: windows_sys::Win32::Foundation::HANDLE,
2453}
2454
2455#[cfg(target_os = "windows")]
2456impl Drop for SingleInstanceGuard {
2457 fn drop(&mut self) {
2458 unsafe {
2459 windows_sys::Win32::Foundation::CloseHandle(self.handle);
2460 }
2461 }
2462}
2463
2464#[cfg(target_os = "windows")]
2465fn acquire_single_instance_guard() -> Result<Option<SingleInstanceGuard>, String> {
2466 use windows_sys::Win32::Foundation::GetLastError;
2467 use windows_sys::Win32::System::Threading::CreateMutexW;
2468
2469 let mutex_name = to_wide("Local\\NexRuntimeSingleton");
2470 let handle = unsafe { CreateMutexW(std::ptr::null(), 0, mutex_name.as_ptr()) };
2471 if handle.is_null() {
2472 let error = unsafe { GetLastError() };
2473 return Err(format!("CreateMutexW failed with error {error}"));
2474 }
2475
2476 let error = unsafe { GetLastError() };
2478 if error == 183 {
2479 unsafe {
2480 windows_sys::Win32::Foundation::CloseHandle(handle);
2481 }
2482 return Ok(None);
2483 }
2484
2485 Ok(Some(SingleInstanceGuard { handle }))
2486}
2487
2488#[cfg(target_os = "windows")]
2489fn to_wide(value: &str) -> Vec<u16> {
2490 value.encode_utf16().chain(std::iter::once(0)).collect()
2491}
2492
2493#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
2494#[derive(Debug, Clone, Default, PartialEq, Eq)]
2495struct ForegroundWindowSnapshot {
2496 class_name: String,
2497 process_name: String,
2498 process_path: String,
2499 covers_monitor: bool,
2500 has_standard_frame: bool,
2501 maximized: bool,
2502}
2503
2504#[cfg(target_os = "windows")]
2505fn toggle_game_mode_from_tray(overlay: &NativeOverlayShell, runtime_config: &mut Config) {
2506 let previous = runtime_config.game_mode_enabled;
2507 let next = !previous;
2508 runtime_config.game_mode_enabled = next;
2509 overlay.set_game_mode_enabled(next);
2510
2511 if let Err(error) = config::write_user_template(runtime_config, &runtime_config.config_path) {
2512 runtime_config.game_mode_enabled = previous;
2513 overlay.set_game_mode_enabled(previous);
2514 log_warn(&format!(
2515 "[nex] failed to persist game mode toggle: {error}"
2516 ));
2517 overlay.set_status_text("Could not update Game Mode setting");
2518 return;
2519 }
2520
2521 log_info(&format!(
2522 "[nex] game mode updated from tray: enabled={next}"
2523 ));
2524 overlay.set_status_text(if next {
2525 "Game Mode enabled"
2526 } else {
2527 "Game Mode disabled"
2528 });
2529}
2530
2531#[cfg(target_os = "windows")]
2532fn should_suppress_hotkey_for_game_mode(cfg: &Config) -> bool {
2533 if !cfg.game_mode_enabled {
2534 return false;
2535 }
2536
2537 collect_foreground_window_snapshot()
2538 .is_some_and(|snapshot| should_block_hotkey_for_foreground_window(&snapshot))
2539}
2540
2541#[cfg(target_os = "windows")]
2542fn collect_foreground_window_snapshot() -> Option<ForegroundWindowSnapshot> {
2543 use windows_sys::Win32::Foundation::{CloseHandle, RECT};
2544 use windows_sys::Win32::Graphics::Gdi::{
2545 GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
2546 };
2547 use windows_sys::Win32::System::Threading::{
2548 OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
2549 };
2550 use windows_sys::Win32::UI::WindowsAndMessaging::{
2551 GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowPlacement, GetWindowRect,
2552 GetWindowThreadProcessId, IsIconic, IsWindowVisible, GWL_STYLE, SW_SHOWMAXIMIZED,
2553 WINDOWPLACEMENT, WS_CAPTION, WS_MAXIMIZE, WS_SYSMENU, WS_THICKFRAME,
2554 };
2555
2556 let foreground = unsafe { GetForegroundWindow() };
2557 if foreground.is_null() {
2558 return None;
2559 }
2560 if unsafe { IsWindowVisible(foreground) } == 0 || unsafe { IsIconic(foreground) } != 0 {
2561 return None;
2562 }
2563
2564 let mut class_buf = [0u16; 128];
2565 let class_len =
2566 unsafe { GetClassNameW(foreground, class_buf.as_mut_ptr(), class_buf.len() as i32) };
2567 let class_name = if class_len > 0 {
2568 String::from_utf16_lossy(&class_buf[..class_len as usize])
2569 } else {
2570 String::new()
2571 };
2572 if is_shell_surface_class_name(&class_name) {
2573 return None;
2574 }
2575
2576 let monitor = unsafe { MonitorFromWindow(foreground, MONITOR_DEFAULTTONEAREST) };
2577 if monitor.is_null() {
2578 return None;
2579 }
2580
2581 let mut monitor_info = MONITORINFO {
2582 cbSize: std::mem::size_of::<MONITORINFO>() as u32,
2583 rcMonitor: RECT {
2584 left: 0,
2585 top: 0,
2586 right: 0,
2587 bottom: 0,
2588 },
2589 rcWork: RECT {
2590 left: 0,
2591 top: 0,
2592 right: 0,
2593 bottom: 0,
2594 },
2595 dwFlags: 0,
2596 };
2597 if unsafe { GetMonitorInfoW(monitor, &mut monitor_info as *mut MONITORINFO) } == 0 {
2598 return None;
2599 }
2600
2601 let mut rect = RECT {
2602 left: 0,
2603 top: 0,
2604 right: 0,
2605 bottom: 0,
2606 };
2607 if unsafe { GetWindowRect(foreground, &mut rect as *mut RECT) } == 0 {
2608 return None;
2609 }
2610
2611 let fuzz = 2;
2612 let covers_monitor = rect.left <= monitor_info.rcMonitor.left + fuzz
2613 && rect.top <= monitor_info.rcMonitor.top + fuzz
2614 && rect.right >= monitor_info.rcMonitor.right - fuzz
2615 && rect.bottom >= monitor_info.rcMonitor.bottom - fuzz;
2616
2617 let style = unsafe { GetWindowLongPtrW(foreground, GWL_STYLE) as u32 };
2618 let has_standard_frame =
2619 style & ((WS_CAPTION | WS_THICKFRAME | WS_SYSMENU) as u32) != 0;
2620
2621 let mut placement = WINDOWPLACEMENT {
2622 length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
2623 flags: 0,
2624 showCmd: 0,
2625 ptMinPosition: windows_sys::Win32::Foundation::POINT { x: 0, y: 0 },
2626 ptMaxPosition: windows_sys::Win32::Foundation::POINT { x: 0, y: 0 },
2627 rcNormalPosition: RECT {
2628 left: 0,
2629 top: 0,
2630 right: 0,
2631 bottom: 0,
2632 },
2633 };
2634 let placement_reports_maximized =
2635 unsafe { GetWindowPlacement(foreground, &mut placement as *mut WINDOWPLACEMENT) } != 0
2636 && placement.showCmd == SW_SHOWMAXIMIZED as u32;
2637 let maximized = placement_reports_maximized || (style & (WS_MAXIMIZE as u32) != 0);
2638
2639 let mut pid = 0u32;
2640 unsafe {
2641 GetWindowThreadProcessId(foreground, &mut pid as *mut u32);
2642 }
2643
2644 let mut process_path = String::new();
2645 let mut process_name = String::new();
2646 if pid != 0 {
2647 let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2648 if !process.is_null() {
2649 let mut buffer = vec![0u16; 1024];
2650 let mut length = buffer.len() as u32;
2651 let ok = unsafe {
2652 QueryFullProcessImageNameW(process, 0, buffer.as_mut_ptr(), &mut length as *mut u32)
2653 };
2654 if ok != 0 && length > 0 {
2655 process_path = String::from_utf16_lossy(&buffer[..length as usize]);
2656 process_name = std::path::Path::new(&process_path)
2657 .file_name()
2658 .and_then(|name| name.to_str())
2659 .unwrap_or_default()
2660 .to_string();
2661 }
2662 unsafe {
2663 CloseHandle(process);
2664 }
2665 }
2666 }
2667
2668 Some(ForegroundWindowSnapshot {
2669 class_name,
2670 process_name,
2671 process_path,
2672 covers_monitor,
2673 has_standard_frame,
2674 maximized,
2675 })
2676}
2677
2678#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
2679fn should_block_hotkey_for_foreground_window(snapshot: &ForegroundWindowSnapshot) -> bool {
2680 if !snapshot.covers_monitor {
2681 return false;
2682 }
2683 if snapshot.maximized && snapshot.has_standard_frame {
2684 return false;
2685 }
2686
2687 let process_name = snapshot.process_name.trim().to_ascii_lowercase();
2688 let process_path = snapshot.process_path.trim().to_ascii_lowercase();
2689 if is_known_non_game_process(&process_name) || is_known_non_game_path(&process_path) {
2690 return false;
2691 }
2692 if is_known_game_process(&process_name) || is_likely_game_path(&process_path) {
2693 return true;
2694 }
2695
2696 !snapshot.has_standard_frame
2697}
2698
2699#[allow(dead_code)]
2700fn is_shell_surface_class_name(class_name: &str) -> bool {
2701 matches!(
2702 class_name.trim().to_ascii_lowercase().as_str(),
2703 "progman" | "workerw" | "shell_traywnd"
2704 )
2705}
2706
2707#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
2708fn is_known_non_game_process(process_name: &str) -> bool {
2709 matches!(
2710 process_name,
2711 ""
2712 | "explorer.exe"
2713 | "taskmgr.exe"
2714 | "chrome.exe"
2715 | "msedge.exe"
2716 | "firefox.exe"
2717 | "waterfox.exe"
2718 | "code.exe"
2719 | "devenv.exe"
2720 | "wezterm-gui.exe"
2721 | "windowsterminal.exe"
2722 | "powershell.exe"
2723 | "pwsh.exe"
2724 | "cmd.exe"
2725 | "notepad.exe"
2726 | "notepad++.exe"
2727 | "vlc.exe"
2728 | "mpv.exe"
2729 | "obs64.exe"
2730 | "applicationframehost.exe"
2731 | "searchhost.exe"
2732 | "startmenuexperiencehost.exe"
2733 | "lockapp.exe"
2734 )
2735}
2736
2737#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
2738fn is_known_non_game_path(process_path: &str) -> bool {
2739 process_path.contains("\\microsoft\\edge\\application\\")
2740 || process_path.contains("\\google\\chrome\\application\\")
2741 || process_path.contains("\\mozilla firefox\\")
2742 || process_path.contains("\\microsoft\\windowsapps\\")
2743}
2744
2745#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
2746fn is_known_game_process(process_name: &str) -> bool {
2747 process_name.contains("valorant")
2748 || process_name == "cs2.exe"
2749 || process_name == "csgo.exe"
2750 || process_name == "cod.exe"
2751 || process_name == "modernwarfare.exe"
2752 || process_name.ends_with("-shipping.exe")
2753}
2754
2755#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
2756fn is_likely_game_path(process_path: &str) -> bool {
2757 process_path.contains("\\steamapps\\common\\")
2758 || process_path.contains("\\riot games\\")
2759 || process_path.contains("\\epic games\\")
2760 || process_path.contains("\\battle.net\\")
2761 || process_path.contains("\\blizzard entertainment\\")
2762 || process_path.contains("\\ubisoft\\")
2763 || process_path.contains("\\rockstar games\\")
2764 || process_path.contains("\\gog galaxy\\games\\")
2765 || process_path.contains("\\ea games\\")
2766 || process_path.contains("\\electronic arts\\")
2767}
2768
2769#[cfg(target_os = "windows")]
2770fn start_background_index_refresh(
2771 config: &Config,
2772 initial_cache_empty: bool,
2773 startup_started_at: Instant,
2774) -> BackgroundIndexRefresh {
2775 let completed = Arc::new(AtomicBool::new(false));
2776 let result = Arc::new(Mutex::new(None));
2777 let completed_worker = completed.clone();
2778 let result_worker = result.clone();
2779 let worker_config = config.clone();
2780 std::thread::spawn(move || {
2781 let outcome = CoreService::new(worker_config)
2782 .map(|service| service.with_runtime_providers())
2783 .and_then(|service| service.rebuild_index_incremental_with_report())
2784 .map_err(|error| format!("background indexing failed: {error}"));
2785 let mut slot = match result_worker.lock() {
2786 Ok(guard) => guard,
2787 Err(poisoned) => poisoned.into_inner(),
2788 };
2789 *slot = Some(outcome);
2790 completed_worker.store(true, Ordering::Release);
2791 });
2792
2793 BackgroundIndexRefresh {
2794 completed,
2795 result,
2796 cache_applied: false,
2797 initial_cache_empty,
2798 pending_discovery_reindex: false,
2799 pending_discovery_reindex_due_at: None,
2800 pending_discovery_reindex_requests: 0,
2801 ready_notice_pending: false,
2802 started_at: Instant::now(),
2803 startup_started_at,
2804 }
2805}
2806
2807#[cfg(target_os = "windows")]
2808fn maybe_apply_background_index_refresh(
2809 service: &CoreService,
2810 state: &mut BackgroundIndexRefresh,
2811 runtime_config: &Config,
2812) {
2813 if state.cache_applied {
2814 maybe_start_queued_discovery_reindex(service, state, runtime_config);
2815 return;
2816 }
2817 if !state.completed.load(Ordering::Acquire) {
2818 return;
2819 }
2820
2821 let outcome = {
2822 let mut slot = match state.result.lock() {
2823 Ok(guard) => guard,
2824 Err(poisoned) => poisoned.into_inner(),
2825 };
2826 slot.take()
2827 };
2828
2829 match outcome {
2830 Some(Ok(report)) => {
2831 let elapsed_ms = state.started_at.elapsed().as_millis();
2832 let startup_elapsed_ms = state.startup_started_at.elapsed().as_millis();
2833 log_info(&format!(
2834 "[nex] startup_phase phase=indexing_completed elapsed_ms={} worker_elapsed_ms={} indexed_items={} discovered={} upserted={} removed={}",
2835 startup_elapsed_ms,
2836 elapsed_ms,
2837 report.indexed_total,
2838 report.discovered_total,
2839 report.upserted_total,
2840 report.removed_total
2841 ));
2842 match service.reload_cache_from_store() {
2843 Ok(cached_items) => {
2844 log_info(&format!(
2845 "[nex] startup indexed_items={} discovered={} upserted={} removed={} elapsed_ms={} cached_items={}",
2846 report.indexed_total,
2847 report.discovered_total,
2848 report.upserted_total,
2849 report.removed_total,
2850 elapsed_ms,
2851 cached_items
2852 ));
2853 log_info(&format!(
2854 "[nex] startup_phase phase=cache_applied elapsed_ms={} cached_items={} initial_cache_empty={}",
2855 startup_elapsed_ms,
2856 cached_items,
2857 state.initial_cache_empty
2858 ));
2859 for provider in &report.providers {
2860 log_info(&format!(
2861 "[nex] index_provider name={} discovered={} upserted={} removed={} skipped={} elapsed_ms={}",
2862 provider.provider,
2863 provider.discovered,
2864 provider.upserted,
2865 provider.removed,
2866 provider.skipped,
2867 provider.elapsed_ms
2868 ));
2869 }
2870 }
2871 Err(error) => {
2872 log_warn(&format!(
2873 "[nex] background indexing cache refresh failed: {error}"
2874 ));
2875 }
2876 }
2877 if state.initial_cache_empty {
2878 state.ready_notice_pending = true;
2879 }
2880 }
2881 Some(Err(error)) => {
2882 log_warn(&format!("[nex] {error}"));
2883 }
2884 None => {
2885 log_warn("[nex] background indexing completed without result");
2886 }
2887 }
2888
2889 state.cache_applied = true;
2890
2891 if state.pending_discovery_reindex {
2892 log_info("[nex] discovery settings queued during indexing; pending reindex remains scheduled");
2893 maybe_start_queued_discovery_reindex(service, state, runtime_config);
2894 }
2895}
2896
2897#[cfg(target_os = "windows")]
2898fn should_show_indexing_status(state: &BackgroundIndexRefresh) -> bool {
2899 state.initial_cache_empty && !state.cache_applied
2900}
2901
2902#[cfg(target_os = "windows")]
2903fn queue_discovery_reindex_after_active_index(state: &mut BackgroundIndexRefresh) {
2904 state.pending_discovery_reindex = true;
2905 state.pending_discovery_reindex_requests =
2906 state.pending_discovery_reindex_requests.saturating_add(1);
2907 state.pending_discovery_reindex_due_at = Some(
2908 Instant::now() + Duration::from_millis(QUEUED_DISCOVERY_REINDEX_DEBOUNCE_MS),
2909 );
2910}
2911
2912#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2913fn queued_discovery_reindex_is_due(
2914 cache_applied: bool,
2915 pending: bool,
2916 due_at: Option<Instant>,
2917 now: Instant,
2918) -> bool {
2919 cache_applied && pending && due_at.is_some_and(|due| now >= due)
2920}
2921
2922#[cfg(target_os = "windows")]
2923fn maybe_start_queued_discovery_reindex(
2924 service: &CoreService,
2925 state: &mut BackgroundIndexRefresh,
2926 runtime_config: &Config,
2927) {
2928 if !queued_discovery_reindex_is_due(
2929 state.cache_applied,
2930 state.pending_discovery_reindex,
2931 state.pending_discovery_reindex_due_at,
2932 Instant::now(),
2933 ) {
2934 return;
2935 }
2936
2937 let request_count = state.pending_discovery_reindex_requests.max(1);
2938 let startup_started_at = state.startup_started_at;
2939 log_info(&format!(
2940 "[nex] discovery settings queued during indexing; starting debounced reindex requests={} debounce_ms={}",
2941 request_count,
2942 QUEUED_DISCOVERY_REINDEX_DEBOUNCE_MS
2943 ));
2944 log_info(&format!(
2945 "[nex] startup_phase phase=indexing_started elapsed_ms={} initial_cache_empty=false cached_items={}",
2946 startup_started_at.elapsed().as_millis(),
2947 service.cached_items_len()
2948 ));
2949 *state = start_background_index_refresh(runtime_config, false, startup_started_at);
2950}
2951
2952#[cfg(target_os = "windows")]
2953fn maybe_show_background_index_ready_notice(
2954 overlay: &NativeOverlayShell,
2955 state: &mut BackgroundIndexRefresh,
2956) {
2957 if !state.ready_notice_pending || !overlay.is_visible() {
2958 return;
2959 }
2960 if !overlay.query_text().trim().is_empty() {
2961 return;
2962 }
2963
2964 set_idle_overlay_state(overlay);
2965 overlay.show_placeholder_hint(STATUS_ROW_TYPE_TO_SEARCH);
2966 overlay.set_status_text(STATUS_TEXT_INDEX_READY);
2967 state.ready_notice_pending = false;
2968}
2969
2970#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2971#[cfg_attr(not(test), allow(dead_code))]
2972fn search_overlay_results(
2973 service: &CoreService,
2974 cfg: &Config,
2975 plugins: &PluginRegistry,
2976 parsed_query: &ParsedQuery,
2977 result_limit: usize,
2978) -> Result<Vec<crate::model::SearchItem>, String> {
2979 let mut session = OverlaySearchSession::default();
2980 search_overlay_results_with_session(
2981 service,
2982 cfg,
2983 plugins,
2984 parsed_query,
2985 result_limit,
2986 &mut session,
2987 )
2988}
2989
2990#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
2991fn search_overlay_results_with_session(
2992 service: &CoreService,
2993 cfg: &Config,
2994 plugins: &PluginRegistry,
2995 parsed_query: &ParsedQuery,
2996 result_limit: usize,
2997 session: &mut OverlaySearchSession,
2998) -> Result<Vec<crate::model::SearchItem>, String> {
2999 if result_limit == 0 {
3000 return Ok(Vec::new());
3001 }
3002
3003 let filter = build_search_filter(cfg, parsed_query);
3004 let text_query = parsed_query.free_text.trim();
3005 let normalized_query = crate::model::normalize_for_search(text_query);
3006 if should_skip_non_searchable_query(parsed_query, &normalized_query) {
3007 log_info(&format!(
3008 "[nex] query_guard skip=non_searchable_symbol_only q=\"{}\"",
3009 sanitize_query_for_profile_log(parsed_query.raw.as_str())
3010 ));
3011 session.clear();
3012 return Ok(Vec::new());
3013 }
3014 let cache_key = final_query_cache_key(parsed_query, &filter, &normalized_query, result_limit);
3015 if let Some(cached) = cached_final_query_results(session, &cache_key) {
3016 return Ok(cached);
3017 }
3018 let candidate_limit = candidate_limit_for_query(
3019 result_limit,
3020 &filter,
3021 &normalized_query,
3022 parsed_query.command_mode,
3023 );
3024 let base_indexed_seed_limit = indexed_seed_limit(candidate_limit, normalized_query.len());
3025 let seed_cap = (cfg.index_max_items_per_query_seed as usize).max(candidate_limit);
3026 let indexed_seed_limit = adaptive_indexed_seed_limit(
3027 session,
3028 candidate_limit,
3029 normalized_query.len(),
3030 base_indexed_seed_limit,
3031 )
3032 .min(seed_cap);
3033 let short_query_app_bias =
3034 should_use_short_query_app_mode(parsed_query, &filter, &normalized_query);
3035 let mut indexed_filter = filter.clone();
3036 if short_query_app_bias {
3037 indexed_filter.mode = crate::config::SearchMode::Apps;
3038 }
3039
3040 let search_started = Instant::now();
3041 let mut merged = Vec::new();
3042 let indexed_started = Instant::now();
3043 let mut indexed_cache_hit = false;
3044 let prefix_cache_eligible = is_prefix_cache_eligible_query(parsed_query, short_query_app_bias);
3045 let indexed_seed_items = if let Some(cache) =
3046 session.indexed_prefix_cache.as_ref().filter(|cache| {
3047 can_use_indexed_prefix_cache(
3048 cache,
3049 prefix_cache_eligible,
3050 &normalized_query,
3051 &indexed_filter,
3052 )
3053 }) {
3054 indexed_cache_hit = true;
3055 crate::search::search_with_filter(
3056 &cache.seed_items,
3057 text_query,
3058 indexed_seed_limit,
3059 &indexed_filter,
3060 )
3061 } else {
3062 service
3063 .search_with_filter_uncapped(text_query, indexed_seed_limit, &indexed_filter)
3064 .map_err(|error| format!("indexed search failed: {error}"))?
3065 };
3066 let indexed_ms = indexed_started.elapsed().as_millis();
3067 if !indexed_cache_hit {
3068 record_indexed_latency_sample(session, indexed_ms);
3069 }
3070 let indexed_count = indexed_seed_items.len();
3071 merged.extend(indexed_seed_items.iter().take(candidate_limit).cloned());
3072 if prefix_cache_eligible && normalized_query.len() >= INDEXED_PREFIX_CACHE_MIN_QUERY_LEN {
3073 session.indexed_prefix_cache = Some(IndexedPrefixCache {
3074 normalized_query: normalized_query.clone(),
3075 indexed_filter: indexed_filter.clone(),
3076 seed_items: indexed_seed_items,
3077 });
3078 } else {
3079 session.clear();
3080 }
3081
3082 let mut provider_ms = 0_u128;
3083 let mut provider_count = 0_usize;
3084 if !short_query_app_bias {
3085 let provider_started = Instant::now();
3086 let provider_results = crate::search::search_with_filter(
3087 &plugins.provider_items,
3088 text_query,
3089 candidate_limit,
3090 &filter,
3091 );
3092 provider_ms = provider_started.elapsed().as_millis();
3093 provider_count = provider_results.len();
3094 merged.extend(provider_results);
3095 }
3096
3097 let actions_started = Instant::now();
3098 let mut action_items =
3099 search_actions_with_mode(text_query, candidate_limit, parsed_query.command_mode, cfg);
3100 let built_in_actions_count = action_items.len();
3101 let mut plugin_action_count = 0_usize;
3102 if !plugins.action_items.is_empty() {
3103 let plugin_actions = crate::search::search_with_filter(
3104 &plugins.action_items,
3105 text_query,
3106 candidate_limit,
3107 &SearchFilter {
3108 mode: crate::config::SearchMode::Actions,
3109 ..SearchFilter::default()
3110 },
3111 );
3112 plugin_action_count = plugin_actions.len();
3113 action_items.extend(plugin_actions);
3114 }
3115 let action_results =
3116 crate::search::search_with_filter(&action_items, text_query, candidate_limit, &filter);
3117 let actions_ms = actions_started.elapsed().as_millis();
3118 let action_count = action_results.len();
3119 merged.extend(action_results);
3120
3121 let mut clipboard_ms = 0_u128;
3122 let mut clipboard_count = 0_usize;
3123 if !short_query_app_bias {
3124 let clipboard_started = Instant::now();
3125 let clipboard_results =
3126 clipboard_history::search_history(cfg, text_query, &filter, candidate_limit.min(120));
3127 clipboard_ms = clipboard_started.elapsed().as_millis();
3128 clipboard_count = clipboard_results.len();
3129 merged.extend(clipboard_results);
3130 }
3131
3132 let rank_started = Instant::now();
3133 let ranked = crate::search::search_with_filter(&merged, text_query, result_limit, &filter);
3134 let rank_ms = rank_started.elapsed().as_millis();
3135 let total_ms = search_started.elapsed().as_millis();
3136 if total_ms >= QUERY_PROFILE_LOG_THRESHOLD_MS {
3137 log_info(&format!(
3138 "[nex] query_profile q=\"{}\" mode={} candidate_limit={} indexed_seed_limit={} short_app_bias={} indexed_cache_hit={} indexed_count={} indexed_ms={} provider_count={} provider_ms={} action_count={} action_ms={} built_in_actions={} plugin_actions={} clipboard_count={} clipboard_ms={} rank_ms={} total_ms={}",
3139 sanitize_query_for_profile_log(text_query),
3140 format!("{:?}", filter.mode).to_ascii_lowercase(),
3141 candidate_limit,
3142 indexed_seed_limit,
3143 short_query_app_bias,
3144 indexed_cache_hit,
3145 indexed_count,
3146 indexed_ms,
3147 provider_count,
3148 provider_ms,
3149 action_count,
3150 actions_ms,
3151 built_in_actions_count,
3152 plugin_action_count,
3153 clipboard_count,
3154 clipboard_ms,
3155 rank_ms,
3156 total_ms
3157 ));
3158 }
3159 store_final_query_results(session, cache_key, ranked.as_slice());
3160 Ok(ranked)
3161}
3162
3163fn build_search_filter(cfg: &Config, parsed_query: &ParsedQuery) -> SearchFilter {
3164 let mode = resolved_mode_for_query(cfg, parsed_query);
3165 SearchFilter {
3166 mode,
3167 kind_filter: parsed_query.kind_filter.clone(),
3168 extension_filter: parsed_query.extension_filter.clone(),
3169 include_files: cfg.show_files,
3170 include_folders: cfg.show_folders,
3171 include_groups: parsed_query.include_groups.clone(),
3172 exclude_terms: parsed_query.exclude_terms.clone(),
3173 modified_within: parsed_query.modified_within,
3174 created_within: parsed_query.created_within,
3175 }
3176}
3177
3178fn resolved_mode_for_query(cfg: &Config, parsed_query: &ParsedQuery) -> crate::config::SearchMode {
3179 let mut mode = parsed_query
3180 .mode_override
3181 .unwrap_or(cfg.search_mode_default);
3182 if parsed_query.command_mode {
3183 mode = crate::config::SearchMode::Actions;
3184 }
3185 mode
3186}
3187
3188#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
3189fn should_use_short_query_app_mode(
3190 parsed_query: &ParsedQuery,
3191 filter: &SearchFilter,
3192 normalized_query: &str,
3193) -> bool {
3194 if normalized_query.is_empty() || normalized_query.len() > SHORT_QUERY_APP_BIAS_MAX_LEN {
3195 return false;
3196 }
3197 if parsed_query.command_mode {
3198 return false;
3199 }
3200 if filter.mode != crate::config::SearchMode::All {
3201 return false;
3202 }
3203 parsed_query.kind_filter.is_none()
3204 && parsed_query.extension_filter.is_none()
3205 && parsed_query.exclude_terms.is_empty()
3206 && parsed_query.modified_within.is_none()
3207 && parsed_query.created_within.is_none()
3208}
3209
3210fn should_skip_non_searchable_query(parsed_query: &ParsedQuery, normalized_query: &str) -> bool {
3211 if !normalized_query.is_empty() {
3212 return false;
3213 }
3214 if parsed_query.command_mode {
3215 return false;
3216 }
3217 if parsed_query.mode_override.is_some() {
3218 return false;
3219 }
3220 parsed_query.kind_filter.is_none()
3221 && parsed_query.extension_filter.is_none()
3222 && parsed_query.include_groups.is_empty()
3223 && parsed_query.exclude_terms.is_empty()
3224 && parsed_query.modified_within.is_none()
3225 && parsed_query.created_within.is_none()
3226}
3227
3228#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
3229fn result_limit_for_query(base_limit: usize, parsed_query: &ParsedQuery) -> usize {
3230 if base_limit == 0 {
3231 return 0;
3232 }
3233 if parsed_query.command_mode
3234 && crate::uninstall_registry::has_uninstall_intent(parsed_query.free_text.as_str())
3235 {
3236 return base_limit.max(UNINSTALL_QUERY_RESULT_LIMIT);
3237 }
3238 base_limit
3239}
3240
3241#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
3242fn maybe_expand_uninstall_quick_shortcut(query: &str, last_query: &str) -> Option<String> {
3243 let raw = query.trim_start();
3244 let remainder = raw.strip_prefix('>')?;
3245 if remainder.eq_ignore_ascii_case("u") {
3246 let last_trimmed = last_query.trim();
3247 if last_trimmed.is_empty() || last_trimmed == ">" {
3248 return Some(">u ".to_string());
3249 }
3250 }
3251 None
3252}
3253
3254#[cfg(target_os = "windows")]
3255fn apply_query_change(
3256 mut query: String,
3257 overlay: &NativeOverlayShell,
3258 service: &CoreService,
3259 runtime_config: &Config,
3260 plugin_registry: &PluginRegistry,
3261 max_results: usize,
3262 background_index_refresh: &BackgroundIndexRefresh,
3263 search_session: &mut OverlaySearchSession,
3264 pending_uninstall_confirmation: &mut Option<PendingUninstallConfirmation>,
3265 suppressed_uninstall_titles: &[String],
3266 current_results: &mut Vec<crate::model::SearchItem>,
3267 selected_index: &mut usize,
3268 last_query: &mut String,
3269) {
3270 *pending_uninstall_confirmation = None;
3271 if let Some(expanded) = maybe_expand_uninstall_quick_shortcut(&query, last_query.as_str()) {
3272 overlay.set_query_text(&expanded);
3273 query = expanded;
3274 }
3275
3276 let trimmed = query.trim();
3277 if trimmed.is_empty() {
3278 current_results.clear();
3279 *selected_index = 0;
3280 last_query.clear();
3281 search_session.clear();
3282 *pending_uninstall_confirmation = None;
3283 set_idle_overlay_state(overlay);
3284 return;
3285 }
3286 if trimmed == last_query {
3287 return;
3288 }
3289 *last_query = trimmed.to_string();
3290 let parsed_query = ParsedQuery::parse(trimmed, runtime_config.search_dsl_enabled);
3291 let query_result_limit = result_limit_for_query(max_results, &parsed_query);
3292
3293 match search_overlay_results_with_session(
3294 service,
3295 runtime_config,
3296 plugin_registry,
3297 &parsed_query,
3298 query_result_limit,
3299 search_session,
3300 ) {
3301 Ok(mut results) => {
3302 dedupe_overlay_results(&mut results);
3303 if !suppressed_uninstall_titles.is_empty() {
3304 filter_suppressed_uninstall_results(&mut results, suppressed_uninstall_titles);
3305 }
3306 *current_results = results;
3307 *selected_index = 0;
3308 if current_results.is_empty() {
3309 if should_show_indexing_status(background_index_refresh) {
3310 set_status_row_overlay_state(overlay, STATUS_ROW_INDEXING);
3311 } else {
3312 set_status_row_overlay_state(
3313 overlay,
3314 if parsed_query.command_mode {
3315 STATUS_ROW_NO_COMMAND_RESULTS
3316 } else {
3317 STATUS_ROW_NO_RESULTS
3318 },
3319 );
3320 }
3321 } else {
3322 let rows = overlay_rows(current_results, parsed_query.command_mode);
3323 overlay.set_results(&rows, *selected_index);
3324 }
3325 }
3326 Err(error) => {
3327 current_results.clear();
3328 *selected_index = 0;
3329 search_session.clear();
3330 overlay.set_results(&[], 0);
3331 overlay.set_status_text(&format!("Search error: {error}"));
3332 }
3333 }
3334}
3335
3336#[cfg(target_os = "windows")]
3337fn config_file_modified_time(path: &std::path::Path) -> Option<SystemTime> {
3338 std::fs::metadata(path).ok()?.modified().ok()
3339}
3340
3341#[cfg(target_os = "windows")]
3342fn maybe_apply_runtime_config_reload(
3343 overlay: &NativeOverlayShell,
3344 service: &CoreService,
3345 runtime_config: &mut Config,
3346 plugin_registry: &mut PluginRegistry,
3347 search_session: &mut OverlaySearchSession,
3348 pending_uninstall_confirmation: &mut Option<PendingUninstallConfirmation>,
3349 max_results: &mut usize,
3350 watcher: &mut RuntimeConfigWatcher,
3351 background_index_refresh: &mut BackgroundIndexRefresh,
3352) {
3353 if watcher.last_checked.elapsed() < CONFIG_RELOAD_POLL_INTERVAL {
3354 return;
3355 }
3356 watcher.last_checked = Instant::now();
3357
3358 let modified = config_file_modified_time(watcher.path.as_path());
3359 if modified == watcher.last_modified {
3360 return;
3361 }
3362 watcher.last_modified = modified;
3363
3364 match config::load(Some(watcher.path.as_path())) {
3365 Ok(next_config) => {
3366 let previous = runtime_config.clone();
3367 let hotkey_changed = next_config.hotkey != previous.hotkey;
3368 let index_db_path_changed = next_config.index_db_path != previous.index_db_path;
3369 let discovery_config_changed = next_config.discovery_roots != previous.discovery_roots
3370 || next_config.discovery_exclude_roots != previous.discovery_exclude_roots
3371 || next_config.windows_search_enabled != previous.windows_search_enabled
3372 || next_config.windows_search_fallback_filesystem
3373 != previous.windows_search_fallback_filesystem
3374 || next_config.show_files != previous.show_files
3375 || next_config.show_folders != previous.show_folders
3376 || next_config.index_max_items_total != previous.index_max_items_total
3377 || next_config.index_max_items_per_root != previous.index_max_items_per_root
3378 || next_config.index_max_items_per_query_seed
3379 != previous.index_max_items_per_query_seed;
3380 let mut discovery_reindex_queued = false;
3381 *runtime_config = next_config;
3382 *max_results = runtime_config.max_results as usize;
3383
3384 overlay.set_performance_tuning(
3385 runtime_config.idle_cache_trim_ms,
3386 runtime_config.active_memory_target_mb,
3387 );
3388 overlay.set_game_mode_enabled(runtime_config.game_mode_enabled);
3389 *plugin_registry = PluginRegistry::load_from_config(runtime_config);
3390 for warning in &plugin_registry.load_warnings {
3391 log_warn(&format!("[nex] plugin_warning {warning}"));
3392 }
3393 search_session.clear();
3394 *pending_uninstall_confirmation = None;
3395
3396 if hotkey_changed {
3397 log_warn(&format!(
3398 "[nex] config hotkey changed ({} -> {}), restart required to apply",
3399 previous.hotkey, runtime_config.hotkey
3400 ));
3401 }
3402 if index_db_path_changed {
3403 log_warn(
3404 "[nex] config index_db_path changed; restart required to apply",
3405 );
3406 }
3407 if discovery_config_changed {
3408 if let Err(error) = service.reconfigure_runtime_providers(runtime_config) {
3409 log_warn(&format!(
3410 "[nex] provider reconfigure failed after config reload: {error}"
3411 ));
3412 } else {
3413 if background_index_refresh.cache_applied {
3414 *background_index_refresh =
3415 start_background_index_refresh(
3416 runtime_config,
3417 false,
3418 background_index_refresh.startup_started_at,
3419 );
3420 log_info("[nex] discovery settings changed; background reindex started");
3421 } else {
3422 queue_discovery_reindex_after_active_index(background_index_refresh);
3423 discovery_reindex_queued = true;
3424 log_info(&format!(
3425 "[nex] discovery settings changed while indexing is active; reindex queued debounce_ms={} requests={}",
3426 QUEUED_DISCOVERY_REINDEX_DEBOUNCE_MS,
3427 background_index_refresh.pending_discovery_reindex_requests
3428 ));
3429 }
3430 }
3431 }
3432
3433 log_info(&format!(
3434 "[nex] config reloaded max_results={} mode={:?} show_files={} show_folders={} game_mode={} dsl={} clipboard={} uninstall_actions={} plugins_enabled={} plugins_actions={} index_caps_total={} index_caps_per_root={} index_seed_cap={}",
3435 runtime_config.max_results,
3436 runtime_config.search_mode_default,
3437 runtime_config.show_files,
3438 runtime_config.show_folders,
3439 runtime_config.game_mode_enabled,
3440 runtime_config.search_dsl_enabled,
3441 runtime_config.clipboard_enabled,
3442 runtime_config.uninstall_actions_enabled,
3443 runtime_config.plugins_enabled,
3444 plugin_registry.action_items.len(),
3445 runtime_config.index_max_items_total,
3446 runtime_config.index_max_items_per_root,
3447 runtime_config.index_max_items_per_query_seed,
3448 ));
3449
3450 if discovery_config_changed {
3451 if discovery_reindex_queued {
3452 overlay.set_status_text("Discovery settings queued; reindex starts after debounce");
3453 } else {
3454 overlay.set_status_text("Discovery settings updated; reindexing...");
3455 }
3456 } else if index_db_path_changed {
3457 overlay.set_status_text("Restart required to apply index path changes");
3458 } else if hotkey_changed {
3459 overlay.set_status_text("Restart required to apply hotkey changes");
3460 } else {
3461 overlay.set_status_text("Settings applied");
3462 }
3463 }
3464 Err(error) => {
3465 log_warn(&format!(
3466 "[nex] config reload skipped due to invalid config: {error}"
3467 ));
3468 }
3469 }
3470}
3471
3472#[cfg(target_os = "windows")]
3473fn should_suppress_failed_uninstall(error: &str) -> bool {
3474 let lower = error.to_ascii_lowercase();
3475 lower.contains("shell_code=2")
3476 || lower.contains(" code 2")
3477 || lower.contains("no longer available")
3478 || lower.contains("file not found")
3479}
3480
3481#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
3482fn uninstall_confirmation_results(
3483 uninstall_action: &crate::model::SearchItem,
3484) -> Vec<crate::model::SearchItem> {
3485 let target = uninstall_target_title_from_action_title(uninstall_action.title.as_str())
3486 .unwrap_or_else(|| uninstall_action.title.trim().to_string());
3487 let confirm_title = if target.is_empty() {
3488 "Confirm uninstall".to_string()
3489 } else {
3490 format!("Confirm uninstall {}", target.trim())
3491 };
3492
3493 vec![
3494 crate::model::SearchItem::new(
3495 ACTION_UNINSTALL_CONFIRM_ID,
3496 "action",
3497 confirm_title.as_str(),
3498 "Open app uninstaller",
3499 ),
3500 crate::model::SearchItem::new(
3501 ACTION_UNINSTALL_CANCEL_ID,
3502 "action",
3503 "Cancel",
3504 "Return to previous results",
3505 ),
3506 ]
3507}
3508
3509fn candidate_limit_for_query(
3510 result_limit: usize,
3511 filter: &SearchFilter,
3512 normalized_query: &str,
3513 command_mode: bool,
3514) -> usize {
3515 if result_limit == 0 {
3516 return 0;
3517 }
3518
3519 let base = result_limit.saturating_mul(6).max(60);
3520 if command_mode || filter.mode == crate::config::SearchMode::Actions {
3521 return result_limit
3522 .saturating_mul(4)
3523 .max(48)
3524 .min(160)
3525 .max(result_limit);
3526 }
3527
3528 match normalized_query.len() {
3529 0 => result_limit
3530 .saturating_mul(2)
3531 .max(24)
3532 .min(64)
3533 .max(result_limit),
3534 1 => match filter.mode {
3535 crate::config::SearchMode::All => result_limit
3536 .saturating_mul(3)
3537 .max(45)
3538 .min(96)
3539 .max(result_limit),
3540 crate::config::SearchMode::Files => result_limit
3541 .saturating_mul(5)
3542 .max(70)
3543 .min(200)
3544 .max(result_limit),
3545 _ => result_limit
3546 .saturating_mul(4)
3547 .max(56)
3548 .min(180)
3549 .max(result_limit),
3550 },
3551 2 => match filter.mode {
3552 crate::config::SearchMode::All => result_limit
3553 .saturating_mul(4)
3554 .max(56)
3555 .min(140)
3556 .max(result_limit),
3557 crate::config::SearchMode::Files => result_limit
3558 .saturating_mul(5)
3559 .max(70)
3560 .min(200)
3561 .max(result_limit),
3562 _ => result_limit
3563 .saturating_mul(4)
3564 .max(56)
3565 .min(180)
3566 .max(result_limit),
3567 },
3568 _ => base.min(280).max(result_limit),
3569 }
3570}
3571
3572fn indexed_seed_limit(candidate_limit: usize, normalized_query_len: usize) -> usize {
3573 let multiplier = match normalized_query_len {
3574 0 | 1 => 4,
3575 2 => 2,
3576 _ => 2,
3577 };
3578 candidate_limit.saturating_mul(multiplier).clamp(
3579 INDEXED_PREFIX_CACHE_MIN_SEED_LIMIT,
3580 INDEXED_PREFIX_CACHE_MAX_SEED_LIMIT,
3581 )
3582}
3583
3584fn adaptive_indexed_seed_limit(
3585 session: &OverlaySearchSession,
3586 candidate_limit: usize,
3587 normalized_query_len: usize,
3588 base_seed_limit: usize,
3589) -> usize {
3590 let mut samples: Vec<u128> = session.indexed_latency_ms.iter().copied().collect();
3591 if samples.len() < 6 {
3592 return base_seed_limit;
3593 }
3594
3595 let p95 = percentile_u128(&mut samples, 0.95);
3596 let scaled = if p95 >= 160 {
3597 (base_seed_limit.saturating_mul(60)) / 100
3598 } else if p95 >= 120 {
3599 (base_seed_limit.saturating_mul(72)) / 100
3600 } else if p95 >= 95 {
3601 (base_seed_limit.saturating_mul(84)) / 100
3602 } else if p95 <= 50 && normalized_query_len >= 3 {
3603 (base_seed_limit.saturating_mul(108)) / 100
3604 } else {
3605 base_seed_limit
3606 };
3607
3608 let minimum = candidate_limit.max(INDEXED_PREFIX_CACHE_MIN_SEED_LIMIT / 2);
3609 scaled.clamp(minimum, INDEXED_PREFIX_CACHE_MAX_SEED_LIMIT)
3610}
3611
3612fn record_indexed_latency_sample(session: &mut OverlaySearchSession, indexed_ms: u128) {
3613 session.indexed_latency_ms.push_back(indexed_ms);
3614 while session.indexed_latency_ms.len() > ADAPTIVE_INDEXED_LATENCY_WINDOW {
3615 session.indexed_latency_ms.pop_front();
3616 }
3617}
3618
3619fn final_query_cache_key(
3620 parsed_query: &ParsedQuery,
3621 filter: &SearchFilter,
3622 normalized_query: &str,
3623 result_limit: usize,
3624) -> String {
3625 format!(
3626 "q={};mode={:?};kind={};ext={};include={};exclude={};modified={:?};created={:?};cmd={};limit={}",
3627 normalized_query,
3628 filter.mode,
3629 filter.kind_filter.as_deref().unwrap_or("-"),
3630 filter.extension_filter.as_deref().unwrap_or("-"),
3631 encode_term_groups(&filter.include_groups),
3632 filter.exclude_terms.join(","),
3633 filter.modified_within,
3634 filter.created_within,
3635 parsed_query.command_mode,
3636 result_limit
3637 )
3638}
3639
3640fn encode_term_groups(groups: &[Vec<String>]) -> String {
3641 if groups.is_empty() {
3642 return "-".to_string();
3643 }
3644
3645 groups
3646 .iter()
3647 .map(|group| group.join("+"))
3648 .collect::<Vec<String>>()
3649 .join("|")
3650}
3651
3652fn cached_final_query_results(
3653 session: &mut OverlaySearchSession,
3654 key: &str,
3655) -> Option<Vec<crate::model::SearchItem>> {
3656 let cached = session.final_query_cache.get(key).cloned()?;
3657 if let Some(position) = session
3658 .final_query_cache_lru
3659 .iter()
3660 .position(|entry| entry == key)
3661 {
3662 session.final_query_cache_lru.remove(position);
3663 }
3664 session.final_query_cache_lru.push_back(key.to_string());
3665 Some(cached)
3666}
3667
3668fn store_final_query_results(
3669 session: &mut OverlaySearchSession,
3670 key: String,
3671 results: &[crate::model::SearchItem],
3672) {
3673 if results.is_empty() {
3674 return;
3675 }
3676
3677 session
3678 .final_query_cache
3679 .insert(key.clone(), results.to_vec());
3680 if let Some(position) = session
3681 .final_query_cache_lru
3682 .iter()
3683 .position(|entry| entry == &key)
3684 {
3685 session.final_query_cache_lru.remove(position);
3686 }
3687 session.final_query_cache_lru.push_back(key);
3688
3689 while session.final_query_cache.len() > FINAL_QUERY_CACHE_MAX_ENTRIES {
3690 let Some(oldest) = session.final_query_cache_lru.pop_front() else {
3691 break;
3692 };
3693 session.final_query_cache.remove(&oldest);
3694 }
3695}
3696
3697fn can_use_indexed_prefix_cache(
3698 cache: &IndexedPrefixCache,
3699 prefix_cache_eligible: bool,
3700 normalized_query: &str,
3701 indexed_filter: &SearchFilter,
3702) -> bool {
3703 if !prefix_cache_eligible {
3704 return false;
3705 }
3706 if cache.seed_items.is_empty() || cache.normalized_query.is_empty() {
3707 return false;
3708 }
3709 if !indexed_filter_matches_for_prefix_cache(&cache.indexed_filter, indexed_filter) {
3710 return false;
3711 }
3712 normalized_query.len() > cache.normalized_query.len()
3713 && normalized_query.starts_with(&cache.normalized_query)
3714}
3715
3716fn indexed_filter_matches_for_prefix_cache(a: &SearchFilter, b: &SearchFilter) -> bool {
3717 a.mode == b.mode
3718 && a.kind_filter == b.kind_filter
3719 && a.extension_filter == b.extension_filter
3720 && a.modified_within == b.modified_within
3721 && a.created_within == b.created_within
3722}
3723
3724fn is_prefix_cache_eligible_query(parsed_query: &ParsedQuery, short_query_app_bias: bool) -> bool {
3725 if short_query_app_bias || parsed_query.command_mode {
3726 return false;
3727 }
3728 if parsed_query.mode_override.is_some()
3729 || parsed_query.kind_filter.is_some()
3730 || parsed_query.extension_filter.is_some()
3731 || !parsed_query.exclude_terms.is_empty()
3732 || parsed_query.modified_within.is_some()
3733 || parsed_query.created_within.is_some()
3734 {
3735 return false;
3736 }
3737 if parsed_query.free_text.trim().is_empty() {
3738 return false;
3739 }
3740 parsed_query.raw.trim() == parsed_query.free_text.trim()
3741}
3742
3743fn sanitize_query_for_profile_log(query: &str) -> String {
3744 const MAX_QUERY_LOG_CHARS: usize = 48;
3745 let trimmed = query.trim();
3746 if trimmed.is_empty() {
3747 return "-".to_string();
3748 }
3749 let mut cleaned = String::new();
3750 for ch in trimmed.chars().take(MAX_QUERY_LOG_CHARS) {
3751 if ch.is_control() {
3752 cleaned.push(' ');
3753 } else {
3754 cleaned.push(ch);
3755 }
3756 }
3757 cleaned.trim().to_string()
3758}
3759
3760#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
3761fn launch_overlay_selection(
3762 service: &CoreService,
3763 cfg: &Config,
3764 plugins: &PluginRegistry,
3765 results: &[crate::model::SearchItem],
3766 selected_index: usize,
3767 query_text: &str,
3768) -> Result<(), String> {
3769 if results.is_empty() {
3770 return Err("no result selected".to_string());
3771 }
3772
3773 if selected_index >= results.len() {
3774 return Err(format!(
3775 "selected index out of range: {selected_index} (len={})",
3776 results.len()
3777 ));
3778 }
3779
3780 let selected = &results[selected_index];
3781 if selected.kind.eq_ignore_ascii_case("action") {
3782 return execute_action_selection(service, cfg, plugins, selected);
3783 }
3784 if selected.kind.eq_ignore_ascii_case("clipboard") {
3785 return clipboard_history::copy_result_to_clipboard(cfg, &selected.id);
3786 }
3787
3788 let parsed_query = ParsedQuery::parse(query_text.trim(), cfg.search_dsl_enabled);
3789 let mode = resolved_mode_for_query(cfg, &parsed_query);
3790 service
3791 .launch_with_query_context(LaunchTarget::Id(&selected.id), Some(query_text), Some(mode))
3792 .map_err(|error| format!("launch failed: {error}"))
3793}
3794
3795#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
3796fn execute_action_selection(
3797 service: &CoreService,
3798 cfg: &Config,
3799 plugins: &PluginRegistry,
3800 selected: &crate::model::SearchItem,
3801) -> Result<(), String> {
3802 if selected
3803 .id
3804 .starts_with(crate::uninstall_registry::ACTION_UNINSTALL_PREFIX)
3805 {
3806 return crate::uninstall_registry::execute_uninstall_action(&selected.id)
3807 .map_err(|error| format!("uninstall launch failed: {error}"));
3808 }
3809
3810 if selected.id.starts_with(ACTION_WEB_SEARCH_PREFIX) {
3811 return crate::action_executor::launch_open_target(selected.path.trim())
3812 .map_err(|error| format!("web search launch failed: {error}"));
3813 }
3814
3815 match selected.id.as_str() {
3816 ACTION_OPEN_LOGS_ID => crate::logging::open_logs_folder()
3817 .map_err(|error| format!("open logs folder failed: {error}")),
3818 ACTION_REBUILD_INDEX_ID => {
3819 let report = service
3820 .rebuild_index_with_report()
3821 .map_err(|error| format!("rebuild index failed: {error}"))?;
3822 log_info(&format!(
3823 "[nex] action_rebuild_index indexed={} discovered={} upserted={} removed={}",
3824 report.indexed_total, report.discovered_total, report.upserted_total, report.removed_total
3825 ));
3826 Ok(())
3827 }
3828 ACTION_CLEAR_CLIPBOARD_ID => clipboard_history::clear_history(cfg),
3829 ACTION_OPEN_CONFIG_ID => {
3830 crate::action_executor::launch_path(cfg.config_path.to_string_lossy().as_ref())
3831 .map_err(|error| format!("open config failed: {error}"))
3832 }
3833 ACTION_DIAGNOSTICS_BUNDLE_ID => {
3834 let output_dir = write_diagnostics_bundle(cfg)
3835 .map_err(|error| format!("diagnostics bundle failed: {error}"))?;
3836 log_info(&format!(
3837 "[nex] diagnostics bundle written to {}",
3838 output_dir.display()
3839 ));
3840 Ok(())
3841 }
3842 ACTION_CHECK_UPDATES_ID => launch_stable_updater()
3843 .map(|_| ())
3844 .map_err(|error| format!("check for updates failed: {error}")),
3845 ACTION_TRIM_MEMORY_ID => {
3846 log_info("[nex] trim memory action invoked");
3847 Ok(())
3848 }
3849 _ => execute_plugin_action(cfg, plugins, &selected.id),
3850 }
3851}
3852
3853fn execute_plugin_action(
3854 cfg: &Config,
3855 plugins: &PluginRegistry,
3856 result_id: &str,
3857) -> Result<(), String> {
3858 let action = plugins
3859 .actions_by_result_id
3860 .get(result_id)
3861 .ok_or_else(|| "unknown action".to_string())?;
3862
3863 match &action.kind {
3864 PluginActionKind::OpenPath { path } => crate::action_executor::launch_path(path)
3865 .map_err(|error| format!("plugin open path failed: {error}")),
3866 PluginActionKind::Command { command, args } => {
3867 if cfg.plugins_safe_mode {
3868 return Err(
3869 "plugin command execution blocked: plugins_safe_mode is enabled in config"
3870 .to_string(),
3871 );
3872 }
3873 if command.trim().is_empty() {
3874 return Err("plugin command action missing command".to_string());
3875 }
3876 std::process::Command::new(command)
3877 .args(args)
3878 .spawn()
3879 .map_err(|e| format!("plugin command spawn failed: {e}"))?;
3880 Ok(())
3881 }
3882 }
3883}
3884
3885fn configure_stdio_logging(options: RuntimeOptions) {
3886 let suppress_from_env = env_var_with_legacy("NEX_SUPPRESS_STDIO", "SWIFTFIND_SUPPRESS_STDIO")
3887 .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
3888 .unwrap_or(false);
3889 let suppress_for_background = options.command == RuntimeCommand::Run && options.background;
3890 STDIO_LOGGING_ENABLED.store(
3891 !(suppress_from_env || suppress_for_background),
3892 Ordering::Relaxed,
3893 );
3894}
3895
3896fn should_log_to_stdio() -> bool {
3897 STDIO_LOGGING_ENABLED.load(Ordering::Relaxed)
3898}
3899
3900fn log_info(message: &str) {
3901 if should_log_to_stdio() {
3902 println!("{message}");
3903 }
3904 crate::logging::info(message);
3905}
3906
3907fn log_warn(message: &str) {
3908 if should_log_to_stdio() {
3909 eprintln!("{message}");
3910 }
3911 crate::logging::warn(message);
3912}
3913
3914#[cfg(test)]
3915mod tests {
3916 use super::{
3917 adaptive_indexed_seed_limit, build_status_diagnostics_json,
3918 can_use_indexed_prefix_cache, candidate_limit_for_query, dedupe_overlay_results,
3919 filter_suppressed_uninstall_results, launch_overlay_selection,
3920 hotkey_registration_recovery_message, hotkey_registration_status_text,
3921 maybe_expand_uninstall_quick_shortcut, next_selection_index, parse_cli_args,
3922 parse_status_diagnostics_snapshot, parse_tasklist_pid_lines, result_limit_for_query,
3923 queued_discovery_reindex_is_due,
3924 search_overlay_results, search_overlay_results_with_session,
3925 should_block_hotkey_for_foreground_window, should_hide_known_start_menu_doc_sample_entry,
3926 should_skip_non_searchable_query, summarize_query_profiles,
3927 track_uninstall_title_suppression,
3928 uninstall_confirmation_results, uninstall_target_title_from_action_title,
3929 ForegroundWindowSnapshot, IndexedPrefixCache, OverlaySearchSession, RuntimeCommand,
3930 RuntimeOptions, ACTION_UNINSTALL_CANCEL_ID, ACTION_UNINSTALL_CONFIRM_ID,
3931 INDEXED_PREFIX_CACHE_MAX_SEED_LIMIT, INDEXED_PREFIX_CACHE_MIN_SEED_LIMIT,
3932 UNINSTALL_QUERY_RESULT_LIMIT,
3933 };
3934 use crate::action_registry::{ACTION_DIAGNOSTICS_BUNDLE_ID, ACTION_WEB_SEARCH_PREFIX};
3935 use crate::config::{Config, SearchMode};
3936 use crate::core_service::CoreService;
3937 use crate::index_store::open_memory;
3938 use crate::model::SearchItem;
3939 use crate::plugin_sdk::PluginRegistry;
3940 use crate::query_dsl::ParsedQuery;
3941 use crate::search::SearchFilter;
3942 use std::time::{Instant, SystemTime, UNIX_EPOCH};
3943
3944 #[test]
3945 fn overlay_search_returns_ranked_results() {
3946 let unique = SystemTime::now()
3947 .duration_since(UNIX_EPOCH)
3948 .expect("clock should be valid")
3949 .as_nanos();
3950 let path = std::env::temp_dir().join(format!("nex-overlay-search-{unique}.tmp"));
3951 std::fs::write(&path, b"ok").expect("temp file should be created");
3952
3953 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
3954 .expect("service should initialize");
3955 service
3956 .upsert_item(&SearchItem::new(
3957 "item-1",
3958 "app",
3959 "Visual Studio Code",
3960 path.to_string_lossy().as_ref(),
3961 ))
3962 .expect("item should upsert");
3963
3964 let parsed = ParsedQuery::parse("code", true);
3965 let cfg = Config::default();
3966 let plugins = PluginRegistry::default();
3967 let results = search_overlay_results(&service, &cfg, &plugins, &parsed, 20)
3968 .expect("search should succeed");
3969
3970 assert_eq!(results.len(), 1);
3971 assert_eq!(results[0].id, "item-1");
3972
3973 std::fs::remove_file(path).expect("temp file should be removed");
3974 }
3975
3976 #[test]
3977 fn overlay_launch_selection_launches_selected_item() {
3978 let unique = SystemTime::now()
3979 .duration_since(UNIX_EPOCH)
3980 .expect("clock should be valid")
3981 .as_nanos();
3982 let launch_path = std::env::temp_dir().join(format!("nex-launch-flow-{unique}.tmp"));
3983 std::fs::write(&launch_path, b"ok").expect("temp launch file should be created");
3984
3985 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
3986 .expect("service should initialize");
3987 service
3988 .upsert_item(&SearchItem::new(
3989 "item-1",
3990 "app",
3991 "Code Launcher",
3992 launch_path.to_string_lossy().as_ref(),
3993 ))
3994 .expect("item should upsert");
3995
3996 let parsed = ParsedQuery::parse("code", true);
3997 let cfg = Config::default();
3998 let plugins = PluginRegistry::default();
3999 let results = search_overlay_results(&service, &cfg, &plugins, &parsed, 20)
4000 .expect("search should succeed");
4001 launch_overlay_selection(&service, &cfg, &plugins, &results, 0, "launch target")
4002 .expect("launch should succeed");
4003
4004 std::fs::remove_file(&launch_path).expect("temp launch file should be removed");
4005 }
4006
4007 #[test]
4008 fn overlay_launch_selection_reports_error_for_missing_path() {
4009 let missing_path = std::env::temp_dir().join("nex-does-not-exist-launch-flow.exe");
4010 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4011 .expect("service should initialize");
4012 let item = SearchItem::new(
4013 "missing",
4014 "file",
4015 "Missing Item",
4016 missing_path.to_string_lossy().as_ref(),
4017 );
4018 service
4019 .upsert_item(&SearchItem::new(
4020 "missing",
4021 "file",
4022 "Missing Item",
4023 missing_path.to_string_lossy().as_ref(),
4024 ))
4025 .expect("item should upsert");
4026
4027 let results = vec![item];
4028 let cfg = Config::default();
4029 let plugins = PluginRegistry::default();
4030 let error = launch_overlay_selection(&service, &cfg, &plugins, &results, 0, "missing")
4031 .expect_err("launch should fail");
4032
4033 assert!(error.contains("launch failed:"));
4034 }
4035
4036 #[test]
4037 fn overlay_launch_selection_rejects_out_of_range_index() {
4038 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4039 .expect("service should initialize");
4040 let results = vec![SearchItem::new("item-1", "app", "One", "C:\\One.exe")];
4041
4042 let cfg = Config::default();
4043 let plugins = PluginRegistry::default();
4044 let error = launch_overlay_selection(&service, &cfg, &plugins, &results, 1, "out")
4045 .expect_err("selection should fail");
4046
4047 assert!(error.contains("selected index out of range"));
4048 }
4049
4050 #[test]
4051 fn overlay_launch_selection_rejects_empty_results() {
4052 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4053 .expect("service should initialize");
4054
4055 let cfg = Config::default();
4056 let plugins = PluginRegistry::default();
4057 let error = launch_overlay_selection(&service, &cfg, &plugins, &[], 0, "")
4058 .expect_err("empty selection should fail");
4059
4060 assert_eq!(error, "no result selected");
4061 }
4062
4063 #[test]
4064 fn selection_index_bounds_are_stable() {
4065 assert_eq!(next_selection_index(0, 0, 1), 0);
4066 assert_eq!(next_selection_index(0, 3, -1), 0);
4067 assert_eq!(next_selection_index(1, 3, -1), 0);
4068 assert_eq!(next_selection_index(1, 3, 1), 2);
4069 assert_eq!(next_selection_index(2, 3, 1), 2);
4070 assert_eq!(next_selection_index(1, 3, 0), 1);
4071 assert_eq!(next_selection_index(5, 3, 0), 2);
4072 }
4073
4074 #[test]
4075 fn candidate_limit_adapts_to_query_shape() {
4076 let all = SearchFilter::default();
4077 let empty_all = candidate_limit_for_query(20, &all, "", false);
4078 let short_all = candidate_limit_for_query(20, &all, "v", false);
4079 let medium_all = candidate_limit_for_query(20, &all, "vi", false);
4080 let long_all = candidate_limit_for_query(20, &all, "vivaldi", false);
4081 assert!(empty_all <= short_all);
4082 assert!(short_all < medium_all);
4083 assert!(medium_all <= long_all);
4084
4085 let actions = SearchFilter {
4086 mode: SearchMode::Actions,
4087 ..SearchFilter::default()
4088 };
4089 let short_actions = candidate_limit_for_query(20, &actions, "v", true);
4090 assert!(short_actions < long_all);
4091 }
4092
4093 #[test]
4094 fn uninstall_queries_use_expanded_result_limit() {
4095 let parsed = ParsedQuery::parse(">uninstall", true);
4096 let limit = result_limit_for_query(20, &parsed);
4097 assert_eq!(limit, UNINSTALL_QUERY_RESULT_LIMIT);
4098
4099 let non_uninstall = ParsedQuery::parse(">web rust", true);
4100 let non_limit = result_limit_for_query(20, &non_uninstall);
4101 assert_eq!(non_limit, 20);
4102 }
4103
4104 #[test]
4105 fn quick_uninstall_shortcut_expands_only_on_initial_u() {
4106 assert_eq!(
4107 maybe_expand_uninstall_quick_shortcut(">u", ">"),
4108 Some(">u ".to_string())
4109 );
4110 assert_eq!(maybe_expand_uninstall_quick_shortcut(">u", ">u"), None);
4111 assert_eq!(
4112 maybe_expand_uninstall_quick_shortcut(">u", ">u something"),
4113 None
4114 );
4115 }
4116
4117 #[test]
4118 fn uninstall_action_title_extracts_target_name() {
4119 assert_eq!(
4120 uninstall_target_title_from_action_title("Uninstall Discord"),
4121 Some("Discord".to_string())
4122 );
4123 assert_eq!(
4124 uninstall_target_title_from_action_title("uninstall Visual Studio Code "),
4125 Some("Visual Studio Code".to_string())
4126 );
4127 assert_eq!(
4128 uninstall_target_title_from_action_title("Open Discord"),
4129 None
4130 );
4131 }
4132
4133 #[test]
4134 fn uninstall_title_suppression_tracks_uniques() {
4135 let mut suppressed = Vec::new();
4136 track_uninstall_title_suppression(&mut suppressed, "Uninstall Discord");
4137 track_uninstall_title_suppression(&mut suppressed, "uninstall discord");
4138 track_uninstall_title_suppression(&mut suppressed, "Open Discord");
4139 assert_eq!(suppressed, vec!["Discord".to_string()]);
4140 }
4141
4142 #[test]
4143 fn uninstall_confirmation_results_are_confirm_then_cancel() {
4144 let uninstall_action = SearchItem::new(
4145 "action:uninstall:discord",
4146 "action",
4147 "Uninstall Discord",
4148 "shell:AppsFolder\\Discord",
4149 );
4150 let results = uninstall_confirmation_results(&uninstall_action);
4151 assert_eq!(results.len(), 2);
4152 assert_eq!(results[0].id, ACTION_UNINSTALL_CONFIRM_ID);
4153 assert_eq!(results[1].id, ACTION_UNINSTALL_CANCEL_ID);
4154 assert!(results[0].title.contains("Discord"));
4155 assert_eq!(results[1].title, "Cancel");
4156 }
4157
4158 #[test]
4159 fn suppressed_uninstall_results_are_filtered_from_results() {
4160 let mut results = vec![
4161 SearchItem::new("app-discord", "app", "Discord", "C:\\Discord\\Discord.exe"),
4162 SearchItem::new(
4163 "__nex_action_uninstall__:discord",
4164 "action",
4165 "Uninstall Discord",
4166 "Vendor application",
4167 ),
4168 SearchItem::new(
4169 "app-vscode",
4170 "app",
4171 "Visual Studio Code",
4172 "C:\\Code\\Code.exe",
4173 ),
4174 SearchItem::new("file-readme", "file", "readme.md", "C:\\repo\\readme.md"),
4175 ];
4176 let suppressed = vec!["Discord".to_string()];
4177 filter_suppressed_uninstall_results(&mut results, &suppressed);
4178
4179 assert_eq!(results.len(), 2);
4180 assert!(results.iter().all(|item| item.id != "app-discord"));
4181 assert!(results
4182 .iter()
4183 .all(|item| item.id != "__nex_action_uninstall__:discord"));
4184 assert!(results.iter().any(|item| item.id == "app-vscode"));
4185 assert!(results.iter().any(|item| item.id == "file-readme"));
4186 }
4187
4188 #[test]
4189 fn hides_known_start_menu_doc_and_sample_entries() {
4190 let docs = SearchItem::new(
4191 "app-docs",
4192 "app",
4193 "Documentation Desktop Apps",
4194 "shell:AppsFolder\\Contoso.DocumentationDesktopApps",
4195 );
4196 let sample = SearchItem::new(
4197 "app-sample",
4198 "app",
4199 "Sample UWP Apps",
4200 "shell:AppsFolder\\Contoso.SampleUwpApps",
4201 );
4202 let normal = SearchItem::new(
4203 "app-normal",
4204 "app",
4205 "Discord",
4206 "shell:AppsFolder\\Discord.Discord",
4207 );
4208 let non_shell = SearchItem::new(
4209 "app-nonshell",
4210 "app",
4211 "Sample Tool",
4212 "C:\\Tools\\SampleTool.exe",
4213 );
4214 let manual_lnk = SearchItem::new(
4215 "app-manual",
4216 "app",
4217 "User Manual",
4218 "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Tool\\User Manual.lnk",
4219 );
4220 let faq_pdf = SearchItem::new(
4221 "app-faq",
4222 "app",
4223 "Tool FAQ",
4224 "shell:AppsFolder\\Vendor.ToolFAQ.pdf",
4225 );
4226 let normal_lnk = SearchItem::new(
4227 "app-normal-lnk",
4228 "app",
4229 "Discord",
4230 "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Discord\\Discord.lnk",
4231 );
4232
4233 assert!(should_hide_known_start_menu_doc_sample_entry(&docs));
4234 assert!(should_hide_known_start_menu_doc_sample_entry(&sample));
4235 assert!(should_hide_known_start_menu_doc_sample_entry(&manual_lnk));
4236 assert!(should_hide_known_start_menu_doc_sample_entry(&faq_pdf));
4237 assert!(!should_hide_known_start_menu_doc_sample_entry(&normal));
4238 assert!(!should_hide_known_start_menu_doc_sample_entry(&non_shell));
4239 assert!(!should_hide_known_start_menu_doc_sample_entry(&normal_lnk));
4240 }
4241
4242 #[test]
4243 fn prefix_cache_predicate_requires_same_filter_and_extended_query() {
4244 let cache = IndexedPrefixCache {
4245 normalized_query: "vi".to_string(),
4246 indexed_filter: SearchFilter::default(),
4247 seed_items: vec![SearchItem::new(
4248 "app-1",
4249 "app",
4250 "Vivaldi",
4251 "C:\\Vivaldi.exe",
4252 )],
4253 };
4254
4255 assert!(can_use_indexed_prefix_cache(
4256 &cache,
4257 true,
4258 "viv",
4259 &SearchFilter::default()
4260 ));
4261 assert!(!can_use_indexed_prefix_cache(
4262 &cache,
4263 true,
4264 "vi",
4265 &SearchFilter::default()
4266 ));
4267 assert!(!can_use_indexed_prefix_cache(
4268 &cache,
4269 true,
4270 "xvi",
4271 &SearchFilter::default()
4272 ));
4273
4274 let different_mode = SearchFilter {
4275 mode: SearchMode::Apps,
4276 ..SearchFilter::default()
4277 };
4278 assert!(!can_use_indexed_prefix_cache(
4279 &cache,
4280 true,
4281 "viv",
4282 &different_mode
4283 ));
4284 assert!(!can_use_indexed_prefix_cache(
4285 &cache,
4286 false,
4287 "viv",
4288 &SearchFilter::default()
4289 ));
4290 }
4291
4292 #[test]
4293 fn game_mode_does_not_block_standard_maximized_apps() {
4294 let snapshot = ForegroundWindowSnapshot {
4295 class_name: "Chrome_WidgetWin_1".to_string(),
4296 process_name: "chrome.exe".to_string(),
4297 process_path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe".to_string(),
4298 covers_monitor: true,
4299 has_standard_frame: true,
4300 maximized: true,
4301 };
4302
4303 assert!(!should_block_hotkey_for_foreground_window(&snapshot));
4304 }
4305
4306 #[test]
4307 fn game_mode_blocks_known_game_like_borderless_windows() {
4308 let snapshot = ForegroundWindowSnapshot {
4309 class_name: "UnrealWindow".to_string(),
4310 process_name: "VALORANT-Win64-Shipping.exe".to_string(),
4311 process_path: "C:\\Riot Games\\VALORANT\\live\\ShooterGame\\Binaries\\Win64\\VALORANT-Win64-Shipping.exe".to_string(),
4312 covers_monitor: true,
4313 has_standard_frame: false,
4314 maximized: false,
4315 };
4316
4317 assert!(should_block_hotkey_for_foreground_window(&snapshot));
4318 }
4319
4320 #[test]
4321 fn repeated_overlay_query_uses_final_cache() {
4322 let unique = SystemTime::now()
4323 .duration_since(UNIX_EPOCH)
4324 .expect("clock should be valid")
4325 .as_nanos();
4326 let path = std::env::temp_dir().join(format!("nex-overlay-cache-{unique}.tmp"));
4327 std::fs::write(&path, b"ok").expect("temp file should be created");
4328
4329 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4330 .expect("service should initialize");
4331 service
4332 .upsert_item(&SearchItem::new(
4333 "item-1",
4334 "app",
4335 "Vivaldi",
4336 path.to_string_lossy().as_ref(),
4337 ))
4338 .expect("item should upsert");
4339
4340 let cfg = Config::default();
4341 let plugins = PluginRegistry::default();
4342 let parsed = ParsedQuery::parse("vi", true);
4343 let mut session = OverlaySearchSession::default();
4344
4345 let first = search_overlay_results_with_session(
4346 &service,
4347 &cfg,
4348 &plugins,
4349 &parsed,
4350 20,
4351 &mut session,
4352 )
4353 .expect("first query should succeed");
4354 let sample_count_after_first = session.indexed_latency_ms.len();
4355
4356 let second = search_overlay_results_with_session(
4357 &service,
4358 &cfg,
4359 &plugins,
4360 &parsed,
4361 20,
4362 &mut session,
4363 )
4364 .expect("second query should succeed");
4365
4366 assert_eq!(first, second);
4367 assert_eq!(session.indexed_latency_ms.len(), sample_count_after_first);
4368 assert!(!session.final_query_cache.is_empty());
4369
4370 std::fs::remove_file(path).expect("temp file should be removed");
4371 }
4372
4373 #[test]
4374 fn adaptive_seed_limit_reduces_on_high_latency_window() {
4375 let mut session = OverlaySearchSession::default();
4376 session
4377 .indexed_latency_ms
4378 .extend(std::iter::repeat(170_u128).take(12));
4379
4380 let base = 320;
4381 let tuned = adaptive_indexed_seed_limit(&session, 120, 1, base);
4382 assert!(tuned < base);
4383 assert!(tuned >= INDEXED_PREFIX_CACHE_MIN_SEED_LIMIT / 2);
4384 assert!(tuned <= INDEXED_PREFIX_CACHE_MAX_SEED_LIMIT);
4385 }
4386
4387 #[test]
4388 fn parses_background_run_args() {
4389 let args = vec!["--background".to_string()];
4390 let options = parse_cli_args(&args).expect("args should parse");
4391 assert_eq!(
4392 options,
4393 RuntimeOptions {
4394 command: RuntimeCommand::Run,
4395 background: true,
4396 }
4397 );
4398 }
4399
4400 #[test]
4401 fn parses_lifecycle_commands() {
4402 let args = vec!["--status".to_string()];
4403 let options = parse_cli_args(&args).expect("status should parse");
4404 assert_eq!(options.command, RuntimeCommand::Status);
4405 assert!(!options.background);
4406
4407 let args = vec!["--status-json".to_string()];
4408 let options = parse_cli_args(&args).expect("status-json should parse");
4409 assert_eq!(options.command, RuntimeCommand::StatusJson);
4410 assert!(!options.background);
4411 }
4412
4413 #[test]
4414 fn parses_diagnostics_bundle_command() {
4415 let args = vec!["--diagnostics-bundle".to_string()];
4416 let options = parse_cli_args(&args).expect("diagnostics command should parse");
4417 assert_eq!(options.command, RuntimeCommand::DiagnosticsBundle);
4418 assert!(!options.background);
4419 }
4420
4421 #[test]
4422 fn parses_set_launch_at_startup_command() {
4423 let args = vec!["--set-launch-at-startup=true".to_string()];
4424 let options = parse_cli_args(&args).expect("startup command should parse");
4425 assert_eq!(options.command, RuntimeCommand::SetLaunchAtStartup(true));
4426 assert!(!options.background);
4427
4428 let args = vec!["--set-launch-at-startup=false".to_string()];
4429 let options = parse_cli_args(&args).expect("startup command should parse");
4430 assert_eq!(options.command, RuntimeCommand::SetLaunchAtStartup(false));
4431 assert!(!options.background);
4432 }
4433
4434 #[test]
4435 fn rejects_invalid_set_launch_at_startup_value() {
4436 let args = vec!["--set-launch-at-startup=maybe".to_string()];
4437 let error = parse_cli_args(&args).expect_err("invalid value should fail");
4438 assert!(error.contains("invalid value for --set-launch-at-startup"));
4439 }
4440
4441 #[test]
4442 fn rejects_background_with_non_run_commands() {
4443 let args = vec!["--quit".to_string(), "--background".to_string()];
4444 let error = parse_cli_args(&args).expect_err("invalid combination should fail");
4445 assert!(error.contains("background mode"));
4446 }
4447
4448 #[test]
4449 fn command_mode_returns_action_results() {
4450 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4451 .expect("service should initialize");
4452 let cfg = Config::default();
4453 let plugins = PluginRegistry::default();
4454 let parsed = ParsedQuery::parse(">diag", true);
4455 let results = search_overlay_results(&service, &cfg, &plugins, &parsed, 10)
4456 .expect("search should succeed");
4457 assert!(results
4458 .iter()
4459 .any(|item| item.id == ACTION_DIAGNOSTICS_BUNDLE_ID));
4460 }
4461
4462 #[test]
4463 fn command_mode_includes_web_search_action() {
4464 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4465 .expect("service should initialize");
4466 let cfg = Config::default();
4467 let plugins = PluginRegistry::default();
4468 let parsed = ParsedQuery::parse(">nex roadmap", true);
4469 let results = search_overlay_results(&service, &cfg, &plugins, &parsed, 10)
4470 .expect("search should succeed");
4471 assert!(results
4472 .iter()
4473 .any(|item| item.id.starts_with(ACTION_WEB_SEARCH_PREFIX)));
4474 }
4475
4476 #[test]
4477 fn short_single_letter_query_in_all_mode_biases_to_apps() {
4478 let unique = SystemTime::now()
4479 .duration_since(UNIX_EPOCH)
4480 .expect("clock should be valid")
4481 .as_nanos();
4482 let app_path = std::env::temp_dir().join(format!("nex-short-query-app-{unique}.tmp"));
4483 let file_path =
4484 std::env::temp_dir().join(format!("nex-short-query-file-{unique}.tmp"));
4485 std::fs::write(&app_path, b"ok").expect("app temp file should be created");
4486 std::fs::write(&file_path, b"ok").expect("file temp file should be created");
4487
4488 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4489 .expect("service should initialize");
4490 service
4491 .upsert_item(&SearchItem::new(
4492 "app-1",
4493 "app",
4494 "Vivaldi Browser",
4495 app_path.to_string_lossy().as_ref(),
4496 ))
4497 .expect("app should upsert");
4498 service
4499 .upsert_item(&SearchItem::new(
4500 "file-1",
4501 "file",
4502 "Vacation Notes",
4503 file_path.to_string_lossy().as_ref(),
4504 ))
4505 .expect("file should upsert");
4506
4507 let parsed = ParsedQuery::parse("v", true);
4508 let cfg = Config::default();
4509 let plugins = PluginRegistry::default();
4510 let results = search_overlay_results(&service, &cfg, &plugins, &parsed, 20)
4511 .expect("search should succeed");
4512 assert!(results.iter().any(|item| item.id == "app-1"));
4513 assert!(!results.iter().any(|item| item.id == "file-1"));
4514
4515 std::fs::remove_file(app_path).expect("app temp file should be removed");
4516 std::fs::remove_file(file_path).expect("file temp file should be removed");
4517 }
4518
4519 #[test]
4520 fn short_two_letter_query_in_all_mode_biases_to_apps() {
4521 let unique = SystemTime::now()
4522 .duration_since(UNIX_EPOCH)
4523 .expect("clock should be valid")
4524 .as_nanos();
4525 let app_path = std::env::temp_dir().join(format!("nex-short-two-app-{unique}.tmp"));
4526 let file_path = std::env::temp_dir().join(format!("nex-short-two-file-{unique}.tmp"));
4527 std::fs::write(&app_path, b"ok").expect("app temp file should be created");
4528 std::fs::write(&file_path, b"ok").expect("file temp file should be created");
4529
4530 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
4531 .expect("service should initialize");
4532 service
4533 .upsert_item(&SearchItem::new(
4534 "app-1",
4535 "app",
4536 "Valorant",
4537 app_path.to_string_lossy().as_ref(),
4538 ))
4539 .expect("app should upsert");
4540 service
4541 .upsert_item(&SearchItem::new(
4542 "file-1",
4543 "file",
4544 "Valuation Notes",
4545 file_path.to_string_lossy().as_ref(),
4546 ))
4547 .expect("file should upsert");
4548
4549 let parsed = ParsedQuery::parse("va", true);
4550 let cfg = Config::default();
4551 let plugins = PluginRegistry::default();
4552 let results = search_overlay_results(&service, &cfg, &plugins, &parsed, 20)
4553 .expect("search should succeed");
4554 assert!(results.iter().any(|item| item.id == "app-1"));
4555 assert!(!results.iter().any(|item| item.id == "file-1"));
4556
4557 std::fs::remove_file(app_path).expect("app temp file should be removed");
4558 std::fs::remove_file(file_path).expect("file temp file should be removed");
4559 }
4560
4561 #[test]
4562 fn dedupes_duplicate_app_titles_for_overlay() {
4563 let mut results = vec![
4564 SearchItem::new("a1", "app", "Steam", "C:\\One\\Steam.lnk"),
4565 SearchItem::new("a2", "app", "Steam", "C:\\Two\\Steam.lnk"),
4566 SearchItem::new("a3", "app", "Calculator", "C:\\Calc.lnk"),
4567 ];
4568 dedupe_overlay_results(&mut results);
4569 assert_eq!(results.len(), 2);
4570 assert_eq!(results[0].title, "Steam");
4571 assert_eq!(results[1].title, "Calculator");
4572 }
4573
4574 #[test]
4575 fn dedupes_non_app_entries_by_normalized_path() {
4576 let mut results = vec![
4577 SearchItem::new("f1", "file", "Doc A", "C:/Users/Admin/Docs/test.txt"),
4578 SearchItem::new("f2", "file", "Doc B", "C:\\Users\\Admin\\Docs\\test.txt"),
4579 SearchItem::new("f3", "file", "Doc C", "C:\\Users\\Admin\\Docs\\other.txt"),
4580 ];
4581 dedupe_overlay_results(&mut results);
4582 assert_eq!(results.len(), 2);
4583 assert_eq!(results[0].id, "f1");
4584 assert_eq!(results[1].id, "f3");
4585 }
4586
4587 #[test]
4588 fn dedupes_lnk_file_when_matching_app_title_exists() {
4589 let mut results = vec![
4590 SearchItem::new("a1", "app", "Framer", "C:\\ProgramData\\Framer.lnk"),
4591 SearchItem::new(
4592 "f1",
4593 "file",
4594 "Framer.lnk",
4595 "C:\\Users\\Admin\\Desktop\\Framer.lnk",
4596 ),
4597 SearchItem::new(
4598 "f2",
4599 "file",
4600 "Framer Notes.lnk",
4601 "C:\\Users\\Admin\\Desktop\\Framer Notes.lnk",
4602 ),
4603 ];
4604
4605 dedupe_overlay_results(&mut results);
4606 let ids: Vec<&str> = results.iter().map(|item| item.id.as_str()).collect();
4607
4608 assert_eq!(ids, vec!["a1", "f2"]);
4609 }
4610
4611 #[test]
4612 fn parses_status_diagnostics_snapshot_from_log_content() {
4613 let content = "\
4614[0] [INFO] [nex] startup_phase phase=overlay_ready elapsed_ms=41
4615[0] [INFO] [nex] startup_phase phase=hotkey_ready elapsed_ms=56 hotkey=Ctrl+Space
4616[0] [WARN] [nex] hotkey_registration_issue hotkey=Ctrl+Space suggestions=Ctrl+Shift+Space|Alt+Space error=conflict
4617[0] [INFO] [nex] startup_phase phase=indexing_started elapsed_ms=7 initial_cache_empty=true cached_items=0
4618[0] [INFO] [nex] startup_phase phase=indexing_completed elapsed_ms=2815 worker_elapsed_ms=2809 indexed_items=310 discovered=320 upserted=16 removed=4
4619[0] [INFO] [nex] startup_phase phase=cache_applied elapsed_ms=2820 cached_items=310 initial_cache_empty=true
4620[1] [INFO] [nex] startup indexed_items=310 discovered=320 upserted=16 removed=4
4621[2] [INFO] [nex] index_provider name=start-menu-apps discovered=120 upserted=4 removed=1 elapsed_ms=42
4622[3] [INFO] [nex] provider_freshness name=filesystem skipped=false last_scan_age_secs=0 reconcile_interval_secs=1800 has_stamp=true
4623[4] [INFO] [nex] stale_prune scanned=512 removed=3 cached_items_remaining=738
4624[5] [INFO] [nex] cache_compaction input_total=812 retained=596 dropped=216 retained_apps=20 retained_file_folders=576 retained_other=0 effective_file_seed_cap=576 broad_root_mode=true active_memory_target_mb=72
4625[6] [INFO] [nex] overlay_icon_cache reason=cache_clear hits=12 misses=8 load_failures=1 evictions=0 cleared_entries=9 live_entries=0 max_entries=90
4626";
4627
4628 let snapshot = parse_status_diagnostics_snapshot(content).expect("snapshot should parse");
4629 assert!(snapshot
4630 .overlay_ready_line
4631 .as_deref()
4632 .unwrap_or_default()
4633 .contains("phase=overlay_ready"));
4634 assert!(snapshot
4635 .hotkey_ready_line
4636 .as_deref()
4637 .unwrap_or_default()
4638 .contains("phase=hotkey_ready"));
4639 assert!(snapshot
4640 .hotkey_registration_issue_line
4641 .as_deref()
4642 .unwrap_or_default()
4643 .contains("hotkey_registration_issue hotkey=Ctrl+Space"));
4644 assert!(snapshot
4645 .indexing_started_line
4646 .as_deref()
4647 .unwrap_or_default()
4648 .contains("phase=indexing_started"));
4649 assert!(snapshot
4650 .indexing_completed_line
4651 .as_deref()
4652 .unwrap_or_default()
4653 .contains("phase=indexing_completed"));
4654 assert!(snapshot
4655 .cache_applied_line
4656 .as_deref()
4657 .unwrap_or_default()
4658 .contains("phase=cache_applied"));
4659 assert!(snapshot
4660 .startup_index_line
4661 .as_deref()
4662 .unwrap_or_default()
4663 .contains("startup indexed_items=310"));
4664 assert!(snapshot
4665 .last_provider_line
4666 .as_deref()
4667 .unwrap_or_default()
4668 .contains("index_provider name=start-menu-apps"));
4669 assert!(snapshot
4670 .last_provider_freshness_line
4671 .as_deref()
4672 .unwrap_or_default()
4673 .contains("provider_freshness name=filesystem"));
4674 assert!(snapshot
4675 .last_stale_prune_line
4676 .as_deref()
4677 .unwrap_or_default()
4678 .contains("stale_prune scanned=512"));
4679 assert!(snapshot
4680 .last_cache_compaction_line
4681 .as_deref()
4682 .unwrap_or_default()
4683 .contains("cache_compaction input_total=812"));
4684 assert!(snapshot
4685 .last_icon_cache_line
4686 .as_deref()
4687 .unwrap_or_default()
4688 .contains("overlay_icon_cache reason=cache_clear"));
4689 }
4690
4691 #[test]
4692 fn status_diagnostics_json_includes_startup_lifecycle_tokens() {
4693 let content = "\
4694[1773000001] [INFO] [nex] startup_phase phase=overlay_ready elapsed_ms=33
4695[1773000002] [INFO] [nex] startup_phase phase=hotkey_ready elapsed_ms=48 hotkey=Ctrl+Space
4696[1773000002] [WARN] [nex] hotkey_registration_issue hotkey=Ctrl+Space suggestions=Ctrl+Shift+Space|Alt+Space error=conflict
4697[1773000003] [INFO] [nex] startup_phase phase=indexing_started elapsed_ms=6 initial_cache_empty=true cached_items=0
4698[1773000028] [INFO] [nex] startup_phase phase=indexing_completed elapsed_ms=2600 worker_elapsed_ms=2593 indexed_items=310 discovered=320 upserted=16 removed=4
4699[1773000029] [INFO] [nex] startup_phase phase=cache_applied elapsed_ms=2605 cached_items=310 initial_cache_empty=true
4700[1773000030] [INFO] [nex] provider_freshness name=filesystem skipped=false last_scan_age_secs=0 reconcile_interval_secs=1800 has_stamp=true
4701[1773000031] [INFO] [nex] stale_prune scanned=512 removed=3 cached_items_remaining=738
4702[1773000032] [INFO] [nex] cache_compaction input_total=812 retained=596 dropped=216 retained_apps=20 retained_file_folders=576 retained_other=0 effective_file_seed_cap=576 broad_root_mode=true active_memory_target_mb=72
4703[1773000033] [INFO] [nex] overlay_icon_cache reason=cache_clear hits=12 misses=8 load_failures=1 evictions=0 cleared_entries=9 live_entries=0 max_entries=90
4704";
4705 let snapshot = parse_status_diagnostics_snapshot(content).expect("snapshot should parse");
4706 let json = build_status_diagnostics_json(&snapshot);
4707
4708 assert_eq!(
4709 json["startup_lifecycle"]["overlay_ready"]["tokens"]["elapsed_ms"],
4710 serde_json::json!(33)
4711 );
4712 assert_eq!(
4713 json["startup_lifecycle"]["hotkey_ready"]["tokens"]["hotkey"],
4714 serde_json::json!("Ctrl+Space")
4715 );
4716 assert_eq!(
4717 json["hotkey_issue"]["tokens"]["suggestions"],
4718 serde_json::json!("Ctrl+Shift+Space|Alt+Space")
4719 );
4720 assert_eq!(
4721 json["hotkey_issue"]["epoch_secs"],
4722 serde_json::json!(1773000002_u64)
4723 );
4724 assert_eq!(
4725 json["startup_lifecycle"]["indexing_started"]["tokens"]["initial_cache_empty"],
4726 serde_json::json!(true)
4727 );
4728 assert_eq!(
4729 json["startup_lifecycle"]["indexing_completed"]["tokens"]["worker_elapsed_ms"],
4730 serde_json::json!(2593)
4731 );
4732 assert_eq!(
4733 json["startup_lifecycle"]["cache_applied"]["tokens"]["cached_items"],
4734 serde_json::json!(310)
4735 );
4736 assert_eq!(
4737 json["startup_lifecycle"]["cache_applied"]["epoch_secs"],
4738 serde_json::json!(1773000029_u64)
4739 );
4740 assert_eq!(
4741 json["provider_freshness"]["reconcile_interval_secs"],
4742 serde_json::json!(1800)
4743 );
4744 assert_eq!(
4745 json["stale_prune"]["removed"],
4746 serde_json::json!(3)
4747 );
4748 assert_eq!(
4749 json["cache_compaction"]["effective_file_seed_cap"],
4750 serde_json::json!(576)
4751 );
4752 assert_eq!(
4753 json["cache_compaction"]["broad_root_mode"],
4754 serde_json::json!(true)
4755 );
4756 assert_eq!(
4757 json["icon_cache"]["max_entries"],
4758 serde_json::json!(90)
4759 );
4760 }
4761
4762 #[test]
4763 fn queued_reindex_starts_only_after_due_time() {
4764 let now = Instant::now();
4765 assert!(!queued_discovery_reindex_is_due(
4766 true,
4767 true,
4768 Some(now + std::time::Duration::from_millis(5)),
4769 now
4770 ));
4771 assert!(queued_discovery_reindex_is_due(
4772 true,
4773 true,
4774 Some(now),
4775 now
4776 ));
4777 assert!(!queued_discovery_reindex_is_due(true, false, Some(now), now));
4778 assert!(!queued_discovery_reindex_is_due(false, true, Some(now), now));
4779 assert!(!queued_discovery_reindex_is_due(true, true, None, now));
4780 }
4781
4782 #[test]
4783 fn returns_none_for_status_snapshot_without_diagnostics_tokens() {
4784 let content = "[1] [INFO] [nex] status: running\n";
4785 assert!(parse_status_diagnostics_snapshot(content).is_none());
4786 }
4787
4788 #[test]
4789 fn hotkey_registration_messages_include_recovery_guidance() {
4790 let message = hotkey_registration_recovery_message(
4791 "Ctrl+Space",
4792 std::path::Path::new("C:\\Users\\Admin\\AppData\\Roaming\\Nex\\config.toml"),
4793 );
4794 assert!(message.contains("Hotkey 'Ctrl+Space' is unavailable."));
4795 assert!(message.contains("Ctrl+Shift+Space"));
4796 assert!(message.contains("config.toml"));
4797
4798 let status = hotkey_registration_status_text("Ctrl+Space");
4799 assert!(status.contains("Hotkey unavailable: Ctrl+Space."));
4800 assert!(status.contains("Ctrl+Shift+Space"));
4801 }
4802
4803 #[test]
4804 fn summarizes_query_profiles_from_log_content() {
4805 let content = "\
4806[1] [INFO] [nex] query_profile q=\"v\" mode=all candidate_limit=60 indexed_seed_limit=240 short_app_bias=true indexed_cache_hit=false indexed_count=20 indexed_ms=20 provider_count=0 provider_ms=0 action_count=0 action_ms=0 built_in_actions=0 plugin_actions=0 clipboard_count=0 clipboard_ms=0 rank_ms=0 total_ms=21
4807[2] [INFO] [nex] query_profile q=\"va\" mode=all candidate_limit=80 indexed_seed_limit=160 short_app_bias=true indexed_cache_hit=false indexed_count=20 indexed_ms=26 provider_count=0 provider_ms=0 action_count=0 action_ms=0 built_in_actions=0 plugin_actions=0 clipboard_count=0 clipboard_ms=0 rank_ms=0 total_ms=27
4808[3] [INFO] [nex] query_profile q=\"vala\" mode=all candidate_limit=120 indexed_seed_limit=240 short_app_bias=false indexed_cache_hit=false indexed_count=20 indexed_ms=54 provider_count=0 provider_ms=0 action_count=0 action_ms=0 built_in_actions=0 plugin_actions=0 clipboard_count=0 clipboard_ms=0 rank_ms=0 total_ms=55
4809";
4810 let summary = summarize_query_profiles(content).expect("summary should parse");
4811 assert_eq!(summary.samples, 3);
4812 assert_eq!(summary.p95_total_ms, 55);
4813 assert_eq!(summary.short_query_samples, 2);
4814 assert_eq!(summary.short_query_app_bias_rate_pct, 100);
4815 assert_eq!(summary.short_query_p95_total_ms, 27);
4816 }
4817
4818 #[test]
4819 fn skips_non_searchable_symbol_only_query() {
4820 let parsed = ParsedQuery::parse("-", true);
4821 let normalized = crate::model::normalize_for_search(parsed.free_text.trim());
4822 assert!(should_skip_non_searchable_query(&parsed, &normalized));
4823
4824 let parsed_command = ParsedQuery::parse(">-", true);
4825 let normalized_command =
4826 crate::model::normalize_for_search(parsed_command.free_text.trim());
4827 assert!(!should_skip_non_searchable_query(
4828 &parsed_command,
4829 &normalized_command
4830 ));
4831 }
4832
4833 #[test]
4834 fn parses_tasklist_pid_lines_from_list_output() {
4835 let content = "\
4836Image Name: nex.exe
4837PID: 1124
4838Session Name: Console
4839
4840Image Name: nex.exe
4841PID: 2208
4842Session Name: Console
4843";
4844 let pids = parse_tasklist_pid_lines(content);
4845 assert_eq!(pids, vec![1124, 2208]);
4846 }
4847}