1use std::collections::HashSet;
2#[cfg(target_os = "windows")]
3use std::collections::HashMap;
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 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 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}