Skip to main content

nex_core/
runtime.rs

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