Skip to main content

nex_core/
core_service.rs

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    // Always register filesystem provider so toggling file/folder discovery off
193    // can actively prune stale file/folder records from the index.
194    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                    // Discovery providers do not carry usage metrics; preserve learned
454                    // launch signals across incremental/full refreshes.
455                    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            // Kind-based ownership is safe for current runtime provider composition:
477            // start-menu apps own kind=app, filesystem owns kind=file/folder.
478            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            // ShellExecute missing-file/path errors: remove stale entries immediately.
1147            (*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}