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(
441 &self.db,
442 &provider_name,
443 now_epoch_secs,
444 true,
445 )?;
446 continue;
447 }
448
449 let discovered = provider.discover()?;
450 let discovered_count = discovered.len();
451 discovered_total += discovered_count;
452
453 let mut upserted = 0_usize;
454 let mut discovered_ids = HashSet::with_capacity(discovered_count);
455
456 for mut item in discovered {
457 if let Some(previous) = existing_by_id.get(&item.id) {
458 if item.use_count == 0 {
461 item.use_count = previous.use_count;
462 }
463 if item.last_accessed_epoch_secs <= 0 {
464 item.last_accessed_epoch_secs = previous.last_accessed_epoch_secs;
465 }
466 }
467
468 discovered_ids.insert(item.id.clone());
469 let changed = existing_by_id
470 .get(&item.id)
471 .map(|previous| previous != &item)
472 .unwrap_or(true);
473 if changed {
474 index_store::upsert_item(&self.db, &item)?;
475 upserted += 1;
476 upserted_total += 1;
477 }
478 existing_by_id.insert(item.id.clone(), item);
479 }
480
481 let removable_ids: Vec<String> = existing_by_id
484 .values()
485 .filter(|item| provider_manages_kind(provider.provider_name(), &item.kind))
486 .filter(|item| !discovered_ids.contains(&item.id))
487 .map(|item| item.id.clone())
488 .collect();
489
490 for id in &removable_ids {
491 index_store::delete_item(&self.db, id)?;
492 existing_by_id.remove(id);
493 }
494
495 removed_total += removable_ids.len();
496 provider_reports.push(ProviderRefreshReport {
497 provider: provider_name.clone(),
498 discovered: discovered_count,
499 upserted,
500 removed: removable_ids.len(),
501 skipped: false,
502 elapsed_ms: started.elapsed().as_millis(),
503 });
504
505 if incremental_mode {
506 persist_provider_discovery_state(
507 &self.db,
508 &provider_name,
509 provider_stamp.as_deref(),
510 now_epoch_secs,
511 )?;
512 log_provider_freshness_status(
513 &self.db,
514 &provider_name,
515 now_epoch_secs,
516 false,
517 )?;
518 }
519 }
520
521 self.refresh_cache_from_store()?;
522 let indexed_total = self.cached_len();
523 Ok(IndexRefreshReport {
524 indexed_total,
525 discovered_total,
526 upserted_total,
527 removed_total,
528 providers: provider_reports,
529 })
530 }
531
532 pub fn rebuild_index_incremental(&self) -> Result<usize, ServiceError> {
533 let report = self.rebuild_index_incremental_with_report()?;
534 Ok(report.indexed_total)
535 }
536
537 pub fn upsert_item(&self, item: &SearchItem) -> Result<(), ServiceError> {
538 index_store::upsert_item(&self.db, item)?;
539 self.upsert_cached_item(item.clone());
540 Ok(())
541 }
542
543 pub fn handle_command(&self, request: CoreRequest) -> Result<CoreResponse, ServiceError> {
544 match request {
545 CoreRequest::Search(search) => {
546 let results = self.search(&search.query, search.limit.unwrap_or(0))?;
547 Ok(CoreResponse::Search(SearchResponse {
548 results: results.into_iter().map(Into::into).collect(),
549 }))
550 }
551 CoreRequest::Launch(launch) => {
552 if let Some(id) = launch.id.as_deref() {
553 if !id.trim().is_empty() {
554 self.launch(LaunchTarget::Id(id))?;
555 return Ok(CoreResponse::Launch(LaunchResponse { launched: true }));
556 }
557 }
558
559 if let Some(path) = launch.path.as_deref() {
560 if !path.trim().is_empty() {
561 self.launch(LaunchTarget::Path(path))?;
562 return Ok(CoreResponse::Launch(LaunchResponse { launched: true }));
563 }
564 }
565
566 Err(ServiceError::InvalidRequest(
567 "launch requires non-empty id or path".into(),
568 ))
569 }
570 }
571 }
572}
573
574impl CoreService {
575 fn cached_len(&self) -> usize {
576 match self.cached_items.read() {
577 Ok(guard) => guard.len(),
578 Err(poisoned) => poisoned.into_inner().len(),
579 }
580 }
581
582 fn db_query_candidates(
583 &self,
584 query: &str,
585 mode: SearchMode,
586 limit: usize,
587 ) -> Result<Vec<SearchItem>, ServiceError> {
588 if limit == 0 {
589 return Ok(Vec::new());
590 }
591 let trimmed = query.trim();
592 if trimmed.is_empty() {
593 return Ok(Vec::new());
594 }
595 if matches!(mode, SearchMode::Actions | SearchMode::Clipboard) {
596 return Ok(Vec::new());
597 }
598
599 let sql = match mode {
600 SearchMode::Files => {
601 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs
602 FROM item
603 WHERE (title LIKE ?1 COLLATE NOCASE OR path LIKE ?1 COLLATE NOCASE)
604 AND kind IN ('file', 'folder')
605 ORDER BY use_count DESC, last_accessed_epoch_secs DESC, id
606 LIMIT ?2"
607 }
608 SearchMode::Apps => {
609 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs
610 FROM item
611 WHERE (title LIKE ?1 COLLATE NOCASE OR path LIKE ?1 COLLATE NOCASE)
612 AND kind = 'app'
613 ORDER BY use_count DESC, last_accessed_epoch_secs DESC, id
614 LIMIT ?2"
615 }
616 SearchMode::All => {
617 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs
618 FROM item
619 WHERE title LIKE ?1 COLLATE NOCASE OR path LIKE ?1 COLLATE NOCASE
620 ORDER BY use_count DESC, last_accessed_epoch_secs DESC, id
621 LIMIT ?2"
622 }
623 SearchMode::Actions | SearchMode::Clipboard => unreachable!(),
624 };
625
626 let pattern = format!("%{trimmed}%");
627 let mut stmt = self
628 .db
629 .prepare(sql)
630 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
631 let mut rows = stmt
632 .query(params![pattern, limit as i64])
633 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
634 let mut out = Vec::new();
635 while let Some(row) = rows
636 .next()
637 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?
638 {
639 let id: String = row
640 .get(0)
641 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
642 let kind: String = row
643 .get(1)
644 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
645 let title: String = row
646 .get(2)
647 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
648 let path: String = row
649 .get(3)
650 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
651 let subtitle: String = row
652 .get(4)
653 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
654 let use_count: u32 = row
655 .get(5)
656 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
657 let last_accessed_epoch_secs: i64 = row
658 .get(6)
659 .map_err(|error| ServiceError::Store(StoreError::Db(error)))?;
660 out.push(SearchItem::from_owned_with_subtitle(
661 id,
662 kind,
663 title,
664 path,
665 subtitle,
666 use_count,
667 last_accessed_epoch_secs,
668 ));
669 }
670 Ok(out)
671 }
672
673 fn query_personalization_boosts(
674 &self,
675 query: &str,
676 mode: SearchMode,
677 ) -> Result<HashMap<String, i64>, ServiceError> {
678 let query_norm = crate::model::normalize_for_search(query);
679 if query_norm.is_empty() || matches!(mode, SearchMode::Actions | SearchMode::Clipboard) {
680 return Ok(HashMap::new());
681 }
682
683 let rows =
684 index_store::list_query_selections(&self.db, &query_norm, search_mode_key(mode), 64)?;
685 let now = now_epoch_secs();
686 let mut boosts = HashMap::with_capacity(rows.len());
687 for (item_id, selected_count, last_selected_epoch_secs) in rows {
688 let usage_boost = (selected_count.min(12) as i64) * 280;
689 let recency_boost = query_memory_recency_boost(last_selected_epoch_secs, now);
690 let total = (usage_boost + recency_boost).clamp(0, 5_000);
691 if total > 0 {
692 boosts.insert(item_id, total);
693 }
694 }
695 Ok(boosts)
696 }
697
698 fn refresh_cache_from_store(&self) -> Result<(), ServiceError> {
699 let config_snapshot = self.config_snapshot();
700 let latest_full = index_store::list_items(&self.db)?;
701 let latest_apps = collect_app_items(&latest_full);
702 let summary = cache_compaction_summary(&latest_full, &config_snapshot);
703 let latest = compact_cached_items(&latest_full, &config_snapshot);
704 if summary.dropped_total > 0 {
705 crate::logging::info(&format!(
706 "[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={}",
707 summary.input_total,
708 summary.retained_total,
709 summary.dropped_total,
710 summary.retained_apps,
711 summary.retained_file_folders,
712 summary.retained_other,
713 summary.effective_file_seed_cap,
714 summary.broad_root_mode,
715 summary.active_memory_target_mb
716 ));
717 }
718 match self.cached_items.write() {
719 Ok(mut guard) => {
720 *guard = latest;
721 }
722 Err(poisoned) => {
723 let mut guard = poisoned.into_inner();
724 *guard = latest;
725 }
726 }
727 match self.cached_app_items.write() {
728 Ok(mut guard) => {
729 *guard = latest_apps;
730 }
731 Err(poisoned) => {
732 let mut guard = poisoned.into_inner();
733 *guard = latest_apps;
734 }
735 }
736 Ok(())
737 }
738
739 fn upsert_cached_item(&self, item: SearchItem) {
740 let item_for_apps = item.clone();
741 let item_id = item.id.clone();
742 let is_app = item.kind.eq_ignore_ascii_case("app");
743 match self.cached_items.write() {
744 Ok(mut guard) => upsert_cached_item_inner(&mut guard, item),
745 Err(poisoned) => {
746 let mut guard = poisoned.into_inner();
747 upsert_cached_item_inner(&mut guard, item);
748 }
749 }
750 match self.cached_app_items.write() {
751 Ok(mut guard) => {
752 if is_app {
753 upsert_cached_item_inner(&mut guard, item_for_apps);
754 } else {
755 guard.retain(|entry| entry.id != item_id);
756 }
757 }
758 Err(poisoned) => {
759 let mut guard = poisoned.into_inner();
760 if is_app {
761 upsert_cached_item_inner(&mut guard, item_for_apps);
762 } else {
763 guard.retain(|entry| entry.id != item_id);
764 }
765 }
766 }
767 }
768
769 fn remove_cached_item_by_id(&self, id: &str) {
770 match self.cached_items.write() {
771 Ok(mut guard) => guard.retain(|entry| entry.id != id),
772 Err(poisoned) => {
773 let mut guard = poisoned.into_inner();
774 guard.retain(|entry| entry.id != id);
775 }
776 }
777 match self.cached_app_items.write() {
778 Ok(mut guard) => guard.retain(|entry| entry.id != id),
779 Err(poisoned) => {
780 let mut guard = poisoned.into_inner();
781 guard.retain(|entry| entry.id != id);
782 }
783 }
784 }
785
786 fn record_successful_launch(&self, item: &SearchItem) -> Result<(), ServiceError> {
787 let now = now_epoch_secs();
788 let mut updated = item.clone();
789 updated.use_count = updated.use_count.saturating_add(1);
790 updated.last_accessed_epoch_secs = now.max(updated.last_accessed_epoch_secs);
791
792 index_store::upsert_item(&self.db, &updated)?;
793 self.upsert_cached_item(updated);
794 Ok(())
795 }
796
797 fn prune_stale_items_if_due(&self) -> Result<(), ServiceError> {
798 let should_prune = {
799 let mut last = match self.last_stale_prune.lock() {
800 Ok(guard) => guard,
801 Err(poisoned) => poisoned.into_inner(),
802 };
803 let now = Instant::now();
804 match *last {
805 Some(prev) if now.duration_since(prev) < STALE_PRUNE_INTERVAL => false,
806 _ => {
807 *last = Some(now);
808 true
809 }
810 }
811 };
812
813 if !should_prune {
814 return Ok(());
815 }
816
817 let candidates = self.stale_prune_candidates(STALE_PRUNE_BATCH_SIZE);
818 let stale_ids: Vec<String> = candidates
819 .iter()
820 .filter(|item| is_stale_index_entry(item))
821 .map(|item| item.id.clone())
822 .collect();
823
824 if stale_ids.is_empty() {
825 return Ok(());
826 }
827
828 for stale_id in &stale_ids {
829 index_store::delete_item(&self.db, stale_id)?;
830 }
831
832 match self.cached_items.write() {
833 Ok(mut guard) => {
834 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
835 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
836 }
837 Err(poisoned) => {
838 let mut guard = poisoned.into_inner();
839 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
840 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
841 }
842 }
843 match self.cached_app_items.write() {
844 Ok(mut guard) => {
845 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
846 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
847 }
848 Err(poisoned) => {
849 let mut guard = poisoned.into_inner();
850 let stale_lookup: HashSet<&str> = stale_ids.iter().map(String::as_str).collect();
851 guard.retain(|entry| !stale_lookup.contains(entry.id.as_str()));
852 }
853 }
854
855 crate::logging::info(&format!(
856 "[nex] stale_prune scanned={} removed={} cached_items_remaining={}",
857 candidates.len(),
858 stale_ids.len(),
859 self.cached_len()
860 ));
861
862 Ok(())
863 }
864
865 fn stale_prune_candidates(&self, batch_size: usize) -> Vec<SearchItem> {
866 if batch_size == 0 {
867 return Vec::new();
868 }
869
870 let mut cursor = match self.stale_prune_cursor.lock() {
871 Ok(guard) => guard,
872 Err(poisoned) => poisoned.into_inner(),
873 };
874 let guard = match self.cached_items.read() {
875 Ok(guard) => guard,
876 Err(poisoned) => poisoned.into_inner(),
877 };
878
879 if guard.is_empty() {
880 *cursor = 0;
881 return Vec::new();
882 }
883
884 let len = guard.len();
885 let start = (*cursor).min(len - 1);
886 let take = batch_size.min(len);
887 let mut out = Vec::with_capacity(take);
888 for offset in 0..take {
889 let idx = (start + offset) % len;
890 out.push(guard[idx].clone());
891 }
892 *cursor = (start + take) % len;
893 out
894 }
895}
896
897fn upsert_cached_item_inner(cached: &mut Vec<SearchItem>, item: SearchItem) {
898 if let Some(existing) = cached.iter_mut().find(|entry| entry.id == item.id) {
899 *existing = item;
900 } else {
901 cached.push(item);
902 }
903}
904
905fn collect_app_items(items: &[SearchItem]) -> Vec<SearchItem> {
906 items
907 .iter()
908 .filter(|item| item.kind.eq_ignore_ascii_case("app"))
909 .cloned()
910 .collect()
911}
912
913#[derive(Debug, Clone, Copy, PartialEq, Eq)]
914struct CacheCompactionSummary {
915 input_total: usize,
916 retained_total: usize,
917 dropped_total: usize,
918 retained_apps: usize,
919 retained_file_folders: usize,
920 retained_other: usize,
921 effective_file_seed_cap: usize,
922 broad_root_mode: bool,
923 active_memory_target_mb: u16,
924}
925
926#[derive(Debug, Clone, Copy, PartialEq, Eq)]
927struct ProviderFreshnessStatus {
928 last_scan_age_secs: i64,
929 reconcile_interval_secs: i64,
930 has_stamp: bool,
931}
932
933fn compact_cached_items(items: &[SearchItem], cfg: &Config) -> Vec<SearchItem> {
934 cache_compaction_summary(items, cfg)
935 .retain_items(items)
936 .0
937}
938
939fn cache_compaction_summary(items: &[SearchItem], cfg: &Config) -> CacheCompactionSummary {
940 let effective_file_seed_cap = effective_file_folder_cache_cap(cfg);
941 let broad_root_mode = broad_root_discovery_enabled(cfg);
942 let (retained_total, retained_apps, retained_file_folders, retained_other) =
943 retained_cache_counts(items, effective_file_seed_cap);
944
945 CacheCompactionSummary {
946 input_total: items.len(),
947 retained_total,
948 dropped_total: items.len().saturating_sub(retained_total),
949 retained_apps,
950 retained_file_folders,
951 retained_other,
952 effective_file_seed_cap,
953 broad_root_mode,
954 active_memory_target_mb: cfg.active_memory_target_mb,
955 }
956}
957
958impl CacheCompactionSummary {
959 fn retain_items(&self, items: &[SearchItem]) -> (Vec<SearchItem>, usize) {
960 let mut out = Vec::with_capacity(items.len().min(self.effective_file_seed_cap + 2048));
961 let mut file_or_folder_count = 0_usize;
962
963 for item in items {
964 if is_file_or_folder_kind(item.kind.as_str()) {
965 if file_or_folder_count >= self.effective_file_seed_cap {
966 continue;
967 }
968 file_or_folder_count += 1;
969 }
970 out.push(item.clone());
971 }
972
973 (out, file_or_folder_count)
974 }
975}
976
977fn retained_cache_counts(
978 items: &[SearchItem],
979 effective_file_seed_cap: usize,
980) -> (usize, usize, usize, usize) {
981 let mut retained_total = 0_usize;
982 let mut retained_apps = 0_usize;
983 let mut retained_file_folders = 0_usize;
984 let mut retained_other = 0_usize;
985 let mut file_or_folder_count = 0_usize;
986
987 for item in items {
988 if is_file_or_folder_kind(item.kind.as_str()) {
989 if file_or_folder_count >= effective_file_seed_cap {
990 continue;
991 }
992 file_or_folder_count += 1;
993 retained_file_folders += 1;
994 } else if item.kind.eq_ignore_ascii_case("app") {
995 retained_apps += 1;
996 } else {
997 retained_other += 1;
998 }
999 retained_total += 1;
1000 }
1001
1002 (
1003 retained_total,
1004 retained_apps,
1005 retained_file_folders,
1006 retained_other,
1007 )
1008}
1009
1010fn effective_file_folder_cache_cap(cfg: &Config) -> usize {
1011 let base_cap = (cfg.index_max_items_per_query_seed as usize).max(250);
1012 if !broad_root_discovery_enabled(cfg) {
1013 return base_cap;
1014 }
1015
1016 let memory_scaled_cap = ((cfg.active_memory_target_mb as usize).saturating_mul(8)).clamp(250, 1500);
1017 base_cap.min(memory_scaled_cap)
1018}
1019
1020fn broad_root_discovery_enabled(cfg: &Config) -> bool {
1021 if !(cfg.show_files || cfg.show_folders) {
1022 return false;
1023 }
1024 cfg.discovery_roots
1025 .iter()
1026 .any(|root| is_broad_discovery_root(root))
1027}
1028
1029fn is_broad_discovery_root(path: &Path) -> bool {
1030 let raw = path.to_string_lossy().trim().replace('/', "\\");
1031 if raw.is_empty() {
1032 return false;
1033 }
1034 if raw == "\\" || raw == "/" {
1035 return true;
1036 }
1037 if raw.len() == 2 {
1038 let bytes = raw.as_bytes();
1039 if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
1040 return true;
1041 }
1042 }
1043 if raw.len() == 3 {
1044 let bytes = raw.as_bytes();
1045 if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() && (bytes[2] == b'\\' || bytes[2] == b'/') {
1046 return true;
1047 }
1048 }
1049 false
1050}
1051
1052fn is_file_or_folder_kind(kind: &str) -> bool {
1053 kind.eq_ignore_ascii_case("file") || kind.eq_ignore_ascii_case("folder")
1054}
1055
1056fn should_use_app_cache(filter: &SearchFilter) -> bool {
1057 filter.mode == SearchMode::Apps
1058}
1059
1060fn should_use_db_query_seed(filter: &SearchFilter, query: &str) -> bool {
1061 !query.trim().is_empty() && matches!(filter.mode, SearchMode::All | SearchMode::Files)
1062}
1063
1064fn search_mode_key(mode: SearchMode) -> &'static str {
1065 match mode {
1066 SearchMode::All => "all",
1067 SearchMode::Apps => "apps",
1068 SearchMode::Files => "files",
1069 SearchMode::Actions => "actions",
1070 SearchMode::Clipboard => "clipboard",
1071 }
1072}
1073
1074fn query_memory_recency_boost(last_selected_epoch_secs: i64, now_epoch_secs: i64) -> i64 {
1075 if last_selected_epoch_secs <= 0 || now_epoch_secs <= 0 {
1076 return 0;
1077 }
1078 let age_secs = now_epoch_secs.saturating_sub(last_selected_epoch_secs);
1079 if age_secs <= 86_400 {
1080 900
1081 } else if age_secs <= 7 * 86_400 {
1082 550
1083 } else if age_secs <= 30 * 86_400 {
1084 220
1085 } else {
1086 0
1087 }
1088}
1089
1090fn merge_seed_candidates(seed_items: &mut Vec<SearchItem>, extra: Vec<SearchItem>) {
1091 if extra.is_empty() {
1092 return;
1093 }
1094 let mut seen: HashSet<String> = seed_items.iter().map(|item| item.id.clone()).collect();
1095 for item in extra {
1096 if seen.insert(item.id.clone()) {
1097 seed_items.push(item);
1098 }
1099 }
1100}
1101
1102fn is_stale_index_entry(item: &SearchItem) -> bool {
1103 if !(item.kind.eq_ignore_ascii_case("app")
1104 || item.kind.eq_ignore_ascii_case("file")
1105 || item.kind.eq_ignore_ascii_case("folder"))
1106 {
1107 return false;
1108 }
1109
1110 let path = item.path.trim();
1111 if path.is_empty() {
1112 return false;
1113 }
1114 if path.contains("://") {
1115 return false;
1116 }
1117 if !looks_like_filesystem_path(path) {
1118 return false;
1119 }
1120
1121 !Path::new(path).exists()
1122}
1123
1124fn looks_like_filesystem_path(path: &str) -> bool {
1125 if path.starts_with('/') || path.starts_with('\\') {
1126 return true;
1127 }
1128
1129 let bytes = path.as_bytes();
1130 bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')
1131}
1132
1133fn provider_manages_kind(provider_name: &str, kind: &str) -> bool {
1134 let kind = kind.to_ascii_lowercase();
1135 match provider_name {
1136 "start-menu-apps" | "app" => kind == "app",
1137 "filesystem" | "file" => kind == "file" || kind == "folder",
1138 _ => false,
1139 }
1140}
1141
1142fn should_prune_after_launch_error(item: &SearchItem, error: &LaunchError) -> bool {
1143 let is_filesystem_target = looks_like_filesystem_path(item.path.trim());
1144 match error {
1145 LaunchError::MissingPath(_) => {
1146 is_filesystem_target
1147 && (item.kind.eq_ignore_ascii_case("app")
1148 || item.kind.eq_ignore_ascii_case("file")
1149 || item.kind.eq_ignore_ascii_case("folder"))
1150 }
1151 LaunchError::LaunchFailed {
1152 code: Some(code), ..
1153 } => {
1154 (*code == 2 || *code == 3)
1156 && is_filesystem_target
1157 && (item.kind.eq_ignore_ascii_case("app")
1158 || item.kind.eq_ignore_ascii_case("file")
1159 || item.kind.eq_ignore_ascii_case("folder"))
1160 }
1161 LaunchError::LaunchFailed { .. } | LaunchError::EmptyPath => false,
1162 }
1163}
1164
1165fn should_skip_provider_discovery(
1166 db: &Connection,
1167 provider_name: &str,
1168 stamp: Option<&str>,
1169 now_epoch_secs: i64,
1170) -> Result<bool, ServiceError> {
1171 let Some(stamp) = stamp else {
1172 return Ok(false);
1173 };
1174
1175 let stamp_key = provider_stamp_meta_key(provider_name);
1176 let previous_stamp = index_store::get_meta(db, &stamp_key)?;
1177 if previous_stamp.as_deref() != Some(stamp) {
1178 return Ok(false);
1179 }
1180
1181 let last_scan_key = provider_last_scan_meta_key(provider_name);
1182 let last_scan_epoch = index_store::get_meta(db, &last_scan_key)?
1183 .and_then(|value| value.parse::<i64>().ok())
1184 .unwrap_or(0);
1185 if last_scan_epoch <= 0 {
1186 return Ok(false);
1187 }
1188
1189 Ok(now_epoch_secs.saturating_sub(last_scan_epoch) < PROVIDER_RECONCILE_INTERVAL_SECS)
1190}
1191
1192fn persist_provider_discovery_state(
1193 db: &Connection,
1194 provider_name: &str,
1195 stamp: Option<&str>,
1196 now_epoch_secs: i64,
1197) -> Result<(), ServiceError> {
1198 if let Some(stamp) = stamp {
1199 let stamp_key = provider_stamp_meta_key(provider_name);
1200 index_store::set_meta(db, &stamp_key, stamp)?;
1201 }
1202
1203 let last_scan_key = provider_last_scan_meta_key(provider_name);
1204 index_store::set_meta(db, &last_scan_key, &now_epoch_secs.to_string())?;
1205 Ok(())
1206}
1207
1208fn provider_stamp_meta_key(provider_name: &str) -> String {
1209 format!("provider_stamp:{provider_name}")
1210}
1211
1212fn provider_last_scan_meta_key(provider_name: &str) -> String {
1213 format!("provider_last_scan_epoch:{provider_name}")
1214}
1215
1216fn load_provider_freshness_status(
1217 db: &Connection,
1218 provider_name: &str,
1219 now_epoch_secs: i64,
1220) -> Result<ProviderFreshnessStatus, ServiceError> {
1221 let last_scan_key = provider_last_scan_meta_key(provider_name);
1222 let last_scan_epoch = index_store::get_meta(db, &last_scan_key)?
1223 .and_then(|value| value.parse::<i64>().ok())
1224 .unwrap_or(0);
1225 let stamp_key = provider_stamp_meta_key(provider_name);
1226 let has_stamp = index_store::get_meta(db, &stamp_key)?
1227 .map(|value| !value.trim().is_empty())
1228 .unwrap_or(false);
1229
1230 Ok(ProviderFreshnessStatus {
1231 last_scan_age_secs: if last_scan_epoch > 0 {
1232 now_epoch_secs.saturating_sub(last_scan_epoch).max(0)
1233 } else {
1234 -1
1235 },
1236 reconcile_interval_secs: PROVIDER_RECONCILE_INTERVAL_SECS,
1237 has_stamp,
1238 })
1239}
1240
1241fn log_provider_freshness_status(
1242 db: &Connection,
1243 provider_name: &str,
1244 now_epoch_secs: i64,
1245 skipped: bool,
1246) -> Result<(), ServiceError> {
1247 let freshness = load_provider_freshness_status(db, provider_name, now_epoch_secs)?;
1248 crate::logging::info(&format!(
1249 "[nex] provider_freshness name={} skipped={} last_scan_age_secs={} reconcile_interval_secs={} has_stamp={}",
1250 provider_name,
1251 skipped,
1252 freshness.last_scan_age_secs,
1253 freshness.reconcile_interval_secs,
1254 freshness.has_stamp
1255 ));
1256 Ok(())
1257}
1258
1259fn now_epoch_secs() -> i64 {
1260 SystemTime::now()
1261 .duration_since(UNIX_EPOCH)
1262 .map(|value| value.as_secs() as i64)
1263 .unwrap_or(0)
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268 use super::{
1269 broad_root_discovery_enabled, cache_compaction_summary, compact_cached_items,
1270 effective_file_folder_cache_cap, CoreService,
1271 };
1272 use crate::config::{Config, SearchMode};
1273 use crate::index_store::open_memory;
1274 use crate::model::SearchItem;
1275 use crate::search::SearchFilter;
1276 use std::path::PathBuf;
1277 use std::time::{SystemTime, UNIX_EPOCH};
1278
1279 #[test]
1280 fn app_mode_search_excludes_non_app_items() {
1281 let unique = SystemTime::now()
1282 .duration_since(UNIX_EPOCH)
1283 .expect("clock should be valid")
1284 .as_nanos();
1285 let app_path = std::env::temp_dir().join(format!("nex-app-cache-app-{unique}.tmp"));
1286 let file_path = std::env::temp_dir().join(format!("nex-app-cache-file-{unique}.tmp"));
1287 std::fs::write(&app_path, b"ok").expect("app path should exist");
1288 std::fs::write(&file_path, b"ok").expect("file path should exist");
1289
1290 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
1291 .expect("service should initialize");
1292 service
1293 .upsert_item(&SearchItem::new(
1294 "app-vivaldi",
1295 "app",
1296 "Vivaldi",
1297 app_path.to_string_lossy().as_ref(),
1298 ))
1299 .expect("app should upsert");
1300 service
1301 .upsert_item(&SearchItem::new(
1302 "file-video",
1303 "file",
1304 "video notes",
1305 file_path.to_string_lossy().as_ref(),
1306 ))
1307 .expect("file should upsert");
1308
1309 let filter = SearchFilter {
1310 mode: SearchMode::Apps,
1311 ..SearchFilter::default()
1312 };
1313 let results = service
1314 .search_with_filter("v", 20, &filter)
1315 .expect("search should succeed");
1316 assert!(results.iter().any(|item| item.id == "app-vivaldi"));
1317 assert!(!results.iter().any(|item| item.id == "file-video"));
1318
1319 std::fs::remove_file(app_path).expect("app temp file should be removed");
1320 std::fs::remove_file(file_path).expect("file temp file should be removed");
1321 }
1322
1323 #[test]
1324 fn app_cache_tracks_kind_changes() {
1325 let unique = SystemTime::now()
1326 .duration_since(UNIX_EPOCH)
1327 .expect("clock should be valid")
1328 .as_nanos();
1329 let path = std::env::temp_dir().join(format!("nex-app-cache-kind-{unique}.tmp"));
1330 std::fs::write(&path, b"ok").expect("temp file should exist");
1331
1332 let service = CoreService::with_connection(Config::default(), open_memory().unwrap())
1333 .expect("service should initialize");
1334 service
1335 .upsert_item(&SearchItem::new(
1336 "entry-1",
1337 "app",
1338 "Visual Studio Code",
1339 path.to_string_lossy().as_ref(),
1340 ))
1341 .expect("app should upsert");
1342 service
1343 .upsert_item(&SearchItem::new(
1344 "entry-1",
1345 "file",
1346 "Visual Studio Code.txt",
1347 path.to_string_lossy().as_ref(),
1348 ))
1349 .expect("file should replace app");
1350
1351 let filter = SearchFilter {
1352 mode: SearchMode::Apps,
1353 ..SearchFilter::default()
1354 };
1355 let results = service
1356 .search_with_filter("visual", 20, &filter)
1357 .expect("search should succeed");
1358 assert!(!results.iter().any(|item| item.id == "entry-1"));
1359
1360 std::fs::remove_file(path).expect("temp file should be removed");
1361 }
1362
1363 #[test]
1364 fn uncapped_search_respects_requested_limit_above_config_max() {
1365 let mut cfg = Config::default();
1366 cfg.max_results = 5;
1367 let service = CoreService::with_connection(cfg, open_memory().unwrap())
1368 .expect("service should initialize");
1369
1370 let mut temp_paths = Vec::new();
1371 for idx in 0..25 {
1372 let path = std::env::temp_dir().join(format!("nex-uncapped-{idx}.tmp"));
1373 std::fs::write(&path, b"ok").expect("temp file should exist");
1374 temp_paths.push(path.clone());
1375 service
1376 .upsert_item(&SearchItem::new(
1377 &format!("app-{idx:02}"),
1378 "app",
1379 &format!("Alpha App {idx:02}"),
1380 path.to_string_lossy().as_ref(),
1381 ))
1382 .expect("item should upsert");
1383 }
1384
1385 let filter = SearchFilter::default();
1386 let capped = service
1387 .search_with_filter("alpha", 20, &filter)
1388 .expect("capped search should succeed");
1389 let uncapped = service
1390 .search_with_filter_uncapped("alpha", 20, &filter)
1391 .expect("uncapped search should succeed");
1392
1393 assert_eq!(capped.len(), 5);
1394 assert!(uncapped.len() >= 20);
1395
1396 for path in temp_paths {
1397 let _ = std::fs::remove_file(path);
1398 }
1399 }
1400
1401 #[test]
1402 fn broad_root_mode_detects_drive_roots() {
1403 let mut cfg = Config::default();
1404 cfg.show_files = true;
1405 cfg.show_folders = true;
1406 cfg.discovery_roots = vec![PathBuf::from(r"C:\")];
1407 assert!(broad_root_discovery_enabled(&cfg));
1408 }
1409
1410 #[test]
1411 fn broad_root_mode_ignores_default_profile_roots() {
1412 let cfg = Config::default();
1413 assert!(!broad_root_discovery_enabled(&cfg));
1414 }
1415
1416 #[test]
1417 fn broad_root_mode_reduces_file_folder_cache_cap() {
1418 let mut cfg = Config::default();
1419 cfg.show_files = true;
1420 cfg.show_folders = true;
1421 cfg.discovery_roots = vec![PathBuf::from(r"C:\")];
1422 cfg.index_max_items_per_query_seed = 5_000;
1423 cfg.active_memory_target_mb = 72;
1424
1425 assert_eq!(effective_file_folder_cache_cap(&cfg), 576);
1426 }
1427
1428 #[test]
1429 fn cache_compaction_keeps_apps_but_tightens_files_for_broad_roots() {
1430 let mut cfg = Config::default();
1431 cfg.show_files = true;
1432 cfg.show_folders = true;
1433 cfg.discovery_roots = vec![PathBuf::from(r"C:\")];
1434 cfg.index_max_items_per_query_seed = 5_000;
1435 cfg.active_memory_target_mb = 72;
1436
1437 let mut items = Vec::new();
1438 for idx in 0..20 {
1439 items.push(SearchItem::new(
1440 &format!("app-{idx}"),
1441 "app",
1442 &format!("App {idx}"),
1443 &format!(r"C:\Apps\App{idx}.lnk"),
1444 ));
1445 }
1446 for idx in 0..700 {
1447 items.push(SearchItem::new(
1448 &format!("file-{idx}"),
1449 "file",
1450 &format!("File {idx}"),
1451 &format!(r"C:\Data\File{idx}.txt"),
1452 ));
1453 }
1454
1455 let summary = cache_compaction_summary(&items, &cfg);
1456 let retained = compact_cached_items(&items, &cfg);
1457
1458 assert!(summary.broad_root_mode);
1459 assert_eq!(summary.retained_apps, 20);
1460 assert_eq!(summary.effective_file_seed_cap, 576);
1461 assert_eq!(summary.retained_file_folders, 576);
1462 assert_eq!(summary.retained_total, 596);
1463 assert_eq!(retained.len(), 596);
1464 assert_eq!(
1465 retained
1466 .iter()
1467 .filter(|item| item.kind.eq_ignore_ascii_case("app"))
1468 .count(),
1469 20
1470 );
1471 }
1472}