Skip to main content

nex_core/
discovery.rs

1#[cfg(target_os = "windows")]
2use std::collections::HashMap;
3use std::collections::HashSet;
4use std::fmt::{Display, Formatter};
5use std::path::{Path, PathBuf};
6use std::time::UNIX_EPOCH;
7
8use crate::model::SearchItem;
9
10const DEFAULT_INDEX_MAX_ITEMS_TOTAL: usize = 120_000;
11const DEFAULT_INDEX_MAX_ITEMS_PER_ROOT: usize = 40_000;
12const FILESYSTEM_DISCOVERY_SCHEMA_VERSION: &str = "2";
13const TOP_LEVEL_EXCLUDED_DIR_NAMES: &[&str] = &[
14    "windows",
15    "program files",
16    "program files (x86)",
17    "$recycle.bin",
18    "system volume information",
19    "appdata",
20];
21const ANY_DEPTH_EXCLUDED_DIR_NAMES: &[&str] = &[
22    "node_modules",
23    ".git",
24    ".venv",
25    "venv",
26    "__pycache__",
27    "dist",
28    "build",
29    ".gradle",
30    ".m2",
31    ".dropbox.cache",
32    ".ssh",
33];
34const EXCLUDED_FILE_NAMES: &[&str] = &["pagefile.sys", "hiberfil.sys"];
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ProviderError {
38    message: String,
39}
40
41impl ProviderError {
42    pub fn new(message: impl Into<String>) -> Self {
43        Self {
44            message: message.into(),
45        }
46    }
47}
48
49impl Display for ProviderError {
50    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
51        write!(f, "{}", self.message)
52    }
53}
54
55impl std::error::Error for ProviderError {}
56
57pub trait DiscoveryProvider: Send + Sync {
58    fn provider_name(&self) -> &'static str;
59    fn discover(&self) -> Result<Vec<SearchItem>, ProviderError>;
60    fn change_stamp(&self) -> Option<String> {
61        None
62    }
63}
64
65pub struct AppProvider {
66    apps: Vec<SearchItem>,
67}
68
69impl AppProvider {
70    pub fn from_apps(apps: Vec<SearchItem>) -> Self {
71        Self { apps }
72    }
73
74    pub fn deterministic_fixture() -> Self {
75        Self {
76            apps: vec![
77                SearchItem::new(
78                    "app-code",
79                    "app",
80                    "Visual Studio Code",
81                    "C:\\Program Files\\Microsoft VS Code\\Code.exe",
82                ),
83                SearchItem::new(
84                    "app-term",
85                    "app",
86                    "Windows Terminal",
87                    "C:\\Program Files\\WindowsApps\\Terminal.exe",
88                ),
89            ],
90        }
91    }
92}
93
94impl DiscoveryProvider for AppProvider {
95    fn provider_name(&self) -> &'static str {
96        "app"
97    }
98
99    fn discover(&self) -> Result<Vec<SearchItem>, ProviderError> {
100        Ok(self.apps.clone())
101    }
102}
103
104pub struct FileProvider {
105    files: Vec<SearchItem>,
106}
107
108impl FileProvider {
109    pub fn from_files(files: Vec<SearchItem>) -> Self {
110        Self { files }
111    }
112
113    pub fn deterministic_fixture() -> Self {
114        Self {
115            files: vec![
116                SearchItem::new(
117                    "file-report",
118                    "file",
119                    "Q4_Report.xlsx",
120                    "C:\\Users\\Admin\\Documents\\Q4_Report.xlsx",
121                ),
122                SearchItem::new(
123                    "file-notes",
124                    "file",
125                    "Meeting Notes.txt",
126                    "C:\\Users\\Admin\\Documents\\Meeting Notes.txt",
127                ),
128            ],
129        }
130    }
131}
132
133impl DiscoveryProvider for FileProvider {
134    fn provider_name(&self) -> &'static str {
135        "file"
136    }
137
138    fn discover(&self) -> Result<Vec<SearchItem>, ProviderError> {
139        Ok(self.files.clone())
140    }
141}
142
143pub struct StartMenuAppDiscoveryProvider {
144    roots: Vec<PathBuf>,
145}
146
147impl Default for StartMenuAppDiscoveryProvider {
148    fn default() -> Self {
149        Self {
150            roots: default_start_menu_roots(),
151        }
152    }
153}
154
155impl StartMenuAppDiscoveryProvider {
156    pub fn with_roots(roots: Vec<PathBuf>) -> Self {
157        Self { roots }
158    }
159}
160
161impl DiscoveryProvider for StartMenuAppDiscoveryProvider {
162    fn provider_name(&self) -> &'static str {
163        "start-menu-apps"
164    }
165
166    fn discover(&self) -> Result<Vec<SearchItem>, ProviderError> {
167        #[cfg(not(target_os = "windows"))]
168        {
169            let _ = &self.roots;
170            Ok(Vec::new())
171        }
172
173        #[cfg(target_os = "windows")]
174        {
175            let uninstall_publishers = crate::uninstall_registry::publishers_by_display_name()
176                .unwrap_or_else(|error| {
177                    crate::logging::warn(&format!(
178                        "[nex] uninstall publisher map unavailable: {}",
179                        error
180                    ));
181                    HashMap::new()
182                });
183            let mut items = Vec::new();
184            for root in &self.roots {
185                items.extend(discover_start_menu_root(root, &uninstall_publishers)?);
186            }
187            if let Ok(system_apps) = discover_start_apps(&uninstall_publishers) {
188                items.extend(system_apps);
189            }
190            Ok(dedupe_apps_by_title(items))
191        }
192    }
193
194    fn change_stamp(&self) -> Option<String> {
195        // Bump when Start menu discovery/filtering behavior changes so incremental
196        // rebuilds do not keep stale cached app entries.
197        const START_MENU_DISCOVERY_SCHEMA_VERSION: &str = "6";
198        Some(format!(
199            "v{START_MENU_DISCOVERY_SCHEMA_VERSION};{}",
200            roots_change_stamp(&self.roots)
201        ))
202    }
203}
204
205pub struct FileSystemDiscoveryProvider {
206    roots: Vec<PathBuf>,
207    excluded_roots: Vec<PathBuf>,
208    max_depth: usize,
209    windows_search_enabled: bool,
210    windows_search_fallback_filesystem: bool,
211    show_files: bool,
212    show_folders: bool,
213    max_items_total: usize,
214    max_items_per_root: usize,
215}
216
217impl FileSystemDiscoveryProvider {
218    pub fn new(roots: Vec<PathBuf>, max_depth: usize, excluded_roots: Vec<PathBuf>) -> Self {
219        Self::with_options(roots, max_depth, excluded_roots, true, true, true, true)
220    }
221
222    pub fn with_windows_search_options(
223        roots: Vec<PathBuf>,
224        max_depth: usize,
225        excluded_roots: Vec<PathBuf>,
226        windows_search_enabled: bool,
227        windows_search_fallback_filesystem: bool,
228    ) -> Self {
229        Self::with_options(
230            roots,
231            max_depth,
232            excluded_roots,
233            windows_search_enabled,
234            windows_search_fallback_filesystem,
235            true,
236            true,
237        )
238    }
239
240    pub fn with_options(
241        roots: Vec<PathBuf>,
242        max_depth: usize,
243        excluded_roots: Vec<PathBuf>,
244        windows_search_enabled: bool,
245        windows_search_fallback_filesystem: bool,
246        show_files: bool,
247        show_folders: bool,
248    ) -> Self {
249        Self {
250            roots,
251            excluded_roots,
252            max_depth,
253            windows_search_enabled,
254            windows_search_fallback_filesystem,
255            show_files,
256            show_folders,
257            max_items_total: DEFAULT_INDEX_MAX_ITEMS_TOTAL,
258            max_items_per_root: DEFAULT_INDEX_MAX_ITEMS_PER_ROOT,
259        }
260    }
261
262    pub fn with_index_limits(mut self, max_items_total: usize, max_items_per_root: usize) -> Self {
263        let total = max_items_total.max(1);
264        let per_root = max_items_per_root.max(1).min(total);
265        self.max_items_total = total;
266        self.max_items_per_root = per_root;
267        self
268    }
269}
270
271impl DiscoveryProvider for FileSystemDiscoveryProvider {
272    fn provider_name(&self) -> &'static str {
273        "filesystem"
274    }
275
276    fn discover(&self) -> Result<Vec<SearchItem>, ProviderError> {
277        if !self.show_files && !self.show_folders {
278            return Ok(Vec::new());
279        }
280
281        #[cfg(target_os = "windows")]
282        if self.windows_search_enabled {
283            match discover_windows_search_items(
284                &self.roots,
285                &self.excluded_roots,
286                self.show_files,
287                self.show_folders,
288                self.max_items_total,
289                self.max_items_per_root,
290            ) {
291                Ok(items) if !items.is_empty() => return Ok(items),
292                Ok(_) if !self.windows_search_fallback_filesystem => return Ok(Vec::new()),
293                Ok(_) => {}
294                Err(error) if !self.windows_search_fallback_filesystem => return Err(error),
295                Err(_) => {}
296            }
297        }
298
299        discover_filesystem_walk(
300            &self.roots,
301            &self.excluded_roots,
302            self.max_depth,
303            self.show_files,
304            self.show_folders,
305            self.max_items_total,
306            self.max_items_per_root,
307        )
308    }
309
310    fn change_stamp(&self) -> Option<String> {
311        let mut stamp = String::new();
312        stamp.push_str("schema=");
313        stamp.push_str(FILESYSTEM_DISCOVERY_SCHEMA_VERSION);
314        stamp.push(';');
315        stamp.push_str("roots=");
316        stamp.push_str(&roots_change_stamp(&self.roots));
317        stamp.push_str(";exclude=");
318        stamp.push_str(&roots_change_stamp(&self.excluded_roots));
319        stamp.push_str(";depth=");
320        stamp.push_str(&self.max_depth.to_string());
321        stamp.push_str(";windows_search=");
322        stamp.push_str(if self.windows_search_enabled {
323            "enabled"
324        } else {
325            "disabled"
326        });
327        stamp.push_str(";fallback=");
328        stamp.push_str(if self.windows_search_fallback_filesystem {
329            "filesystem"
330        } else {
331            "none"
332        });
333        stamp.push_str(";show_files=");
334        stamp.push_str(if self.show_files { "true" } else { "false" });
335        stamp.push_str(";show_folders=");
336        stamp.push_str(if self.show_folders { "true" } else { "false" });
337        stamp.push_str(";cap_total=");
338        stamp.push_str(&self.max_items_total.to_string());
339        stamp.push_str(";cap_per_root=");
340        stamp.push_str(&self.max_items_per_root.to_string());
341        Some(stamp)
342    }
343}
344
345fn discover_filesystem_walk(
346    roots: &[PathBuf],
347    excluded_roots: &[PathBuf],
348    max_depth: usize,
349    show_files: bool,
350    show_folders: bool,
351    max_items_total: usize,
352    max_items_per_root: usize,
353) -> Result<Vec<SearchItem>, ProviderError> {
354    let mut out = Vec::new();
355    let exclusion_policy = DiscoveryExclusionPolicy::new(excluded_roots);
356    let total_budget = max_items_total.max(1);
357    let per_root_budget = max_items_per_root.max(1).min(total_budget);
358    let mut total_added = 0_usize;
359    let mut skipped_due_exclusion = 0_usize;
360
361    for root in roots {
362        if total_added >= total_budget {
363            break;
364        }
365        if !root.exists() {
366            continue;
367        }
368        if exclusion_policy.should_exclude_path_under_root(root, root) {
369            skipped_due_exclusion = skipped_due_exclusion.saturating_add(1);
370            continue;
371        }
372
373        let mut root_added = 0_usize;
374        for entry in walkdir::WalkDir::new(root)
375            .max_depth(max_depth)
376            .into_iter()
377            .filter_entry(|entry| {
378                let excluded = exclusion_policy.should_exclude_path_under_root(entry.path(), root);
379                if excluded && entry.path() != root {
380                    skipped_due_exclusion = skipped_due_exclusion.saturating_add(1);
381                }
382                !excluded
383            })
384            .filter_map(Result::ok)
385        {
386            if total_added >= total_budget || root_added >= per_root_budget {
387                break;
388            }
389            let path = entry.path();
390            if path.is_dir() {
391                if !show_folders {
392                    continue;
393                }
394                if path == root {
395                    continue;
396                }
397
398                let folder_name = path
399                    .file_name()
400                    .map(|n| n.to_string_lossy().to_string())
401                    .unwrap_or_else(|| path.to_string_lossy().to_string());
402
403                let id = format!("folder:{}", path.to_string_lossy());
404                out.push(SearchItem::new(
405                    &id,
406                    "folder",
407                    &folder_name,
408                    &path.to_string_lossy(),
409                ));
410                total_added += 1;
411                root_added += 1;
412                continue;
413            }
414
415            if !path.is_file() {
416                continue;
417            }
418            if !show_files {
419                continue;
420            }
421
422            let file_name = path
423                .file_name()
424                .map(|n| n.to_string_lossy().to_string())
425                .unwrap_or_else(|| path.to_string_lossy().to_string());
426
427            let id = format!("file:{}", path.to_string_lossy());
428            out.push(SearchItem::new(
429                &id,
430                "file",
431                &file_name,
432                &path.to_string_lossy(),
433            ));
434            total_added += 1;
435            root_added += 1;
436        }
437    }
438
439    if total_added >= total_budget {
440        crate::logging::info(&format!(
441            "[nex] discovery_cap provider=filesystem total_cap={} reached=true",
442            total_budget
443        ));
444    }
445    if skipped_due_exclusion > 0 {
446        crate::logging::info(&format!(
447            "[nex] discovery_exclusion provider=filesystem skipped={} policy_schema={}",
448            skipped_due_exclusion, FILESYSTEM_DISCOVERY_SCHEMA_VERSION
449        ));
450    }
451
452    Ok(out)
453}
454
455#[derive(Debug, Clone)]
456struct DiscoveryExclusionPolicy {
457    excluded_roots: Vec<String>,
458    top_level_dir_names: HashSet<&'static str>,
459    any_depth_dir_names: HashSet<&'static str>,
460    file_names: HashSet<&'static str>,
461}
462
463impl DiscoveryExclusionPolicy {
464    fn new(user_excluded_roots: &[PathBuf]) -> Self {
465        Self {
466            excluded_roots: effective_normalized_exclusion_roots(user_excluded_roots),
467            top_level_dir_names: TOP_LEVEL_EXCLUDED_DIR_NAMES.iter().copied().collect(),
468            any_depth_dir_names: ANY_DEPTH_EXCLUDED_DIR_NAMES.iter().copied().collect(),
469            file_names: EXCLUDED_FILE_NAMES.iter().copied().collect(),
470        }
471    }
472
473    fn should_exclude_path_under_root(&self, path: &Path, root: &Path) -> bool {
474        if is_path_under_any_excluded_root(path, &self.excluded_roots) {
475            return true;
476        }
477
478        let Ok(relative) = path.strip_prefix(root) else {
479            return false;
480        };
481        let components = relative
482            .components()
483            .filter_map(|component| match component {
484                std::path::Component::Normal(value) => {
485                    Some(value.to_string_lossy().to_ascii_lowercase())
486                }
487                _ => None,
488            })
489            .collect::<Vec<_>>();
490        if components.is_empty() {
491            return false;
492        }
493
494        if self.top_level_dir_names.contains(components[0].as_str()) {
495            return true;
496        }
497
498        let is_dir = path.is_dir();
499        for (index, component) in components.iter().enumerate() {
500            let is_last = index + 1 == components.len();
501            if self.file_names.contains(component.as_str()) {
502                return true;
503            }
504            if self.any_depth_dir_names.contains(component.as_str()) && (!is_last || is_dir) {
505                return true;
506            }
507        }
508
509        false
510    }
511}
512
513fn effective_normalized_exclusion_roots(user_excluded_roots: &[PathBuf]) -> Vec<String> {
514    let mut roots = builtin_exclusion_roots();
515    roots.extend(user_excluded_roots.iter().cloned());
516    normalized_exclusion_roots(&roots)
517}
518
519fn builtin_exclusion_roots() -> Vec<PathBuf> {
520    #[cfg(target_os = "windows")]
521    {
522        let mut roots = Vec::new();
523
524        if let Ok(system_drive) = std::env::var("SystemDrive") {
525            let trimmed = system_drive.trim();
526            if !trimmed.is_empty() {
527                let drive_root = PathBuf::from(format!("{trimmed}\\"));
528                roots.push(drive_root.join("Windows"));
529                roots.push(drive_root.join("Program Files"));
530                roots.push(drive_root.join("Program Files (x86)"));
531                roots.push(drive_root.join("$Recycle.Bin"));
532                roots.push(drive_root.join("System Volume Information"));
533                roots.push(drive_root.join("pagefile.sys"));
534                roots.push(drive_root.join("hiberfil.sys"));
535            }
536        }
537
538        if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
539            let local = PathBuf::from(local_app_data.trim());
540            if !local.as_os_str().is_empty() {
541                roots.push(local.join("Temp"));
542                roots.push(local.join("Microsoft").join("Windows").join("INetCache"));
543            }
544        }
545
546        if let Ok(app_data) = std::env::var("APPDATA") {
547            let roaming = PathBuf::from(app_data.trim());
548            if !roaming.as_os_str().is_empty() {
549                if let Some(parent) = roaming.parent().and_then(|path| path.parent()) {
550                    roots.push(parent.join("AppData"));
551                    roots.push(parent.join(".ssh"));
552                }
553            }
554        }
555
556        roots
557    }
558
559    #[cfg(not(target_os = "windows"))]
560    {
561        Vec::new()
562    }
563}
564
565fn roots_change_stamp(roots: &[PathBuf]) -> String {
566    let mut parts = Vec::with_capacity(roots.len());
567    for root in roots {
568        let normalized = normalize_root_for_stamp(root);
569        let (exists, modified_secs, child_count, child_latest_secs) = quick_path_fingerprint(root);
570        parts.push(format!(
571            "{normalized}:{exists}:{modified_secs}:{child_count}:{child_latest_secs}"
572        ));
573    }
574    parts.join("|")
575}
576
577fn normalize_root_for_stamp(path: &Path) -> String {
578    path.to_string_lossy()
579        .replace('/', "\\")
580        .to_ascii_lowercase()
581}
582
583fn quick_path_fingerprint(path: &Path) -> (u8, u64, usize, u64) {
584    let Ok(meta) = std::fs::metadata(path) else {
585        return (0, 0, 0, 0);
586    };
587    let root_modified_secs = modified_secs(&meta);
588    let mut child_count = 0_usize;
589    let mut child_latest_secs = 0_u64;
590
591    if meta.is_dir() {
592        if let Ok(entries) = std::fs::read_dir(path) {
593            for entry in entries.flatten() {
594                child_count += 1;
595                if let Ok(child_meta) = entry.metadata() {
596                    child_latest_secs = child_latest_secs.max(modified_secs(&child_meta));
597                }
598            }
599        }
600    }
601
602    (1, root_modified_secs, child_count, child_latest_secs)
603}
604
605fn modified_secs(meta: &std::fs::Metadata) -> u64 {
606    meta.modified()
607        .ok()
608        .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
609        .map(|value| value.as_secs())
610        .unwrap_or(0)
611}
612
613fn normalized_exclusion_roots(excluded_roots: &[PathBuf]) -> Vec<String> {
614    excluded_roots
615        .iter()
616        .filter_map(|root| normalize_path_for_compare(root).filter(|v| !v.is_empty()))
617        .collect()
618}
619
620fn is_path_under_any_excluded_root(path: &Path, excluded_roots: &[String]) -> bool {
621    let Some(path_norm) = normalize_path_for_compare(path) else {
622        return false;
623    };
624    excluded_roots.iter().any(|root| {
625        path_norm == *root
626            || (path_norm.starts_with(root) && path_norm[root.len()..].starts_with('\\'))
627    })
628}
629
630fn normalize_path_for_compare(path: &Path) -> Option<String> {
631    let mut value = path.to_string_lossy().replace('/', "\\");
632    while value.ends_with('\\') {
633        value.pop();
634    }
635    let value = value.trim().to_ascii_lowercase();
636    if value.is_empty() {
637        None
638    } else {
639        Some(value)
640    }
641}
642
643#[cfg(target_os = "windows")]
644fn default_start_menu_roots() -> Vec<PathBuf> {
645    let mut roots = Vec::new();
646
647    if let Ok(program_data) = std::env::var("ProgramData") {
648        roots.push(
649            PathBuf::from(program_data)
650                .join("Microsoft")
651                .join("Windows")
652                .join("Start Menu")
653                .join("Programs"),
654        );
655    }
656
657    if let Ok(app_data) = std::env::var("APPDATA") {
658        roots.push(
659            PathBuf::from(app_data)
660                .join("Microsoft")
661                .join("Windows")
662                .join("Start Menu")
663                .join("Programs"),
664        );
665    }
666
667    roots
668}
669
670#[cfg(not(target_os = "windows"))]
671fn default_start_menu_roots() -> Vec<PathBuf> {
672    Vec::new()
673}
674
675#[cfg(target_os = "windows")]
676fn discover_start_menu_root(
677    root: &Path,
678    uninstall_publishers: &HashMap<String, String>,
679) -> Result<Vec<SearchItem>, ProviderError> {
680    if !root.exists() {
681        return Ok(Vec::new());
682    }
683
684    #[derive(Debug, Clone)]
685    struct StartMenuCandidate {
686        path: PathBuf,
687        title: String,
688        ext: String,
689        shortcut_target: Option<String>,
690    }
691
692    let mut candidates: Vec<StartMenuCandidate> = Vec::new();
693    for entry in walkdir::WalkDir::new(root)
694        .into_iter()
695        .filter_map(Result::ok)
696    {
697        let path = entry.path();
698        if !path.is_file() {
699            continue;
700        }
701
702        let ext = path
703            .extension()
704            .map(|e| e.to_string_lossy().to_ascii_lowercase())
705            .unwrap_or_default();
706        let title = path
707            .file_stem()
708            .map(|s| s.to_string_lossy().to_string())
709            .unwrap_or_else(|| path.to_string_lossy().to_string());
710
711        if ext != "lnk" && ext != "exe" {
712            continue;
713        }
714        let mut resolved_shortcut_target = None;
715        if ext == "lnk" {
716            resolved_shortcut_target = resolve_shortcut_target_for_discovery(path);
717            if let Some(shortcut_target) = resolved_shortcut_target.as_deref() {
718                if should_exclude_non_app_start_reference(title.as_str(), shortcut_target) {
719                    continue;
720                }
721            }
722        }
723        if ext == "lnk" && !shortcut_has_launch_target(path) {
724            continue;
725        }
726
727        if is_documentation_like_start_entry_title(&title) {
728            continue;
729        }
730
731        candidates.push(StartMenuCandidate {
732            path: path.to_path_buf(),
733            title,
734            ext,
735            shortcut_target: resolved_shortcut_target,
736        });
737    }
738
739    let mut exe_paths = HashSet::new();
740    for candidate in &candidates {
741        if candidate.ext == "exe" {
742            let exe = normalize_shortcut_target_path(candidate.path.to_string_lossy().as_ref());
743            if !exe.is_empty() {
744                exe_paths.insert(exe);
745            }
746            continue;
747        }
748        if let Some(target) = candidate.shortcut_target.as_deref() {
749            let normalized_target = normalize_shortcut_target_path(target);
750            if looks_like_filesystem_path(normalized_target.as_str())
751                && normalized_target.to_ascii_lowercase().ends_with(".exe")
752            {
753                exe_paths.insert(normalized_target);
754            }
755        }
756    }
757    let mut exe_paths_vec: Vec<String> = exe_paths.into_iter().collect();
758    exe_paths_vec.sort();
759    let exe_publishers = load_exe_company_names(&exe_paths_vec).unwrap_or_default();
760
761    let mut items = Vec::with_capacity(candidates.len());
762    for candidate in candidates {
763        let path_text = candidate.path.to_string_lossy().to_string();
764        let id = format!("app:{path_text}");
765        let mut subtitle = String::new();
766
767        if let Some(publisher) =
768            publisher_from_uninstall_map(candidate.title.as_str(), uninstall_publishers)
769        {
770            subtitle = publisher;
771        }
772
773        if subtitle.trim().is_empty() {
774            let exe_target = if candidate.ext == "exe" {
775                normalize_shortcut_target_path(path_text.as_str())
776            } else {
777                candidate
778                    .shortcut_target
779                    .as_deref()
780                    .map(normalize_shortcut_target_path)
781                    .unwrap_or_default()
782            };
783            if !exe_target.trim().is_empty() {
784                let exe_key = normalize_id_path(exe_target.as_str());
785                if let Some(exe_subtitle) = exe_publishers.get(&exe_key) {
786                    subtitle = exe_subtitle.clone();
787                }
788            }
789        }
790
791        if subtitle.trim().is_empty() {
792            if let Some(fallback) = start_menu_entry_subtitle(
793                root,
794                candidate.path.as_path(),
795                candidate.shortcut_target.as_deref(),
796            ) {
797                subtitle = fallback;
798            }
799        }
800
801        let mut item = SearchItem::new(&id, "app", &candidate.title, &path_text);
802        if let Some(clean_subtitle) = sanitize_publisher_label(subtitle.as_str()) {
803            item = item.with_subtitle(clean_subtitle.as_str());
804        }
805        items.push(item);
806    }
807
808    Ok(items)
809}
810
811#[cfg(target_os = "windows")]
812fn discover_start_apps(
813    uninstall_publishers: &HashMap<String, String>,
814) -> Result<Vec<SearchItem>, ProviderError> {
815    use std::os::windows::process::CommandExt;
816    use std::process::Command;
817
818    const CREATE_NO_WINDOW: u32 = 0x08000000;
819
820    let script = r#"
821$ErrorActionPreference = 'Stop'
822Get-StartApps | ForEach-Object {
823  $name = [string]$_.Name
824  $appId = [string]$_.AppID
825  if (-not [string]::IsNullOrWhiteSpace($name) -and -not [string]::IsNullOrWhiteSpace($appId)) {
826    "{0}`t{1}" -f $name.Trim(), $appId.Trim()
827  }
828}
829"#;
830    let mut command = Command::new("powershell.exe");
831    command
832        .args([
833            "-NoProfile",
834            "-NonInteractive",
835            "-ExecutionPolicy",
836            "Bypass",
837            "-WindowStyle",
838            "Hidden",
839            "-Command",
840            script,
841        ])
842        .creation_flags(CREATE_NO_WINDOW);
843
844    let output = command
845        .output()
846        .map_err(|error| ProviderError::new(format!("Get-StartApps invocation failed: {error}")))?;
847
848    if !output.status.success() {
849        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
850        return Err(ProviderError::new(format!(
851            "Get-StartApps failed (status={}): {}",
852            output.status,
853            if stderr.is_empty() {
854                "no stderr"
855            } else {
856                stderr.as_str()
857            }
858        )));
859    }
860
861    let appx_publishers = load_appx_family_publishers().unwrap_or_default();
862
863    let mut items = Vec::new();
864    for line in String::from_utf8_lossy(&output.stdout).lines() {
865        let Some((name, app_id)) = line.split_once('\t') else {
866            continue;
867        };
868
869        let title = name.trim();
870        let app_id = app_id.trim();
871        if title.is_empty() || app_id.is_empty() {
872            continue;
873        }
874        if should_exclude_non_app_start_reference(title, app_id) {
875            continue;
876        }
877        if is_documentation_like_start_entry_title(title) {
878            continue;
879        }
880
881        let path = format!("shell:AppsFolder\\{app_id}");
882        let id = format!("app:{}", normalize_id_path(&path));
883        let mut item = SearchItem::new(&id, "app", title, &path);
884        if let Some(subtitle) = publisher_from_uninstall_map(title, uninstall_publishers)
885            .or_else(|| start_app_subtitle_from_app_id(app_id, &appx_publishers))
886            .and_then(|raw| sanitize_publisher_label(raw.as_str()))
887        {
888            item = item.with_subtitle(subtitle.as_str());
889        }
890        items.push(item);
891    }
892
893    Ok(items)
894}
895
896#[cfg(target_os = "windows")]
897fn load_appx_family_publishers() -> Result<HashMap<String, String>, ProviderError> {
898    use std::os::windows::process::CommandExt;
899    use std::process::Command;
900
901    const CREATE_NO_WINDOW: u32 = 0x08000000;
902    let script = r#"
903$ErrorActionPreference = 'Stop'
904Get-AppxPackage | ForEach-Object {
905  $family = [string]$_.PackageFamilyName
906  if ([string]::IsNullOrWhiteSpace($family)) { return }
907  $publisher = [string]$_.PublisherDisplayName
908  if ([string]::IsNullOrWhiteSpace($publisher)) {
909    $raw = [string]$_.Publisher
910    if (-not [string]::IsNullOrWhiteSpace($raw)) {
911      if ($raw -match 'CN=([^,]+)') { $publisher = $matches[1] } else { $publisher = $raw }
912    }
913  }
914  if (-not [string]::IsNullOrWhiteSpace($publisher)) {
915    "{0}`t{1}" -f $family.Trim(), $publisher.Trim()
916  }
917}
918"#;
919
920    let mut command = Command::new("powershell.exe");
921    command
922        .args([
923            "-NoProfile",
924            "-NonInteractive",
925            "-ExecutionPolicy",
926            "Bypass",
927            "-WindowStyle",
928            "Hidden",
929            "-Command",
930            script,
931        ])
932        .creation_flags(CREATE_NO_WINDOW);
933
934    let output = command.output().map_err(|error| {
935        ProviderError::new(format!("Get-AppxPackage invocation failed: {error}"))
936    })?;
937    if !output.status.success() {
938        return Ok(HashMap::new());
939    }
940
941    let mut out = HashMap::new();
942    for line in String::from_utf8_lossy(&output.stdout).lines() {
943        let mut parts = line.splitn(2, '\t');
944        let Some(family_raw) = parts.next() else {
945            continue;
946        };
947        let Some(publisher_raw) = parts.next() else {
948            continue;
949        };
950        let family = family_raw.trim();
951        let publisher = publisher_raw.trim();
952        if family.is_empty() || publisher.is_empty() {
953            continue;
954        }
955        if let Some(clean_publisher) = sanitize_publisher_label(publisher) {
956            out.insert(family.to_ascii_lowercase(), clean_publisher);
957        }
958    }
959
960    Ok(out)
961}
962
963#[cfg(target_os = "windows")]
964fn load_exe_company_names(exe_paths: &[String]) -> Result<HashMap<String, String>, ProviderError> {
965    use std::os::windows::process::CommandExt;
966    use std::process::Command;
967
968    const CREATE_NO_WINDOW: u32 = 0x08000000;
969    if exe_paths.is_empty() {
970        return Ok(HashMap::new());
971    }
972    let joined_paths = exe_paths.join("\u{1f}");
973    if joined_paths.trim().is_empty() {
974        return Ok(HashMap::new());
975    }
976
977    let script = r#"
978$ErrorActionPreference = 'Stop'
979$separator = [char]0x1f
980$paths = @()
981if ($env:NEX_EXE_PATHS) { $paths = $env:NEX_EXE_PATHS -split $separator }
982elseif ($env:SWIFTFIND_EXE_PATHS) { $paths = $env:SWIFTFIND_EXE_PATHS -split $separator }
983foreach ($path in $paths) {
984  $candidate = [string]$path
985  if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
986  if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
987  try {
988    $publisher = [string][System.Diagnostics.FileVersionInfo]::GetVersionInfo($candidate).CompanyName
989    if (-not [string]::IsNullOrWhiteSpace($publisher)) {
990      "{0}`t{1}" -f $candidate, $publisher.Trim()
991    }
992  } catch {}
993}
994"#;
995
996    let mut command = Command::new("powershell.exe");
997    command
998        .args([
999            "-NoProfile",
1000            "-NonInteractive",
1001            "-ExecutionPolicy",
1002            "Bypass",
1003            "-WindowStyle",
1004            "Hidden",
1005            "-Command",
1006            script,
1007        ])
1008        .env("NEX_EXE_PATHS", &joined_paths)
1009        .env("SWIFTFIND_EXE_PATHS", joined_paths)
1010        .creation_flags(CREATE_NO_WINDOW);
1011
1012    let output = command.output().map_err(|error| {
1013        ProviderError::new(format!(
1014            "exe publisher discovery invocation failed: {error}"
1015        ))
1016    })?;
1017    if !output.status.success() {
1018        return Ok(HashMap::new());
1019    }
1020
1021    let mut out = HashMap::new();
1022    for line in String::from_utf8_lossy(&output.stdout).lines() {
1023        let mut parts = line.splitn(2, '\t');
1024        let Some(path_raw) = parts.next() else {
1025            continue;
1026        };
1027        let Some(publisher_raw) = parts.next() else {
1028            continue;
1029        };
1030        let path = path_raw.trim();
1031        let publisher = publisher_raw.trim();
1032        if path.is_empty() || publisher.is_empty() {
1033            continue;
1034        }
1035        if let Some(clean_publisher) = sanitize_publisher_label(publisher) {
1036            out.insert(normalize_id_path(path), clean_publisher);
1037        }
1038    }
1039
1040    Ok(out)
1041}
1042
1043#[cfg(target_os = "windows")]
1044fn dedupe_apps_by_title(items: Vec<SearchItem>) -> Vec<SearchItem> {
1045    let mut by_title: HashMap<String, SearchItem> = HashMap::new();
1046    for item in items {
1047        let title_key = crate::model::normalize_for_search(item.title.trim());
1048        let key = if title_key.is_empty() {
1049            format!("path:{}", normalize_id_path(&item.path))
1050        } else {
1051            title_key
1052        };
1053
1054        match by_title.get(&key) {
1055            Some(existing) if app_quality_rank(existing) >= app_quality_rank(&item) => {}
1056            _ => {
1057                by_title.insert(key, item);
1058            }
1059        }
1060    }
1061
1062    let mut out: Vec<SearchItem> = by_title.into_values().collect();
1063    out.sort_by(|a, b| {
1064        a.title
1065            .to_ascii_lowercase()
1066            .cmp(&b.title.to_ascii_lowercase())
1067    });
1068    out
1069}
1070
1071#[cfg(target_os = "windows")]
1072fn app_quality_rank(item: &SearchItem) -> u8 {
1073    let subtitle_bonus = subtitle_quality_score(item.subtitle.as_str());
1074    let lowered = item.path.trim().to_ascii_lowercase();
1075    if lowered.starts_with("shell:appsfolder\\") {
1076        return 3 + subtitle_bonus;
1077    }
1078    if lowered.ends_with(".lnk") || lowered.ends_with(".exe") {
1079        return 2 + subtitle_bonus;
1080    }
1081    1 + subtitle_bonus
1082}
1083
1084#[cfg(target_os = "windows")]
1085fn subtitle_quality_score(subtitle: &str) -> u8 {
1086    let trimmed = subtitle.trim();
1087    if trimmed.is_empty() {
1088        return 0;
1089    }
1090    let word_count = trimmed.split_whitespace().count();
1091    if word_count >= 3 {
1092        3
1093    } else if word_count == 2 {
1094        2
1095    } else {
1096        1
1097    }
1098}
1099
1100#[cfg(target_os = "windows")]
1101fn start_menu_entry_subtitle(
1102    _root: &Path,
1103    _entry_path: &Path,
1104    shortcut_target: Option<&str>,
1105) -> Option<String> {
1106    let shortcut_target = shortcut_target?;
1107    let normalized_target = normalize_shortcut_target_path(shortcut_target);
1108    if normalized_target.is_empty() || !looks_like_filesystem_path(normalized_target.as_str()) {
1109        return None;
1110    }
1111    program_files_vendor_label(normalized_target.as_str())
1112}
1113
1114#[cfg(target_os = "windows")]
1115fn program_files_vendor_label(target_path: &str) -> Option<String> {
1116    let normalized = target_path.replace('/', "\\");
1117    let lower = normalized.to_ascii_lowercase();
1118    let markers = ["\\program files\\", "\\program files (x86)\\"];
1119    for marker in markers {
1120        let Some(start) = lower.find(marker) else {
1121            continue;
1122        };
1123        let tail = &normalized[start + marker.len()..];
1124        let vendor = tail.split('\\').next().unwrap_or("").trim();
1125        if vendor.is_empty() {
1126            continue;
1127        }
1128        let vendor_lower = vendor.to_ascii_lowercase();
1129        if matches!(
1130            vendor_lower.as_str(),
1131            "windowsapps" | "common files" | "windows nt"
1132        ) {
1133            continue;
1134        }
1135        return Some(vendor.to_string());
1136    }
1137    None
1138}
1139
1140#[cfg(target_os = "windows")]
1141fn start_app_subtitle_from_app_id(
1142    app_id: &str,
1143    appx_publishers: &HashMap<String, String>,
1144) -> Option<String> {
1145    let trimmed = app_id.trim();
1146    if trimmed.is_empty() {
1147        return None;
1148    }
1149
1150    let lower = trimmed.to_ascii_lowercase();
1151    if lower.starts_with("microsoft.autogenerated.") {
1152        return None;
1153    }
1154
1155    if let Some((package_name, _app_entry)) = trimmed.split_once('!') {
1156        let family_key = package_name.trim().to_ascii_lowercase();
1157        if let Some(label) = appx_publishers.get(&family_key) {
1158            let cleaned = label.trim();
1159            if !cleaned.is_empty() {
1160                return Some(cleaned.to_string());
1161            }
1162        }
1163        if let Some((publisher_hint, _package_tail)) = package_name.split_once('_') {
1164            if let Some(publisher) = normalize_publisher_hint(publisher_hint) {
1165                return Some(publisher);
1166            }
1167        }
1168    }
1169
1170    None
1171}
1172
1173#[cfg(target_os = "windows")]
1174fn publisher_from_uninstall_map(
1175    title: &str,
1176    uninstall_publishers: &HashMap<String, String>,
1177) -> Option<String> {
1178    let key = crate::model::normalize_for_search(title);
1179    if key.is_empty() {
1180        return None;
1181    }
1182    uninstall_publishers
1183        .get(&key)
1184        .and_then(|publisher| sanitize_publisher_label(publisher.as_str()))
1185}
1186
1187#[cfg(target_os = "windows")]
1188fn normalize_publisher_hint(raw: &str) -> Option<String> {
1189    let trimmed = raw.trim();
1190    if trimmed.is_empty() {
1191        return None;
1192    }
1193    let head = trimmed.split('.').find(|part| !part.trim().is_empty())?;
1194    let head = head.trim();
1195    if head.is_empty() {
1196        return None;
1197    }
1198
1199    let lower = head.to_ascii_lowercase();
1200    if lower.starts_with("microsoft") {
1201        return Some("Microsoft".to_string());
1202    }
1203
1204    sanitize_publisher_label(head)
1205}
1206
1207#[cfg(target_os = "windows")]
1208fn sanitize_publisher_label(raw: &str) -> Option<String> {
1209    let mut trimmed = raw.trim().trim_matches('"').trim().to_string();
1210    if trimmed.is_empty() {
1211        return None;
1212    }
1213
1214    if trimmed.to_ascii_lowercase().starts_with("cn=") {
1215        trimmed = trimmed
1216            .get(3..)
1217            .unwrap_or_default()
1218            .split(',')
1219            .next()
1220            .unwrap_or("")
1221            .trim()
1222            .to_string();
1223        if trimmed.is_empty() {
1224            return None;
1225        }
1226    }
1227
1228    let collapsed = trimmed
1229        .split_whitespace()
1230        .filter(|part| !part.trim().is_empty())
1231        .collect::<Vec<_>>()
1232        .join(" ");
1233    if collapsed.is_empty() {
1234        return None;
1235    }
1236
1237    if collapsed.contains('\\')
1238        || collapsed.contains('/')
1239        || collapsed.contains('{')
1240        || collapsed.contains('}')
1241        || collapsed.contains("://")
1242    {
1243        return None;
1244    }
1245
1246    if looks_like_guid_token(collapsed.as_str())
1247        || looks_like_noisy_publisher_token(collapsed.as_str())
1248    {
1249        return None;
1250    }
1251
1252    let lowered = collapsed.to_ascii_lowercase();
1253    if lowered == "microsoftwindows" {
1254        return Some("Microsoft".to_string());
1255    }
1256
1257    Some(collapsed)
1258}
1259
1260#[cfg(target_os = "windows")]
1261fn looks_like_guid_token(value: &str) -> bool {
1262    let token = value
1263        .trim()
1264        .trim_matches('{')
1265        .trim_matches('}')
1266        .trim()
1267        .to_ascii_lowercase();
1268    let parts: Vec<&str> = token.split('-').collect();
1269    if parts.len() != 5 {
1270        return false;
1271    }
1272    let expected = [8, 4, 4, 4, 12];
1273    for (part, expected_len) in parts.iter().zip(expected.iter()) {
1274        if part.len() != *expected_len || !part.chars().all(|ch| ch.is_ascii_hexdigit()) {
1275            return false;
1276        }
1277    }
1278    true
1279}
1280
1281#[cfg(target_os = "windows")]
1282fn looks_like_noisy_publisher_token(value: &str) -> bool {
1283    let compact = value
1284        .chars()
1285        .filter(|ch| !ch.is_whitespace())
1286        .collect::<String>();
1287    if compact.is_empty() {
1288        return true;
1289    }
1290
1291    let lower = compact.to_ascii_lowercase();
1292    if lower.starts_with("microsoft.autogenerated") || lower.contains("autogenerated") {
1293        return true;
1294    }
1295
1296    if compact.contains('_') {
1297        return true;
1298    }
1299
1300    if !value.contains(' ') && compact.contains('.') {
1301        return true;
1302    }
1303
1304    let letters = compact
1305        .chars()
1306        .filter(|ch| ch.is_ascii_alphabetic())
1307        .count();
1308    let digits = compact.chars().filter(|ch| ch.is_ascii_digit()).count();
1309    if letters == 0 {
1310        return true;
1311    }
1312
1313    if !value.contains(' ') {
1314        if digits > 0 && digits * 2 >= letters {
1315            return true;
1316        }
1317        if compact.len() >= 14 && digits >= 3 {
1318            return true;
1319        }
1320    }
1321
1322    false
1323}
1324
1325#[cfg(target_os = "windows")]
1326fn normalize_id_path(path: &str) -> String {
1327    path.trim().replace('/', "\\").to_ascii_lowercase()
1328}
1329
1330#[cfg(target_os = "windows")]
1331fn shortcut_has_launch_target(shortcut_path: &Path) -> bool {
1332    use windows_sys::Win32::UI::Shell::HlinkResolveShortcutToString;
1333
1334    let wide_shortcut = to_wide(shortcut_path.to_string_lossy().as_ref());
1335    let mut target: windows_sys::core::PWSTR = std::ptr::null_mut();
1336    let mut location: windows_sys::core::PWSTR = std::ptr::null_mut();
1337
1338    let hr =
1339        unsafe { HlinkResolveShortcutToString(wide_shortcut.as_ptr(), &mut target, &mut location) };
1340    if hr < 0 {
1341        return false;
1342    }
1343
1344    let resolved_target = pwstr_to_string_and_free(target);
1345    let resolved_location = pwstr_to_string_and_free(location);
1346
1347    if shortcut_resolves_to_web_target(&resolved_target)
1348        || shortcut_resolves_to_web_target(&resolved_location)
1349    {
1350        return false;
1351    }
1352
1353    let resolved_target = normalize_shortcut_target_path(resolved_target.as_str());
1354    if resolved_target.is_empty() {
1355        return false;
1356    }
1357
1358    if looks_like_filesystem_path(resolved_target.as_str()) {
1359        return Path::new(resolved_target.as_str()).exists();
1360    }
1361
1362    true
1363}
1364
1365#[cfg(target_os = "windows")]
1366fn resolve_shortcut_target_for_discovery(shortcut_path: &Path) -> Option<String> {
1367    use windows_sys::Win32::UI::Shell::HlinkResolveShortcutToString;
1368
1369    let wide_shortcut = to_wide(shortcut_path.to_string_lossy().as_ref());
1370    let mut target: windows_sys::core::PWSTR = std::ptr::null_mut();
1371    let mut location: windows_sys::core::PWSTR = std::ptr::null_mut();
1372    let hr =
1373        unsafe { HlinkResolveShortcutToString(wide_shortcut.as_ptr(), &mut target, &mut location) };
1374    if hr < 0 {
1375        return None;
1376    }
1377
1378    let resolved_target = pwstr_to_string_and_free(target);
1379    let resolved_location = pwstr_to_string_and_free(location);
1380    let preferred = normalize_shortcut_target_path(resolved_target.as_str());
1381    if !preferred.is_empty() {
1382        return Some(preferred);
1383    }
1384    let fallback = normalize_shortcut_target_path(resolved_location.as_str());
1385    if fallback.is_empty() {
1386        None
1387    } else {
1388        Some(fallback)
1389    }
1390}
1391
1392#[cfg(target_os = "windows")]
1393fn should_exclude_non_app_start_reference(title: &str, reference: &str) -> bool {
1394    if is_excluded_windows_kits_shortcut_reference(reference) {
1395        return true;
1396    }
1397    if shortcut_resolves_to_web_target(reference) {
1398        return true;
1399    }
1400    if has_non_app_document_extension(reference) {
1401        return true;
1402    }
1403
1404    // Extra guard for label-only docs/help shortcuts that might point to local wrappers.
1405    if is_documentation_like_start_entry_title(title)
1406        && !reference_points_to_executable_reference(reference)
1407    {
1408        return true;
1409    }
1410
1411    false
1412}
1413
1414#[cfg(target_os = "windows")]
1415fn discover_windows_search_items(
1416    roots: &[PathBuf],
1417    excluded_roots: &[PathBuf],
1418    show_files: bool,
1419    show_folders: bool,
1420    max_items_total: usize,
1421    max_items_per_root: usize,
1422) -> Result<Vec<SearchItem>, ProviderError> {
1423    use std::collections::HashSet;
1424    use std::os::windows::process::CommandExt;
1425    use std::process::Command;
1426
1427    const CREATE_NO_WINDOW: u32 = 0x08000000;
1428
1429    let roots_joined = join_windows_paths_for_powershell(roots);
1430    if roots_joined.is_empty() {
1431        return Ok(Vec::new());
1432    }
1433    let exclusion_policy = DiscoveryExclusionPolicy::new(excluded_roots);
1434    let effective_excluded_roots = effective_excluded_roots_for_powershell(excluded_roots);
1435    let excluded_joined = join_windows_paths_for_powershell(&effective_excluded_roots);
1436
1437    let script = r#"
1438$ErrorActionPreference = 'Stop'
1439$separator = [char]0x1f
1440$roots = @()
1441$excludes = @()
1442if ($env:NEX_WS_ROOTS) { $roots = $env:NEX_WS_ROOTS -split $separator }
1443elseif ($env:SWIFTFIND_WS_ROOTS) { $roots = $env:SWIFTFIND_WS_ROOTS -split $separator }
1444if ($env:NEX_WS_EXCLUDES) { $excludes = $env:NEX_WS_EXCLUDES -split $separator }
1445elseif ($env:SWIFTFIND_WS_EXCLUDES) { $excludes = $env:SWIFTFIND_WS_EXCLUDES -split $separator }
1446
1447$conn = New-Object -ComObject ADODB.Connection
1448$conn.Open("Provider=Search.CollatorDSO;Extended Properties='Application=Windows'")
1449$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
1450
1451foreach ($root in $roots) {
1452  if ([string]::IsNullOrWhiteSpace($root)) { continue }
1453  $scope = $root.Trim().Replace('\', '/')
1454  if (-not $scope.EndsWith('/')) { $scope += '/' }
1455  $scope = $scope.Replace("'", "''")
1456  $query = "SELECT System.ItemPathDisplay, System.ItemName, System.FileAttributes FROM SYSTEMINDEX WHERE scope='file:$scope'"
1457  $recordset = $conn.Execute($query)
1458
1459  while (-not $recordset.EOF) {
1460    $path = [string]$recordset.Fields.Item("System.ItemPathDisplay").Value
1461    $name = [string]$recordset.Fields.Item("System.ItemName").Value
1462    $attrsValue = $recordset.Fields.Item("System.FileAttributes").Value
1463    $attrs = 0
1464    if ($null -ne $attrsValue -and "$attrsValue" -ne "") { $attrs = [int64]$attrsValue }
1465
1466    if (-not [string]::IsNullOrWhiteSpace($path)) {
1467      $skip = $false
1468      foreach ($exclude in $excludes) {
1469        if ([string]::IsNullOrWhiteSpace($exclude)) { continue }
1470        if ($path.StartsWith($exclude, [System.StringComparison]::OrdinalIgnoreCase)) {
1471          $skip = $true
1472          break
1473        }
1474      }
1475
1476      if (-not $skip -and $seen.Add($path)) {
1477        if ([string]::IsNullOrWhiteSpace($name)) { $name = [System.IO.Path]::GetFileName($path) }
1478        if ([string]::IsNullOrWhiteSpace($name)) { $name = $path }
1479        $kind = if (($attrs -band 16) -ne 0) { "folder" } else { "file" }
1480        "{0}`t{1}`t{2}" -f $kind, $name, $path
1481      }
1482    }
1483
1484    $recordset.MoveNext()
1485  }
1486
1487  $recordset.Close()
1488}
1489
1490$conn.Close()
1491"#;
1492
1493    let mut command = Command::new("powershell.exe");
1494    command
1495        .args([
1496            "-NoProfile",
1497            "-NonInteractive",
1498            "-ExecutionPolicy",
1499            "Bypass",
1500            "-WindowStyle",
1501            "Hidden",
1502            "-Command",
1503            script,
1504        ])
1505        .env("NEX_WS_ROOTS", &roots_joined)
1506        .env("SWIFTFIND_WS_ROOTS", roots_joined)
1507        .env("NEX_WS_EXCLUDES", &excluded_joined)
1508        .env("SWIFTFIND_WS_EXCLUDES", excluded_joined)
1509        .creation_flags(CREATE_NO_WINDOW);
1510
1511    let output = command.output().map_err(|error| {
1512        ProviderError::new(format!(
1513            "Windows Search provider invocation failed: {error}"
1514        ))
1515    })?;
1516
1517    if !output.status.success() {
1518        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1519        return Err(ProviderError::new(format!(
1520            "Windows Search provider failed (status={}): {}",
1521            output.status,
1522            if stderr.is_empty() {
1523                "no stderr"
1524            } else {
1525                stderr.as_str()
1526            }
1527        )));
1528    }
1529
1530    let mut seen_ids = HashSet::new();
1531    let normalized_roots = roots
1532        .iter()
1533        .map(|root| normalize_root_for_stamp(root))
1534        .collect::<Vec<_>>();
1535    let mut root_counts = vec![0_usize; normalized_roots.len()];
1536    let total_budget = max_items_total.max(1);
1537    let per_root_budget = max_items_per_root.max(1).min(total_budget);
1538    let mut skipped_due_cap = 0_usize;
1539    let mut skipped_due_exclusion = 0_usize;
1540    let mut items = Vec::new();
1541    for line in String::from_utf8_lossy(&output.stdout).lines() {
1542        let mut parts = line.splitn(3, '\t');
1543        let Some(kind_raw) = parts.next() else {
1544            continue;
1545        };
1546        let Some(title_raw) = parts.next() else {
1547            continue;
1548        };
1549        let Some(path_raw) = parts.next() else {
1550            continue;
1551        };
1552        let kind = kind_raw.trim().to_ascii_lowercase();
1553        if kind != "file" && kind != "folder" {
1554            continue;
1555        }
1556        if kind == "file" && !show_files {
1557            continue;
1558        }
1559        if kind == "folder" && !show_folders {
1560            continue;
1561        }
1562        let path = path_raw.trim();
1563        if path.is_empty() {
1564            continue;
1565        }
1566        let normalized_path = normalize_id_path(path);
1567        let root_index = normalized_roots.iter().position(|root| {
1568            normalized_path == *root
1569                || (normalized_path.starts_with(root)
1570                    && normalized_path[root.len()..].starts_with('\\'))
1571        });
1572        let Some(root_index) = root_index else {
1573            continue;
1574        };
1575        if exclusion_policy.should_exclude_path_under_root(Path::new(path), &roots[root_index]) {
1576            skipped_due_exclusion = skipped_due_exclusion.saturating_add(1);
1577            continue;
1578        }
1579        if items.len() >= total_budget || root_counts[root_index] >= per_root_budget {
1580            skipped_due_cap = skipped_due_cap.saturating_add(1);
1581            continue;
1582        }
1583        let title = title_raw.trim();
1584        let display_title = if title.is_empty() { path } else { title };
1585        let id = format!("{kind}:{normalized_path}");
1586        if seen_ids.insert(id.clone()) {
1587            items.push(SearchItem::new(&id, &kind, display_title, path));
1588            root_counts[root_index] += 1;
1589        }
1590    }
1591
1592    if skipped_due_cap > 0 {
1593        crate::logging::info(&format!(
1594            "[nex] discovery_cap provider=windows_search skipped_due_cap={} total_cap={} per_root_cap={}",
1595            skipped_due_cap, total_budget, per_root_budget
1596        ));
1597    }
1598    if skipped_due_exclusion > 0 {
1599        crate::logging::info(&format!(
1600            "[nex] discovery_exclusion provider=windows_search skipped={} policy_schema={}",
1601            skipped_due_exclusion, FILESYSTEM_DISCOVERY_SCHEMA_VERSION
1602        ));
1603    }
1604
1605    Ok(items)
1606}
1607
1608#[cfg(target_os = "windows")]
1609fn effective_excluded_roots_for_powershell(user_excluded_roots: &[PathBuf]) -> Vec<PathBuf> {
1610    let mut roots = builtin_exclusion_roots();
1611    roots.extend(user_excluded_roots.iter().cloned());
1612    roots.sort();
1613    roots.dedup();
1614    roots
1615}
1616
1617#[cfg(target_os = "windows")]
1618fn join_windows_paths_for_powershell(paths: &[PathBuf]) -> String {
1619    let mut out = Vec::new();
1620    for path in paths {
1621        let mut normalized = path.to_string_lossy().replace('/', "\\");
1622        while normalized.ends_with('\\') && normalized.len() > 3 {
1623            normalized.pop();
1624        }
1625        let trimmed = normalized.trim();
1626        if !trimmed.is_empty() {
1627            out.push(trimmed.to_string());
1628        }
1629    }
1630    out.join("\u{1f}")
1631}
1632
1633#[cfg(target_os = "windows")]
1634fn to_wide(value: &str) -> Vec<u16> {
1635    value.encode_utf16().chain(std::iter::once(0)).collect()
1636}
1637
1638#[cfg(target_os = "windows")]
1639fn pwstr_to_string_and_free(ptr: windows_sys::core::PWSTR) -> String {
1640    use windows_sys::Win32::System::Com::CoTaskMemFree;
1641
1642    if ptr.is_null() {
1643        return String::new();
1644    }
1645
1646    let mut len = 0usize;
1647    unsafe {
1648        while *ptr.add(len) != 0 {
1649            len += 1;
1650        }
1651        let slice = std::slice::from_raw_parts(ptr, len);
1652        let out = String::from_utf16_lossy(slice);
1653        CoTaskMemFree(ptr as _);
1654        out
1655    }
1656}
1657
1658#[cfg(target_os = "windows")]
1659fn shortcut_resolves_to_web_target(raw: &str) -> bool {
1660    let lowered = raw.trim().trim_matches('"').to_ascii_lowercase();
1661    if lowered.is_empty() {
1662        return false;
1663    }
1664    lowered.starts_with("http://")
1665        || lowered.starts_with("https://")
1666        || lowered.starts_with("microsoft-edge:")
1667        || lowered.starts_with("msedge:")
1668        || lowered.starts_with("www.")
1669        || lowered.contains("://")
1670}
1671
1672#[cfg(target_os = "windows")]
1673fn has_non_app_document_extension(value: &str) -> bool {
1674    let normalized = normalize_shortcut_target_path(value).to_ascii_lowercase();
1675    if normalized.is_empty() {
1676        return false;
1677    }
1678
1679    [
1680        ".url", ".pdf", ".htm", ".html", ".xhtml", ".mht", ".mhtml", ".chm", ".txt", ".md", ".rtf",
1681        ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".csv", ".xml", ".json", ".yaml",
1682        ".yml", ".ini", ".log", ".php",
1683    ]
1684    .iter()
1685    .any(|ext| normalized.ends_with(ext))
1686}
1687
1688#[cfg(target_os = "windows")]
1689fn reference_points_to_executable_reference(reference: &str) -> bool {
1690    let normalized = normalize_shortcut_target_path(reference).to_ascii_lowercase();
1691    if normalized.is_empty() {
1692        return false;
1693    }
1694
1695    if normalized.starts_with("shell:") || normalized.starts_with("ms-") {
1696        return true;
1697    }
1698
1699    [
1700        ".exe",
1701        ".com",
1702        ".bat",
1703        ".cmd",
1704        ".msc",
1705        ".ps1",
1706        ".vbs",
1707        ".appref-ms",
1708    ]
1709    .iter()
1710    .any(|ext| normalized.ends_with(ext))
1711}
1712
1713#[cfg(target_os = "windows")]
1714fn normalize_shortcut_target_path(raw: &str) -> String {
1715    raw.trim()
1716        .trim_matches('"')
1717        .trim_start_matches('@')
1718        .trim()
1719        .to_string()
1720}
1721
1722#[cfg(target_os = "windows")]
1723fn looks_like_filesystem_path(path: &str) -> bool {
1724    if path.starts_with('/') || path.starts_with('\\') {
1725        return true;
1726    }
1727    let bytes = path.as_bytes();
1728    bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
1729}
1730
1731#[cfg(target_os = "windows")]
1732fn is_documentation_like_start_entry_title(title: &str) -> bool {
1733    let lower = title.trim().to_ascii_lowercase();
1734    if lower.is_empty() {
1735        return false;
1736    }
1737
1738    let has_docs = lower.contains("documentation") || lower.contains(" docs");
1739    let has_sample = lower.contains("sample");
1740    let has_tools_for = lower.contains("tools for");
1741    let has_app_word = lower.contains(" app") || lower.contains("apps");
1742    let has_platform = lower.contains("uwp")
1743        || lower.contains("desktop")
1744        || lower.contains("winui")
1745        || lower.contains("windows sdk");
1746
1747    (has_docs && has_app_word)
1748        || (has_sample && (has_app_word || has_platform))
1749        || (has_tools_for && has_app_word && has_platform)
1750}
1751
1752#[cfg(target_os = "windows")]
1753fn is_excluded_windows_kits_shortcut_reference(value: &str) -> bool {
1754    let lower = value.trim().replace('/', "\\").to_ascii_lowercase();
1755    if lower.is_empty() {
1756        return false;
1757    }
1758    if !lower.contains("\\windows kits\\10\\shortcuts\\") {
1759        return false;
1760    }
1761    if !lower.ends_with(".url") {
1762        return false;
1763    }
1764    lower.contains("sample")
1765        || lower.contains("documentation")
1766        || lower.contains("toolsdocumentation")
1767}