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(
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                    // Discovery providers do not carry usage metrics; preserve learned
459                    // launch signals across incremental/full refreshes.
460                    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            // Kind-based ownership is safe for current runtime provider composition:
482            // start-menu apps own kind=app, filesystem owns kind=file/folder.
483            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            // ShellExecute missing-file/path errors: remove stale entries immediately.
1155            (*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}