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