1use rusqlite::{params, Connection};
2
3use crate::action_executor::{launch_path, LaunchError};
4use crate::config::{validate, Config, SearchMode};
5use crate::contract::{CoreRequest, CoreResponse, LaunchResponse, SearchResponse};
6use crate::discovery::{
7 DiscoveryProvider, FileSystemDiscoveryProvider, ProviderError, StartMenuAppDiscoveryProvider,
8};
9use crate::index_store::{self, StoreError};
10use crate::model::SearchItem;
11use crate::search::SearchFilter;
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14use std::sync::{Mutex, RwLock};
15use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
16
17const STALE_PRUNE_INTERVAL: Duration = Duration::from_secs(15);
18const PROVIDER_RECONCILE_INTERVAL_SECS: i64 = 30 * 60;
19const STALE_PRUNE_BATCH_SIZE: usize = 512;
20
21#[derive(Debug)]
22pub enum ServiceError {
23 Config(String),
24 Store(StoreError),
25 Provider(ProviderError),
26 Launch(LaunchError),
27 InvalidRequest(String),
28 ItemNotFound(String),
29}
30
31impl std::fmt::Display for ServiceError {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::Config(error) => write!(f, "config error: {error}"),
35 Self::Store(error) => write!(f, "store error: {error}"),
36 Self::Provider(error) => write!(f, "provider error: {error}"),
37 Self::Launch(error) => write!(f, "launch error: {error}"),
38 Self::InvalidRequest(error) => write!(f, "invalid request: {error}"),
39 Self::ItemNotFound(id) => write!(f, "item not found: {id}"),
40 }
41 }
42}
43
44impl std::error::Error for ServiceError {}
45
46impl From<StoreError> for ServiceError {
47 fn from(value: StoreError) -> Self {
48 Self::Store(value)
49 }
50}
51
52impl From<LaunchError> for ServiceError {
53 fn from(value: LaunchError) -> Self {
54 Self::Launch(value)
55 }
56}
57
58impl From<ProviderError> for ServiceError {
59 fn from(value: ProviderError) -> Self {
60 Self::Provider(value)
61 }
62}
63
64pub enum LaunchTarget<'a> {
65 Id(&'a str),
66 Path(&'a str),
67}
68
69pub struct CoreService {
70 config: RwLock<Config>,
71 db: Connection,
72 providers: RwLock<Vec<Box<dyn DiscoveryProvider>>>,
73 cached_items: RwLock<Vec<SearchItem>>,
74 cached_app_items: RwLock<Vec<SearchItem>>,
75 last_stale_prune: Mutex<Option<Instant>>,
76 stale_prune_cursor: Mutex<usize>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ProviderRefreshReport {
81 pub provider: String,
82 pub discovered: usize,
83 pub upserted: usize,
84 pub removed: usize,
85 pub skipped: bool,
86 pub elapsed_ms: u128,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct IndexRefreshReport {
91 pub indexed_total: usize,
92 pub discovered_total: usize,
93 pub upserted_total: usize,
94 pub removed_total: usize,
95 pub providers: Vec<ProviderRefreshReport>,
96}
97
98impl CoreService {
99 pub fn new(config: Config) -> Result<Self, ServiceError> {
100 validate(&config).map_err(ServiceError::Config)?;
101 let db = index_store::open_from_config(&config)?;
102 Self::with_loaded_cache(config, db)
103 }
104
105 pub fn with_connection(config: Config, db: Connection) -> Result<Self, ServiceError> {
106 validate(&config).map_err(ServiceError::Config)?;
107 Self::with_loaded_cache(config, db)
108 }
109
110 fn with_loaded_cache(config: Config, db: Connection) -> Result<Self, ServiceError> {
111 let cached = index_store::list_items(&db)?;
112 let cached_apps = collect_app_items(&cached);
113 Ok(Self {
114 config: RwLock::new(config),
115 db,
116 providers: RwLock::new(Vec::new()),
117 cached_items: RwLock::new(cached),
118 cached_app_items: RwLock::new(cached_apps),
119 last_stale_prune: Mutex::new(None),
120 stale_prune_cursor: Mutex::new(0),
121 })
122 }
123
124 pub fn with_providers(self, providers: Vec<Box<dyn DiscoveryProvider>>) -> Self {
125 self.replace_providers(providers);
126 self
127 }
128
129 pub fn with_runtime_providers(self) -> Self {
130 let providers = runtime_providers_from_config(&self.config_snapshot());
131 self.replace_providers(providers);
132 self
133 }
134
135 pub fn reconfigure_runtime_providers(&self, cfg: &Config) -> Result<(), ServiceError> {
136 validate(cfg).map_err(ServiceError::Config)?;
137 let providers = runtime_providers_from_config(cfg);
138 self.replace_runtime_config(cfg.clone());
139 self.replace_providers(providers);
140 Ok(())
141 }
142
143 fn replace_providers(&self, providers: Vec<Box<dyn DiscoveryProvider>>) {
144 match self.providers.write() {
145 Ok(mut guard) => *guard = providers,
146 Err(poisoned) => {
147 let mut guard = poisoned.into_inner();
148 *guard = providers;
149 }
150 }
151 }
152
153 fn runtime_providers(&self) -> Vec<String> {
154 match self.providers.read() {
155 Ok(guard) => guard
156 .iter()
157 .map(|p| p.provider_name().to_string())
158 .collect(),
159 Err(poisoned) => poisoned
160 .into_inner()
161 .iter()
162 .map(|p| p.provider_name().to_string())
163 .collect(),
164 }
165 }
166
167 pub fn configured_provider_names(&self) -> Vec<String> {
168 self.runtime_providers()
169 }
170
171 fn config_snapshot(&self) -> Config {
172 match self.config.read() {
173 Ok(guard) => guard.clone(),
174 Err(poisoned) => poisoned.into_inner().clone(),
175 }
176 }
177
178 fn replace_runtime_config(&self, next: Config) {
179 match self.config.write() {
180 Ok(mut guard) => *guard = next,
181 Err(poisoned) => {
182 let mut guard = poisoned.into_inner();
183 *guard = next;
184 }
185 }
186 }
187}
188
189fn runtime_providers_from_config(config: &Config) -> Vec<Box<dyn DiscoveryProvider>> {
190 let mut providers: Vec<Box<dyn DiscoveryProvider>> = Vec::new();
191 providers.push(Box::new(StartMenuAppDiscoveryProvider::default()));
192 providers.push(Box::new(
195 FileSystemDiscoveryProvider::with_options(
196 config.discovery_roots.clone(),
197 5,
198 config.discovery_exclude_roots.clone(),
199 config.windows_search_enabled,
200 config.windows_search_fallback_filesystem,
201 config.show_files,
202 config.show_folders,
203 )
204 .with_index_limits(
205 config.index_max_items_total as usize,
206 config.index_max_items_per_root as usize,
207 ),
208 ));
209 providers
210}
211
212impl CoreService {
213 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchItem>, ServiceError> {
214 self.search_with_filter(query, limit, &SearchFilter::default())
215 }
216
217 pub fn search_with_filter(
218 &self,
219 query: &str,
220 limit: usize,
221 filter: &SearchFilter,
222 ) -> Result<Vec<SearchItem>, ServiceError> {
223 self.search_with_filter_internal(query, limit, filter, true)
224 }
225
226 pub fn search_with_filter_uncapped(
227 &self,
228 query: &str,
229 limit: usize,
230 filter: &SearchFilter,
231 ) -> Result<Vec<SearchItem>, ServiceError> {
232 self.search_with_filter_internal(query, limit, filter, false)
233 }
234
235 fn search_with_filter_internal(
236 &self,
237 query: &str,
238 limit: usize,
239 filter: &SearchFilter,
240 clamp_to_config_max: bool,
241 ) -> Result<Vec<SearchItem>, ServiceError> {
242 self.prune_stale_items_if_due()?;
243 let config_snapshot = self.config_snapshot();
244
245 let effective_limit = if clamp_to_config_max {
246 if limit == 0 {
247 config_snapshot.max_results as usize
248 } else {
249 limit.min(config_snapshot.max_results as usize)
250 }
251 } else if limit == 0 {
252 config_snapshot.max_results as usize
253 } else {
254 limit
255 };
256
257 if should_use_app_cache(filter) {
258 let guard = match self.cached_app_items.read() {
259 Ok(guard) => guard,
260 Err(poisoned) => poisoned.into_inner(),
261 };
262 let query_boosts = self.query_personalization_boosts(query, filter.mode)?;
263 return Ok(crate::search::search_with_filter_with_boosts(
264 &guard,
265 query,
266 effective_limit,
267 filter,
268 Some(&query_boosts),
269 ));
270 }
271
272 let mut seed_items = {
273 let guard = match self.cached_items.read() {
274 Ok(guard) => guard,
275 Err(poisoned) => poisoned.into_inner(),
276 };
277 guard.clone()
278 };
279
280 if should_use_db_query_seed(filter, query) {
281 let db_seed_limit = (config_snapshot.index_max_items_per_query_seed as usize).max(250);
282 let db_candidates = self.db_query_candidates(query, filter.mode, db_seed_limit)?;
283 merge_seed_candidates(&mut seed_items, db_candidates);
284 }
285
286 let query_boosts = self.query_personalization_boosts(query, filter.mode)?;
287 Ok(crate::search::search_with_filter_with_boosts(
288 &seed_items,
289 query,
290 effective_limit,
291 filter,
292 Some(&query_boosts),
293 ))
294 }
295
296 pub fn cached_items_snapshot(&self) -> Vec<SearchItem> {
297 let guard = match self.cached_items.read() {
298 Ok(guard) => guard,
299 Err(poisoned) => poisoned.into_inner(),
300 };
301 guard.clone()
302 }
303
304 pub fn cached_items_len(&self) -> usize {
305 self.cached_len()
306 }
307
308 pub fn reload_cache_from_store(&self) -> Result<usize, ServiceError> {
309 self.refresh_cache_from_store()?;
310 Ok(self.cached_len())
311 }
312
313 pub fn launch(&self, target: LaunchTarget<'_>) -> Result<(), ServiceError> {
314 self.launch_with_query_context(target, None, None)
315 }
316
317 pub fn launch_with_query_context(
318 &self,
319 target: LaunchTarget<'_>,
320 query: Option<&str>,
321 mode: Option<SearchMode>,
322 ) -> Result<(), ServiceError> {
323 match target {
324 LaunchTarget::Path(path) => launch_path(path).map_err(ServiceError::from),
325 LaunchTarget::Id(id) => {
326 let item = index_store::get_item(&self.db, id)?
327 .ok_or_else(|| ServiceError::ItemNotFound(id.to_string()))?;
328 match launch_path(&item.path) {
329 Ok(()) => {
330 self.record_successful_launch(&item)?;
331 if let (Some(query), Some(mode)) = (query, mode) {
332 self.record_query_selection_hint(query, mode, &item.id)?;
333 }
334 Ok(())
335 }
336 Err(error) if should_prune_after_launch_error(&item, &error) => {
337 index_store::delete_item(&self.db, &item.id)?;
338 self.remove_cached_item_by_id(&item.id);
339 Err(ServiceError::from(error))
340 }
341 Err(error) => Err(ServiceError::from(error)),
342 }
343 }
344 }
345 }
346
347 pub fn record_query_selection_hint(
348 &self,
349 query: &str,
350 mode: SearchMode,
351 item_id: &str,
352 ) -> Result<(), ServiceError> {
353 let query_norm = crate::model::normalize_for_search(query);
354 if query_norm.is_empty() {
355 return Ok(());
356 }
357 if matches!(mode, SearchMode::Actions | SearchMode::Clipboard) {
358 return Ok(());
359 }
360 index_store::record_query_selection(
361 &self.db,
362 &query_norm,
363 search_mode_key(mode),
364 item_id,
365 now_epoch_secs(),
366 )?;
367 Ok(())
368 }
369
370 pub fn rebuild_index(&self) -> Result<usize, ServiceError> {
371 let report = self.rebuild_index_incremental_with_report()?;
372 Ok(report.indexed_total)
373 }
374
375 pub fn rebuild_index_with_report(&self) -> Result<IndexRefreshReport, ServiceError> {
376 self.rebuild_index_internal(false)
377 }
378
379 pub fn rebuild_index_incremental_with_report(
380 &self,
381 ) -> Result<IndexRefreshReport, ServiceError> {
382 self.rebuild_index_internal(true)
383 }
384
385 fn rebuild_index_internal(
386 &self,
387 incremental_mode: bool,
388 ) -> Result<IndexRefreshReport, ServiceError> {
389 let providers_guard = match self.providers.read() {
390 Ok(guard) => guard,
391 Err(poisoned) => poisoned.into_inner(),
392 };
393 if providers_guard.is_empty() {
394 self.refresh_cache_from_store()?;
395 return Ok(IndexRefreshReport {
396 indexed_total: self.cached_len(),
397 discovered_total: 0,
398 upserted_total: 0,
399 removed_total: 0,
400 providers: Vec::new(),
401 });
402 }
403
404 let mut existing_items = index_store::list_items(&self.db)?;
405 let mut existing_by_id: HashMap<String, SearchItem> = existing_items
406 .drain(..)
407 .map(|item| (item.id.clone(), item))
408 .collect();
409
410 let mut discovered_total = 0_usize;
411 let mut upserted_total = 0_usize;
412 let mut removed_total = 0_usize;
413 let mut provider_reports = Vec::with_capacity(providers_guard.len());
414 let now_epoch_secs = now_epoch_secs();
415
416 for provider in providers_guard.iter() {
417 let started = Instant::now();
418 let provider_name = provider.provider_name().to_string();
419 let provider_stamp = if incremental_mode {
420 provider.change_stamp()
421 } else {
422 None
423 };
424 if incremental_mode
425 && should_skip_provider_discovery(
426 &self.db,
427 &provider_name,
428 provider_stamp.as_deref(),
429 now_epoch_secs,
430 )?
431 {
432 provider_reports.push(ProviderRefreshReport {
433 provider: provider_name.clone(),
434 discovered: 0,
435 upserted: 0,
436 removed: 0,
437 skipped: true,
438 elapsed_ms: started.elapsed().as_millis(),
439 });
440 log_provider_freshness_status(&self.db, &provider_name, now_epoch_secs, true)?;
441 continue;
442 }
443
444 let discovered = provider.discover()?;
445 let discovered_count = discovered.len();
446 discovered_total += discovered_count;
447
448 let mut upserted = 0_usize;
449 let mut discovered_ids = HashSet::with_capacity(discovered_count);
450
451 for mut item in discovered {
452 if let Some(previous) = existing_by_id.get(&item.id) {
453 if item.use_count == 0 {
456 item.use_count = previous.use_count;
457 }
458 if item.last_accessed_epoch_secs <= 0 {
459 item.last_accessed_epoch_secs = previous.last_accessed_epoch_secs;
460 }
461 }
462
463 discovered_ids.insert(item.id.clone());
464 let changed = existing_by_id
465 .get(&item.id)
466 .map(|previous| previous != &item)
467 .unwrap_or(true);
468 if changed {
469 index_store::upsert_item(&self.db, &item)?;
470 upserted += 1;
471 upserted_total += 1;
472 }
473 existing_by_id.insert(item.id.clone(), item);
474 }
475
476 let removable_ids: Vec<String> = existing_by_id
479 .values()
480 .filter(|item| provider_manages_kind(provider.provider_name(), &item.kind))
481 .filter(|item| !discovered_ids.contains(&item.id))
482 .map(|item| item.id.clone())
483 .collect();
484
485 for id in &removable_ids {
486 index_store::delete_item(&self.db, id)?;
487 existing_by_id.remove(id);
488 }
489
490 removed_total += removable_ids.len();
491 provider_reports.push(ProviderRefreshReport {
492 provider: provider_name.clone(),
493 discovered: discovered_count,
494 upserted,
495 removed: removable_ids.len(),
496 skipped: false,
497 elapsed_ms: started.elapsed().as_millis(),
498 });
499
500 if incremental_mode {
501 persist_provider_discovery_state(
502 &self.db,
503 &provider_name,
504 provider_stamp.as_deref(),
505 now_epoch_secs,
506 )?;
507 log_provider_freshness_status(&self.db, &provider_name, now_epoch_secs, false)?;
508 }
509 }
510
511 self.refresh_cache_from_store()?;
512 let indexed_total = self.cached_len();
513 Ok(IndexRefreshReport {
514 indexed_total,
515 discovered_total,
516 upserted_total,
517 removed_total,
518 providers: provider_reports,
519 })
520 }
521
522 pub fn rebuild_index_incremental(&self) -> Result<usize, ServiceError> {
523 let report = self.rebuild_index_incremental_with_report()?;
524 Ok(report.indexed_total)
525 }
526
527 pub fn upsert_item(&self, item: &SearchItem) -> Result<(), ServiceError> {
528 index_store::upsert_item(&self.db, item)?;
529 self.upsert_cached_item(item.clone());
530 Ok(())
531 }
532
533 pub fn handle_command(&self, request: CoreRequest) -> Result<CoreResponse, ServiceError> {
534 match request {
535 CoreRequest::Search(search) => {
536 let results = self.search(&search.query, search.limit.unwrap_or(0))?;
537 Ok(CoreResponse::Search(SearchResponse {
538 results: results.into_iter().map(Into::into).collect(),
539 }))
540 }
541 CoreRequest::Launch(launch) => {
542 if let Some(id) = launch.id.as_deref() {
543 if !id.trim().is_empty() {
544 self.launch(LaunchTarget::Id(id))?;
545 return Ok(CoreResponse::Launch(LaunchResponse { launched: true }));
546 }
547 }
548
549 if let Some(path) = launch.path.as_deref() {
550 if !path.trim().is_empty() {
551 self.launch(LaunchTarget::Path(path))?;
552 return Ok(CoreResponse::Launch(LaunchResponse { launched: true }));
553 }
554 }
555
556 Err(ServiceError::InvalidRequest(
557 "launch requires non-empty id or path".into(),
558 ))
559 }
560 }
561 }
562}
563
564impl CoreService {
565 fn cached_len(&self) -> usize {
566 match self.cached_items.read() {
567 Ok(guard) => guard.len(),
568 Err(poisoned) => poisoned.into_inner().len(),
569 }
570 }
571
572 fn db_query_candidates(
573 &self,
574 query: &str,
575 mode: SearchMode,
576 limit: usize,
577 ) -> Result<Vec<SearchItem>, ServiceError> {
578 if limit == 0 {
579 return Ok(Vec::new());
580 }
581 let trimmed = query.trim();
582 if trimmed.is_empty() {
583 return Ok(Vec::new());
584 }
585 if matches!(mode, SearchMode::Actions | SearchMode::Clipboard) {
586 return Ok(Vec::new());
587 }
588
589 let sql = match mode {
590 SearchMode::Files => {
591 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs
592 FROM item
593 WHERE (title LIKE ?1 COLLATE NOCASE OR path LIKE ?1 COLLATE NOCASE)
594 AND kind IN ('file', 'folder')
595 ORDER BY use_count DESC, last_accessed_epoch_secs DESC, id
596 LIMIT ?2"
597 }
598 SearchMode::Apps => {
599 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs
600 FROM item
601 WHERE (title LIKE ?1 COLLATE NOCASE OR path LIKE ?1 COLLATE NOCASE)
602 AND kind = 'app'
603 ORDER BY use_count DESC, last_accessed_epoch_secs DESC, id
604 LIMIT ?2"
605 }
606 SearchMode::All => {
607 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs
608 FROM item
609 WHERE title LIKE ?1 COLLATE NOCASE OR path LIKE ?1 COLLATE NOCASE
610 ORDER BY use_count DESC, last_accessed_epoch_secs DESC, id
611 LIMIT ?2"
612 }
613 SearchMode::Actions | SearchMode::Clipboard => unreachable!(),
614 };
615
616 let pattern = format!("%{trimmed}%");
617 let mut stmt = self
618 .db
619 .prepare(sql)
620 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
621 let mut rows = stmt
622 .query(params![pattern, limit as i64])
623 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
624 let mut out = Vec::new();
625 while let Some(row) = rows
626 .next()
627 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?
628 {
629 let id: String = row
630 .get(0)
631 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
632 let kind: String = row
633 .get(1)
634 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
635 let title: String = row
636 .get(2)
637 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
638 let path: String = row
639 .get(3)
640 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
641 let subtitle: String = row
642 .get(4)
643 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
644 let use_count: u32 = row
645 .get(5)
646 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
647 let last_accessed_epoch_secs: i64 = row
648 .get(6)
649 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
650 out.push(SearchItem::from_owned_with_subtitle(
651 id,
652 kind,
653 title,
654 path,
655 subtitle,
656 use_count,
657 last_accessed_epoch_secs,
658 ));
659 }
660 Ok(out)
661 }
662
663 fn query_personalization_boosts(
664 &self,
665 query: &str,
666 mode: SearchMode,
667 ) -> Result<HashMap<String, i64>, ServiceError> {
668 let query_norm = crate::model::normalize_for_search(query);
669 if query_norm.is_empty() || matches!(mode, SearchMode::Actions | SearchMode::Clipboard) {
670 return Ok(HashMap::new());
671 }
672
673 let rows =
674 index_store::list_query_selections(&self.db, &query_norm, search_mode_key(mode), 64)?;
675 let now = now_epoch_secs();
676 let mut boosts = HashMap::with_capacity(rows.len());
677 for (item_id, selected_count, last_selected_epoch_secs) in rows {
678 let usage_boost = (selected_count.min(12) as i64) * 280;
679 let recency_boost = query_memory_recency_boost(last_selected_epoch_secs, now);
680 let total = (usage_boost + recency_boost).clamp(0, 5_000);
681 if total > 0 {
682 boosts.insert(item_id, total);
683 }
684 }
685 Ok(boosts)
686 }
687
688 fn refresh_cache_from_store(&self) -> Result<(), ServiceError> {
689 let config_snapshot = self.config_snapshot();
690 let latest_full = index_store::list_items(&self.db)?;
691 let latest_apps = collect_app_items(&latest_full);
692 let summary = cache_compaction_summary(&latest_full, &config_snapshot);
693 let latest = compact_cached_items(&latest_full, &config_snapshot);
694 if summary.dropped_total > 0 {
695 crate::logging::info(&format!(
696 "[nex] cache_compaction input_total={} retained={} dropped={} retained_apps={} retained_file_folders={} retained_other={} effective_file_seed_cap={} broad_root_mode={} active_memory_target_mb={}",
697 summary.input_total,
698 summary.retained_total,
699 summary.dropped_total,
700 summary.retained_apps,
701 summary.retained_file_folders,
702 summary.retained_other,
703 summary.effective_file_seed_cap,
704 summary.broad_root_mode,
705 summary.active_memory_target_mb
706 ));
707 }
708 match self.cached_items.write() {
709 Ok(mut guard) => {
710 *guard = latest;
711 }
712 Err(poisoned) => {
713 let mut guard = poisoned.into_inner();
714 *guard = latest;
715 }
716 }
717 match self.cached_app_items.write() {
718 Ok(mut guard) => {
719 *guard = latest_apps;
720 }
721 Err(poisoned) => {
722 let mut guard = poisoned.into_inner();
723 *guard = latest_apps;
724 }
725 }
726 Ok(())
727 }
728
729 fn upsert_cached_item(&self, item: SearchItem) {
730 let item_for_apps = item.clone();
731 let item_id = item.id.clone();
732 let is_app = item.kind.eq_ignore_ascii_case("app");
733 match self.cached_items.write() {
734 Ok(mut guard) => upsert_cached_item_inner(&mut guard, item),
735 Err(poisoned) => {
736 let mut guard = poisoned.into_inner();
737 upsert_cached_item_inner(&mut guard, item);
738 }
739 }
740 match self.cached_app_items.write() {
741 Ok(mut guard) => {
742 if is_app {
743 upsert_cached_item_inner(&mut guard, item_for_apps);
744 } else {
745 guard.retain(|entry| entry.id != item_id);
746 }
747 }
748 Err(poisoned) => {
749 let mut guard = poisoned.into_inner();
750 if is_app {
751 upsert_cached_item_inner(&mut guard, item_for_apps);
752 } else {
753 guard.retain(|entry| entry.id != item_id);
754 }
755 }
756 }
757 }
758
759 fn remove_cached_item_by_id(&self, id: &str) {
760 match self.cached_items.write() {
761 Ok(mut guard) => guard.retain(|entry| entry.id != id),
762 Err(poisoned) => {
763 let mut guard = poisoned.into_inner();
764 guard.retain(|entry| entry.id != id);
765 }
766 }
767 match self.cached_app_items.write() {
768 Ok(mut guard) => guard.retain(|entry| entry.id != id),
769 Err(poisoned) => {
770 let mut guard = poisoned.into_inner();
771 guard.retain(|entry| entry.id != id);
772 }
773 }
774 }
775
776 fn record_successful_launch(&self, item: &SearchItem) -> Result<(), ServiceError> {
777 let now = now_epoch_secs();
778 let mut updated = item.clone();
779 updated.use_count = updated.use_count.saturating_add(1);
780 updated.last_accessed_epoch_secs = now.max(updated.last_accessed_epoch_secs);
781
782 index_store::upsert_item(&self.db, &updated)?;
783 self.upsert_cached_item(updated);
784 Ok(())
785 }
786
787 fn prune_stale_items_if_due(&self) -> Result<(), ServiceError> {
788 let should_prune = {
789 let mut last = match self.last_stale_prune.lock() {
790 Ok(guard) => guard,
791 Err(poisoned) => poisoned.into_inner(),
792 };
793 let now = Instant::now();
794 match *last {
795 Some(prev) if now.duration_since(prev) < STALE_PRUNE_INTERVAL => false,
796 _ => {
797 *last = Some(now);
798 true
799 }
800 }
801 };
802
803 if !should_prune {
804 return Ok(());
805 }
806
807 let candidates = self.stale_prune_candidates(STALE_PRUNE_BATCH_SIZE);
808 let stale_ids: Vec<String> = candidates
809 .iter()
810 .filter(|item| is_stale_index_entry(item))
811 .map(|item| item.id.clone())
812 .collect();
813
814 if stale_ids.is_empty() {
815 return Ok(());
816 }
817
818 for stale_id in &stale_ids {
819 index_store::delete_item(&self.db, stale_id)?;
820 }
821
822 match self.cached_items.write() {
823 Ok(mut guard) => {
824 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
825 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
826 }
827 Err(poisoned) => {
828 let mut guard = poisoned.into_inner();
829 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
830 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
831 }
832 }
833 match self.cached_app_items.write() {
834 Ok(mut guard) => {
835 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
836 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
837 }
838 Err(poisoned) => {
839 let mut guard = poisoned.into_inner();
840 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
841 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
842 }
843 }
844
845 crate::logging::info(&format!(
846 "[nex] stale_prune scanned={} removed={} cached_items_remaining={}",
847 candidates.len(),
848 stale_ids.len(),
849 self.cached_len()
850 ));
851
852 Ok(())
853 }
854
855 fn stale_prune_candidates(&self, batch_size: usize) -> Vec<SearchItem> {
856 if batch_size == 0 {
857 return Vec::new();
858 }
859
860 let mut cursor = match self.stale_prune_cursor.lock() {
861 Ok(guard) => guard,
862 Err(poisoned) => poisoned.into_inner(),
863 };
864 let guard = match self.cached_items.read() {
865 Ok(guard) => guard,
866 Err(poisoned) => poisoned.into_inner(),
867 };
868
869 if guard.is_empty() {
870 *cursor = 0;
871 return Vec::new();
872 }
873
874 let len = guard.len();
875 let start = (*cursor).min(len - 1);
876 let take = batch_size.min(len);
877 let mut out = Vec::with_capacity(take);
878 for offset in 0..take {
879 let idx = (start + offset) % len;
880 out.push(guard[idx].clone());
881 }
882 *cursor = (start + take) % len;
883 out
884 }
885}
886
887fn upsert_cached_item_inner(cached: &mut Vec<SearchItem>, item: SearchItem) {
888 if let Some(existing) = cached.iter_mut().find(|entry| entry.id == item.id) {
889 *existing = item;
890 } else {
891 cached.push(item);
892 }
893}
894
895fn collect_app_items(items: &[SearchItem]) -> Vec<SearchItem> {
896 items
897 .iter()
898 .filter(|item| item.kind.eq_ignore_ascii_case("app"))
899 .cloned()
900 .collect()
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
904struct CacheCompactionSummary {
905 input_total: usize,
906 retained_total: usize,
907 dropped_total: usize,
908 retained_apps: usize,
909 retained_file_folders: usize,
910 retained_other: usize,
911 effective_file_seed_cap: usize,
912 broad_root_mode: bool,
913 active_memory_target_mb: u16,
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
917struct ProviderFreshnessStatus {
918 last_scan_age_secs: i64,
919 reconcile_interval_secs: i64,
920 has_stamp: bool,
921}
922
923fn compact_cached_items(items: &[SearchItem], cfg: &Config) -> Vec<SearchItem> {
924 cache_compaction_summary(items, cfg).retain_items(items).0
925}
926
927fn cache_compaction_summary(items: &[SearchItem], cfg: &Config) -> CacheCompactionSummary {
928 let effective_file_seed_cap = effective_file_folder_cache_cap(cfg);
929 let broad_root_mode = broad_root_discovery_enabled(cfg);
930 let (retained_total, retained_apps, retained_file_folders, retained_other) =
931 retained_cache_counts(items, effective_file_seed_cap);
932
933 CacheCompactionSummary {
934 input_total: items.len(),
935 retained_total,
936 dropped_total: items.len().saturating_sub(retained_total),
937 retained_apps,
938 retained_file_folders,
939 retained_other,
940 effective_file_seed_cap,
941 broad_root_mode,
942 active_memory_target_mb: cfg.active_memory_target_mb,
943 }
944}
945
946impl CacheCompactionSummary {
947 fn retain_items(&self, items: &[SearchItem]) -> (Vec<SearchItem>, usize) {
948 let mut out = Vec::with_capacity(items.len().min(self.effective_file_seed_cap + 2048));
949 let mut file_or_folder_count = 0_usize;
950
951 for item in items {
952 if is_file_or_folder_kind(item.kind.as_str()) {
953 if file_or_folder_count >= self.effective_file_seed_cap {
954 continue;
955 }
956 file_or_folder_count += 1;
957 }
958 out.push(item.clone());
959 }
960
961 (out, file_or_folder_count)
962 }
963}
964
965fn retained_cache_counts(
966 items: &[SearchItem],
967 effective_file_seed_cap: usize,
968) -> (usize, usize, usize, usize) {
969 let mut retained_total = 0_usize;
970 let mut retained_apps = 0_usize;
971 let mut retained_file_folders = 0_usize;
972 let mut retained_other = 0_usize;
973 let mut file_or_folder_count = 0_usize;
974
975 for item in items {
976 if is_file_or_folder_kind(item.kind.as_str()) {
977 if file_or_folder_count >= effective_file_seed_cap {
978 continue;
979 }
980 file_or_folder_count += 1;
981 retained_file_folders += 1;
982 } else if item.kind.eq_ignore_ascii_case("app") {
983 retained_apps += 1;
984 } else {
985 retained_other += 1;
986 }
987 retained_total += 1;
988 }
989
990 (
991 retained_total,
992 retained_apps,
993 retained_file_folders,
994 retained_other,
995 )
996}
997
998fn effective_file_folder_cache_cap(cfg: &Config) -> usize {
999 let base_cap = (cfg.index_max_items_per_query_seed as usize).max(250);
1000 if !broad_root_discovery_enabled(cfg) {
1001 return base_cap;
1002 }
1003
1004 let memory_scaled_cap =
1005 ((cfg.active_memory_target_mb as usize).saturating_mul(8)).clamp(250, 1500);
1006 base_cap.min(memory_scaled_cap)
1007}
1008
1009fn broad_root_discovery_enabled(cfg: &Config) -> bool {
1010 if !(cfg.show_files || cfg.show_folders) {
1011 return false;
1012 }
1013 cfg.discovery_roots
1014 .iter()
1015 .any(|root| is_broad_discovery_root(root))
1016}
1017
1018fn is_broad_discovery_root(path: &Path) -> bool {
1019 let raw = path.to_string_lossy().trim().replace('/', "\\");
1020 if raw.is_empty() {
1021 return false;
1022 }
1023 if raw == "\\" || raw == "/" {
1024 return true;
1025 }
1026 if raw.len() == 2 {
1027 let bytes = raw.as_bytes();
1028 if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
1029 return true;
1030 }
1031 }
1032 if raw.len() == 3 {
1033 let bytes = raw.as_bytes();
1034 if bytes[1] == b':'
1035 && bytes[0].is_ascii_alphabetic()
1036 && (bytes[2] == b'\\' || bytes[2] == b'/')
1037 {
1038 return true;
1039 }
1040 }
1041 false
1042}
1043
1044fn is_file_or_folder_kind(kind: &str) -> bool {
1045 kind.eq_ignore_ascii_case("file") || kind.eq_ignore_ascii_case("folder")
1046}
1047
1048fn should_use_app_cache(filter: &SearchFilter) -> bool {
1049 filter.mode == SearchMode::Apps
1050}
1051
1052fn should_use_db_query_seed(filter: &SearchFilter, query: &str) -> bool {
1053 !query.trim().is_empty() && matches!(filter.mode, SearchMode::All | SearchMode::Files)
1054}
1055
1056fn search_mode_key(mode: SearchMode) -> &'static str {
1057 match mode {
1058 SearchMode::All => "all",
1059 SearchMode::Apps => "apps",
1060 SearchMode::Files => "files",
1061 SearchMode::Actions => "actions",
1062 SearchMode::Clipboard => "clipboard",
1063 }
1064}
1065
1066fn query_memory_recency_boost(last_selected_epoch_secs: i64, now_epoch_secs: i64) -> i64 {
1067 if last_selected_epoch_secs <= 0 || now_epoch_secs <= 0 {
1068 return 0;
1069 }
1070 let age_secs = now_epoch_secs.saturating_sub(last_selected_epoch_secs);
1071 if age_secs <= 86_400 {
1072 900
1073 } else if age_secs <= 7 * 86_400 {
1074 550
1075 } else if age_secs <= 30 * 86_400 {
1076 220
1077 } else {
1078 0
1079 }
1080}
1081
1082fn merge_seed_candidates(seed_items: &mut Vec<SearchItem>, extra: Vec<SearchItem>) {
1083 if extra.is_empty() {
1084 return;
1085 }
1086 let mut seen: HashSet<String> = seed_items.iter().map(|item| item.id.clone()).collect();
1087 for item in extra {
1088 if seen.insert(item.id.clone()) {
1089 seed_items.push(item);
1090 }
1091 }
1092}
1093
1094fn is_stale_index_entry(item: &SearchItem) -> bool {
1095 if !(item.kind.eq_ignore_ascii_case("app")
1096 || item.kind.eq_ignore_ascii_case("file")
1097 || item.kind.eq_ignore_ascii_case("folder"))
1098 {
1099 return false;
1100 }
1101
1102 let path = item.path.trim();
1103 if path.is_empty() {
1104 return false;
1105 }
1106 if path.contains("://") {
1107 return false;
1108 }
1109 if !looks_like_filesystem_path(path) {
1110 return false;
1111 }
1112
1113 !Path::new(path).exists()
1114}
1115
1116fn looks_like_filesystem_path(path: &str) -> bool {
1117 if path.starts_with('/') || path.starts_with('\\') {
1118 return true;
1119 }
1120
1121 let bytes = path.as_bytes();
1122 bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
1123}
1124
1125fn provider_manages_kind(provider_name: &str, kind: &str) -> bool {
1126 let kind = kind.to_ascii_lowercase();
1127 match provider_name {
1128 "start-menu-apps" | "app" => kind == "app",
1129 "filesystem" | "file" => kind == "file" || kind == "folder",
1130 _ => false,
1131 }
1132}
1133
1134fn should_prune_after_launch_error(item: &SearchItem, error: &LaunchError) -> bool {
1135 let is_filesystem_target = looks_like_filesystem_path(item.path.trim());
1136 match error {
1137 LaunchError::MissingPath(_) => {
1138 is_filesystem_target
1139 && (item.kind.eq_ignore_ascii_case("app")
1140 || item.kind.eq_ignore_ascii_case("file")
1141 || item.kind.eq_ignore_ascii_case("folder"))
1142 }
1143 LaunchError::LaunchFailed {
1144 code: Some(code), ..
1145 } => {
1146 (*code == 2 || *code == 3)
1148 && is_filesystem_target
1149 && (item.kind.eq_ignore_ascii_case("app")
1150 || item.kind.eq_ignore_ascii_case("file")
1151 || item.kind.eq_ignore_ascii_case("folder"))
1152 }
1153 LaunchError::LaunchFailed { .. } | LaunchError::EmptyPath => false,
1154 }
1155}
1156
1157fn should_skip_provider_discovery(
1158 db: &Connection,
1159 provider_name: &str,
1160 stamp: Option<&str>,
1161 now_epoch_secs: i64,
1162) -> Result<bool, ServiceError> {
1163 let Some(stamp) = stamp else {
1164 return Ok(false);
1165 };
1166
1167 let stamp_key = provider_stamp_meta_key(provider_name);
1168 let previous_stamp = index_store::get_meta(db, &stamp_key)?;
1169 if previous_stamp.as_deref() != Some(stamp) {
1170 return Ok(false);
1171 }
1172
1173 let last_scan_key = provider_last_scan_meta_key(provider_name);
1174 let last_scan_epoch = index_store::get_meta(db, &last_scan_key)?
1175 .and_then(|value| value.parse::<i64>().ok())
1176 .unwrap_or(0);
1177 if last_scan_epoch <= 0 {
1178 return Ok(false);
1179 }
1180
1181 Ok(now_epoch_secs.saturating_sub(last_scan_epoch) < PROVIDER_RECONCILE_INTERVAL_SECS)
1182}
1183
1184fn persist_provider_discovery_state(
1185 db: &Connection,
1186 provider_name: &str,
1187 stamp: Option<&str>,
1188 now_epoch_secs: i64,
1189) -> Result<(), ServiceError> {
1190 if let Some(stamp) = stamp {
1191 let stamp_key = provider_stamp_meta_key(provider_name);
1192 index_store::set_meta(db, &stamp_key, stamp)?;
1193 }
1194
1195 let last_scan_key = provider_last_scan_meta_key(provider_name);
1196 index_store::set_meta(db, &last_scan_key, &now_epoch_secs.to_string())?;
1197 Ok(())
1198}
1199
1200fn provider_stamp_meta_key(provider_name: &str) -> String {
1201 format!("provider_stamp:{provider_name}")
1202}
1203
1204fn provider_last_scan_meta_key(provider_name: &str) -> String {
1205 format!("provider_last_scan_epoch:{provider_name}")
1206}
1207
1208fn load_provider_freshness_status(
1209 db: &Connection,
1210 provider_name: &str,
1211 now_epoch_secs: i64,
1212) -> Result<ProviderFreshnessStatus, ServiceError> {
1213 let last_scan_key = provider_last_scan_meta_key(provider_name);
1214 let last_scan_epoch = index_store::get_meta(db, &last_scan_key)?
1215 .and_then(|value| value.parse::<i64>().ok())
1216 .unwrap_or(0);
1217 let stamp_key = provider_stamp_meta_key(provider_name);
1218 let has_stamp = index_store::get_meta(db, &stamp_key)?
1219 .map(|value| !value.trim().is_empty())
1220 .unwrap_or(false);
1221
1222 Ok(ProviderFreshnessStatus {
1223 last_scan_age_secs: if last_scan_epoch > 0 {
1224 now_epoch_secs.saturating_sub(last_scan_epoch).max(0)
1225 } else {
1226 -1
1227 },
1228 reconcile_interval_secs: PROVIDER_RECONCILE_INTERVAL_SECS,
1229 has_stamp,
1230 })
1231}
1232
1233fn log_provider_freshness_status(
1234 db: &Connection,
1235 provider_name: &str,
1236 now_epoch_secs: i64,
1237 skipped: bool,
1238) -> Result<(), ServiceError> {
1239 let freshness = load_provider_freshness_status(db, provider_name, now_epoch_secs)?;
1240 crate::logging::info(&format!(
1241 "[nex] provider_freshness name={} skipped={} last_scan_age_secs={} reconcile_interval_secs={} has_stamp={}",
1242 provider_name,
1243 skipped,
1244 freshness.last_scan_age_secs,
1245 freshness.reconcile_interval_secs,
1246 freshness.has_stamp
1247 ));
1248 Ok(())
1249}
1250
1251fn now_epoch_secs() -> i64 {
1252 SystemTime::now()
1253 .duration_since(UNIX_EPOCH)
1254 .map(|value| value.as_secs() as i64)
1255 .unwrap_or(0)
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::{
1261 broad_root_discovery_enabled, cache_compaction_summary, compact_cached_items,
1262 effective_file_folder_cache_cap, CoreService,
1263 };
1264 use crate::config::{Config, SearchMode};
1265 use crate::index_store::open_memory;
1266 use crate::model::SearchItem;
1267 use crate::search::SearchFilter;
1268 use std::path::PathBuf;
1269 use std::time::{SystemTime, UNIX_EPOCH};
1270
1271 #[test]
1272 fn app_mode_search_excludes_non_app_items() {
1273 let unique = SystemTime::now()
1274 .duration_since(UNIX_EPOCH)
1275 .expect("clock should be valid")
1276 .as_nanos();
1277 let app_path = std::env::temp_dir().join(format!("nex-app-cache-app-{unique}.tmp"));
1278 let file_path = std::env::temp_dir().join(format!("nex-app-cache-file-{unique}.tmp"));
1279 std::fs::write(&app_path, b"ok").expect("app path should exist");
1280 std::fs::write(&file_path, b"ok").expect("file path should exist");
1281
1282 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
1283 .expect("service should initialize");
1284 service
1285 .upsert_item(&SearchItem::new(
1286 "app-vivaldi",
1287 "app",
1288 "Vivaldi",
1289 app_path.to_string_lossy().as_ref(),
1290 ))
1291 .expect("app should upsert");
1292 service
1293 .upsert_item(&SearchItem::new(
1294 "file-video",
1295 "file",
1296 "video notes",
1297 file_path.to_string_lossy().as_ref(),
1298 ))
1299 .expect("file should upsert");
1300
1301 let filter = SearchFilter {
1302 mode: SearchMode::Apps,
1303 ..SearchFilter::default()
1304 };
1305 let results = service
1306 .search_with_filter("v", 20, &filter)
1307 .expect("search should succeed");
1308 assert!(results.iter().any(|item| item.id == "app-vivaldi"));
1309 assert!(!results.iter().any(|item| item.id == "file-video"));
1310
1311 std::fs::remove_file(app_path).expect("app temp file should be removed");
1312 std::fs::remove_file(file_path).expect("file temp file should be removed");
1313 }
1314
1315 #[test]
1316 fn app_cache_tracks_kind_changes() {
1317 let unique = SystemTime::now()
1318 .duration_since(UNIX_EPOCH)
1319 .expect("clock should be valid")
1320 .as_nanos();
1321 let path = std::env::temp_dir().join(format!("nex-app-cache-kind-{unique}.tmp"));
1322 std::fs::write(&path, b"ok").expect("temp file should exist");
1323
1324 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
1325 .expect("service should initialize");
1326 service
1327 .upsert_item(&SearchItem::new(
1328 "entry-1",
1329 "app",
1330 "Visual Studio Code",
1331 path.to_string_lossy().as_ref(),
1332 ))
1333 .expect("app should upsert");
1334 service
1335 .upsert_item(&SearchItem::new(
1336 "entry-1",
1337 "file",
1338 "Visual Studio Code.txt",
1339 path.to_string_lossy().as_ref(),
1340 ))
1341 .expect("file should replace app");
1342
1343 let filter = SearchFilter {
1344 mode: SearchMode::Apps,
1345 ..SearchFilter::default()
1346 };
1347 let results = service
1348 .search_with_filter("visual", 20, &filter)
1349 .expect("search should succeed");
1350 assert!(!results.iter().any(|item| item.id == "entry-1"));
1351
1352 std::fs::remove_file(path).expect("temp file should be removed");
1353 }
1354
1355 #[test]
1356 fn uncapped_search_respects_requested_limit_above_config_max() {
1357 let mut cfg = Config::default();
1358 cfg.max_results = 5;
1359 let service = CoreService::with_connection(cfg, open_memory().unwrap())
1360 .expect("service should initialize");
1361
1362 let mut temp_paths = Vec::new();
1363 for idx in 0..25 {
1364 let path = std::env::temp_dir().join(format!("nex-uncapped-{idx}.tmp"));
1365 std::fs::write(&path, b"ok").expect("temp file should exist");
1366 temp_paths.push(path.clone());
1367 service
1368 .upsert_item(&SearchItem::new(
1369 &format!("app-{idx:02}"),
1370 "app",
1371 &format!("Alpha App {idx:02}"),
1372 path.to_string_lossy().as_ref(),
1373 ))
1374 .expect("item should upsert");
1375 }
1376
1377 let filter = SearchFilter::default();
1378 let capped = service
1379 .search_with_filter("alpha", 20, &filter)
1380 .expect("capped search should succeed");
1381 let uncapped = service
1382 .search_with_filter_uncapped("alpha", 20, &filter)
1383 .expect("uncapped search should succeed");
1384
1385 assert_eq!(capped.len(), 5);
1386 assert!(uncapped.len() >= 20);
1387
1388 for path in temp_paths {
1389 let _ = std::fs::remove_file(path);
1390 }
1391 }
1392
1393 #[test]
1394 fn broad_root_mode_detects_drive_roots() {
1395 let mut cfg = Config::default();
1396 cfg.show_files = true;
1397 cfg.show_folders = true;
1398 cfg.discovery_roots = vec![PathBuf::from(r"C:\")];
1399 assert!(broad_root_discovery_enabled(&cfg));
1400 }
1401
1402 #[test]
1403 fn broad_root_mode_ignores_default_profile_roots() {
1404 let cfg = Config::default();
1405 assert!(!broad_root_discovery_enabled(&cfg));
1406 }
1407
1408 #[test]
1409 fn broad_root_mode_reduces_file_folder_cache_cap() {
1410 let mut cfg = Config::default();
1411 cfg.show_files = true;
1412 cfg.show_folders = true;
1413 cfg.discovery_roots = vec![PathBuf::from(r"C:\")];
1414 cfg.index_max_items_per_query_seed = 5_000;
1415 cfg.active_memory_target_mb = 72;
1416
1417 assert_eq!(effective_file_folder_cache_cap(&cfg), 576);
1418 }
1419
1420 #[test]
1421 fn cache_compaction_keeps_apps_but_tightens_files_for_broad_roots() {
1422 let mut cfg = Config::default();
1423 cfg.show_files = true;
1424 cfg.show_folders = true;
1425 cfg.discovery_roots = vec![PathBuf::from(r"C:\")];
1426 cfg.index_max_items_per_query_seed = 5_000;
1427 cfg.active_memory_target_mb = 72;
1428
1429 let mut items = Vec::new();
1430 for idx in 0..20 {
1431 items.push(SearchItem::new(
1432 &format!("app-{idx}"),
1433 "app",
1434 &format!("App {idx}"),
1435 &format!(r"C:\Apps\App{idx}.lnk"),
1436 ));
1437 }
1438 for idx in 0..700 {
1439 items.push(SearchItem::new(
1440 &format!("file-{idx}"),
1441 "file",
1442 &format!("File {idx}"),
1443 &format!(r"C:\Data\File{idx}.txt"),
1444 ));
1445 }
1446
1447 let summary = cache_compaction_summary(&items, &cfg);
1448 let retained = compact_cached_items(&items, &cfg);
1449
1450 assert!(summary.broad_root_mode);
1451 assert_eq!(summary.retained_apps, 20);
1452 assert_eq!(summary.effective_file_seed_cap, 576);
1453 assert_eq!(summary.retained_file_folders, 576);
1454 assert_eq!(summary.retained_total, 596);
1455 assert_eq!(retained.len(), 596);
1456 assert_eq!(
1457 retained
1458 .iter()
1459 .filter(|item| item.kind.eq_ignore_ascii_case("app"))
1460 .count(),
1461 20
1462 );
1463 }
1464}