Skip to main content

zerolaunch_plugin_api/plugin/
cached_candidate.rs

1use crate::plugin::types::{CandidateId, ExecutionTarget, SearchCandidate};
2use dashmap::DashMap;
3use dashmap::Entry;
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use tracing::{debug, warn};
7
8#[derive(Clone)]
9pub struct CachedCandidateData {
10    /// 当前缓存的候选数据
11    candidates: Vec<SearchCandidate>,
12    /// 候选ID到索引的映射
13    index: DashMap<CandidateId, usize>,
14    /// 该方法用于去重,只有没有重复的候选项才会被添加到candidates中,重复的候选项会被丢弃掉
15    /// 判断的依据:执行目标
16    cached_targets: HashSet<ExecutionTarget>,
17    /// 该方法用于去重,只有显示名不重复的候选项才会被添加到candidates中
18    /// 判断的依据:候选项显示名(忽略大小写)
19    cached_display_names: HashSet<String>,
20    /// 下一个候选项ID
21    next_candidate_id: CandidateId,
22    /// 缓存世代:每次全量重建递增。前端确认时回传该世代,
23    /// 后端校验不匹配即拒绝——防止缓存刷新后 id 漂移导致确认到错误候选。
24    /// 不随跨 RPC 快照传输(快照仅用于流水线组件打分,不含确认语义)。
25    generation: u64,
26}
27
28/// CachedCandidateData 的跨 RPC 序列化快照(宿主缓存 → 插件进程)。
29///
30/// 由 CachedCandidateData::to_data 导出、from_data 还原,仅用于搜索流水线
31/// 组件(SearchEngine / ScoreBooster)经 RPC 接收宿主候选缓存的传输;
32/// index / 去重集合不随快照传输——它们由候选列表确定性派生(id 按插入
33/// 顺序自增分配、无删除),from_data 以 debug_assert 校验后重建。
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CandidateCacheSnapshot {
36    /// 候选列表(保持 id 自增分配顺序,id 与位置一一对应)。
37    #[serde(rename = "candidates")]
38    pub candidates: Vec<SearchCandidate>,
39    /// 下一个待分配的候选项 ID(导出时刻的 next_candidate_id)。
40    #[serde(rename = "nextCandidateId")]
41    pub next_candidate_id: CandidateId,
42}
43
44impl Default for CachedCandidateData {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl CachedCandidateData {
51    pub fn new() -> Self {
52        Self {
53            candidates: Vec::new(),
54            index: DashMap::new(),
55            cached_targets: HashSet::new(),
56            cached_display_names: HashSet::new(),
57            next_candidate_id: 1,
58            generation: 0,
59        }
60    }
61
62    /// 当前缓存世代(前端确认回传比对用)。
63    pub fn generation(&self) -> u64 {
64        self.generation
65    }
66
67    /// 全量重建完成后递增世代:旧确认载荷(携带旧世代)被 route_confirm 拒绝。
68    pub fn bump_generation(&mut self) {
69        self.generation += 1;
70    }
71
72    /// 导出跨 RPC 序列化快照(与 from_data 一一对应)。
73    /// 用于搜索流水线组件(引擎/增强器)经 RPC 接收宿主候选缓存。
74    pub fn to_data(&self) -> CandidateCacheSnapshot {
75        CandidateCacheSnapshot {
76            candidates: self.candidates.clone(),
77            next_candidate_id: self.next_candidate_id,
78        }
79    }
80
81    /// 从跨 RPC 序列化快照还原缓存(保真 id 序列,与 to_data 一一对应)。
82    /// 参数:data - to_data() 导出的快照。
83    /// debug 构建校验快照不变量:id 与位置一一对应、id 唯一、目标与显示名无重复。
84    pub fn from_data(data: CandidateCacheSnapshot) -> Self {
85        let index = DashMap::new();
86        let mut cached_targets = HashSet::new();
87        let mut cached_display_names = HashSet::new();
88        for (pos, candidate) in data.candidates.iter().enumerate() {
89            debug_assert_eq!(
90                candidate.id,
91                pos as CandidateId + 1,
92                "快照候选 id 与位置不对应: id = {}, pos = {}",
93                candidate.id,
94                pos
95            );
96            index.insert(candidate.id, pos);
97            debug_assert!(
98                cached_targets.insert(candidate.target.clone()),
99                "快照候选中存在重复执行目标: {:?}",
100                candidate.target
101            );
102            debug_assert!(
103                cached_display_names.insert(candidate.name.to_lowercase()),
104                "快照候选中存在重复显示名: {}",
105                candidate.name
106            );
107        }
108        debug_assert_eq!(
109            data.next_candidate_id,
110            data.candidates.len() as CandidateId + 1,
111            "快照 next_candidate_id 与候选数量不对应: next = {}, len = {}",
112            data.next_candidate_id,
113            data.candidates.len()
114        );
115        Self {
116            candidates: data.candidates,
117            index,
118            cached_targets,
119            cached_display_names,
120            next_candidate_id: data.next_candidate_id,
121            generation: 0,
122        }
123    }
124
125    /// 添加一个候选人
126    pub fn add_candidate(&mut self, mut candidate: SearchCandidate) {
127        if self.has_target(&candidate.target) || self.has_display_name(&candidate.name) {
128            debug!(
129                "候选项已存在,丢弃重复的候选项: target = {:?}, name = {}",
130                candidate.target, candidate.name
131            );
132            return;
133        }
134        let candidate_id = self.next_candidate_id;
135        candidate.id = candidate_id;
136        self.cached_targets.insert(candidate.target.clone());
137        self.cached_display_names
138            .insert(candidate.name.to_lowercase());
139        self.candidates.push(candidate);
140        self.index.insert(candidate_id, self.candidates.len() - 1);
141        self.next_candidate_id += 1;
142    }
143
144    /// 添加宿主插件候选(沉浸式插件唤醒项)。
145    /// 仅按执行目标去重(target 为 ExecutionTarget::Plugin(id),注册表保证唯一);
146    /// 不按展示名去重——插件候选与数据源候选可能同名,互不丢弃。
147    pub fn add_plugin_candidate(&mut self, mut candidate: SearchCandidate) {
148        if self.has_target(&candidate.target) {
149            warn!(
150                "插件候选项已存在,丢弃重复项: target = {:?}",
151                candidate.target
152            );
153            return;
154        }
155        let candidate_id: u64 = self.next_candidate_id;
156        candidate.id = candidate_id;
157        self.cached_targets.insert(candidate.target.clone());
158        self.candidates.push(candidate);
159        self.index.insert(candidate_id, self.candidates.len() - 1);
160        self.next_candidate_id += 1;
161    }
162
163    /// 根据id获得指定的一个候选人
164    pub fn get_candidate(&self, id: CandidateId) -> Option<&SearchCandidate> {
165        match self.index.entry(id) {
166            Entry::Occupied(entry) => Some(&self.candidates[*entry.get()]),
167            Entry::Vacant(_) => None,
168        }
169    }
170
171    /// 添加多个候选人
172    pub fn add_candidates(&mut self, candidates: CachedCandidateData) {
173        for candidate in candidates.candidates.iter() {
174            self.add_candidate(candidate.clone());
175        }
176    }
177
178    /// 获得原始的数据
179    pub fn get_candidates(&self) -> &Vec<SearchCandidate> {
180        &self.candidates
181    }
182
183    /// 获得原始的数据的可变引用
184    pub fn get_candidates_mut(&mut self) -> &mut Vec<SearchCandidate> {
185        &mut self.candidates
186    }
187
188    /// 判断是否已经缓存了某个执行目标的候选项了
189    fn has_target(&self, target: &ExecutionTarget) -> bool {
190        self.cached_targets.contains(target)
191    }
192
193    /// 判断是否已经缓存了某个显示名的候选项(忽略大小写)
194    fn has_display_name(&self, display_name: &str) -> bool {
195        self.cached_display_names
196            .contains(&display_name.to_lowercase())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::services::IconRequest;
204
205    fn sample_candidate(i: u64) -> SearchCandidate {
206        SearchCandidate {
207            id: i,
208            name: format!("候选{}", i),
209            icon: IconRequest::Path(format!("C:\\item{}.exe", i)),
210            target: ExecutionTarget::Path(format!("C:\\item{}.exe", i)),
211            keywords: vec!["kw".into()],
212            bias: 0.0,
213            trigger_keywords: Vec::new(),
214        }
215    }
216
217    /// to_data → from_data 一一对应:id 序列、顺序、内容全部保真,
218    /// next_candidate_id 还原后继续自增分配。
219    #[test]
220    fn test_snapshot_roundtrip() {
221        let mut cache = CachedCandidateData::new();
222        for i in 1..=10u64 {
223            cache.add_candidate(sample_candidate(i));
224        }
225        let mut restored = CachedCandidateData::from_data(cache.to_data());
226        assert_eq!(restored.get_candidates().len(), 10);
227        for i in 1..=10u64 {
228            let c = restored.get_candidate(i).expect("id 保真");
229            assert_eq!(c.name, format!("候选{}", i));
230            assert_eq!(
231                c.target,
232                ExecutionTarget::Path(format!("C:\\item{}.exe", i))
233            );
234        }
235        // next_candidate_id 还原为 11:新候选 id 从 11 继续
236        restored.add_candidate(sample_candidate(0));
237        assert_eq!(restored.get_candidates().len(), 11);
238        assert_eq!(
239            restored.get_candidate(11).expect("next_id 保真").name,
240            "候选0"
241        );
242    }
243
244    /// 快照经 JSON 序列化后往返,保真性不变(跨 RPC 实际传输路径)。
245    #[test]
246    fn test_snapshot_json_roundtrip() {
247        let mut cache = CachedCandidateData::new();
248        for i in 1..=5u64 {
249            cache.add_candidate(sample_candidate(i));
250        }
251        let json = serde_json::to_value(cache.to_data()).unwrap();
252        let data: CandidateCacheSnapshot = serde_json::from_value(json).unwrap();
253        let restored = CachedCandidateData::from_data(data);
254        assert_eq!(restored.get_candidates().len(), 5);
255        for i in 1..=5u64 {
256            assert_eq!(
257                restored.get_candidate(i).expect("id 保真").name,
258                format!("候选{}", i)
259            );
260        }
261    }
262
263    /// id 与位置不对应的快照在 debug 构建下立即暴露(release 不校验)。
264    #[test]
265    #[should_panic(expected = "id 与位置不对应")]
266    fn test_from_data_detects_mismatched_id() {
267        let data = CandidateCacheSnapshot {
268            candidates: vec![sample_candidate(5)],
269            next_candidate_id: 2,
270        };
271        CachedCandidateData::from_data(data);
272    }
273}