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