use crate::config::Configurable;
use crate::plugin::cached_candidate::CachedCandidateData;
use crate::services::icon_request::IconRequest;
use crate::services::parameter::types::ParameterSnapshot;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
pub type CandidateId = u64;
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub enum TargetType {
#[serde(rename = "Path")]
Path,
#[serde(rename = "App")]
App,
#[serde(rename = "File")]
File,
#[serde(rename = "Url")]
Url,
#[serde(rename = "Command")]
Command,
#[serde(rename = "BuiltinCommand")]
BuiltinCommand,
#[serde(rename = "Plugin")]
Plugin,
}
impl TargetType {
pub fn as_str(&self) -> &'static str {
match self {
TargetType::Path => "Path",
TargetType::App => "App",
TargetType::File => "File",
TargetType::Url => "Url",
TargetType::Command => "Command",
TargetType::BuiltinCommand => "BuiltinCommand",
TargetType::Plugin => "Plugin",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, Hash, PartialEq)]
pub enum ExecutionTarget {
#[serde(rename = "path")]
Path(String),
#[serde(rename = "app")]
App(String),
#[serde(rename = "file")]
File(String),
#[serde(rename = "url")]
Url(String),
#[serde(rename = "command")]
Command(String),
#[serde(rename = "builtinCommand")]
BuiltinCommand(String),
#[serde(rename = "plugin")]
Plugin(String),
}
impl ExecutionTarget {
pub fn target_type(&self) -> TargetType {
match self {
ExecutionTarget::Path(_) => TargetType::Path,
ExecutionTarget::App(_) => TargetType::App,
ExecutionTarget::File(_) => TargetType::File,
ExecutionTarget::Url(_) => TargetType::Url,
ExecutionTarget::Command(_) => TargetType::Command,
ExecutionTarget::BuiltinCommand(_) => TargetType::BuiltinCommand,
ExecutionTarget::Plugin(_) => TargetType::Plugin,
}
}
pub fn payload(&self) -> &str {
match self {
ExecutionTarget::Path(s) => s,
ExecutionTarget::App(s) => s,
ExecutionTarget::File(s) => s,
ExecutionTarget::Url(s) => s,
ExecutionTarget::Command(s) => s,
ExecutionTarget::BuiltinCommand(s) => s,
ExecutionTarget::Plugin(s) => s,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionContext {
pub target: ExecutionTarget,
pub display_name: String,
pub user_args: Vec<String>,
pub parameter_snapshot: ParameterSnapshot,
pub locale: String,
}
impl Default for ExecutionContext {
fn default() -> Self {
Self {
target: ExecutionTarget::Path(String::new()),
display_name: String::new(),
user_args: Vec::new(),
parameter_snapshot: ParameterSnapshot::empty(),
locale: String::new(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExecutionError {
#[error("Execution failed: {0}")]
Failed(String),
#[error("Executor not found for target type: {0:?}")]
NotFound(TargetType),
#[error("Unsupported action: {0:?}:{1}")]
UnsupportedAction(TargetType, String),
#[error("Window activation failed, fallback to: {fallback_action}")]
ActivationFailed { fallback_action: String },
}
#[derive(Debug, thiserror::Error)]
pub enum RegistrationError {
#[error("Action '{action_id}' for {target_type:?} is already registered")]
ActionConflict {
target_type: TargetType,
action_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchCandidate {
#[serde(rename = "id")]
pub id: CandidateId,
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "icon")]
pub icon: IconRequest,
#[serde(rename = "target")]
pub target: ExecutionTarget,
#[serde(rename = "keywords")]
pub keywords: Vec<String>,
#[serde(rename = "bias")]
pub bias: f64,
#[serde(rename = "triggerKeywords")]
pub trigger_keywords: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ScoreDetailKind {
#[default]
#[serde(rename = "add")]
Add,
#[serde(rename = "multiply")]
Multiply,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreDetail {
#[serde(rename = "score")]
pub score: f64,
#[serde(rename = "weight")]
pub weight: f64,
#[serde(rename = "description")]
pub description: String,
#[serde(rename = "kind", default)]
pub kind: ScoreDetailKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoredCandidate {
#[serde(rename = "candidateId")]
pub candidate_id: CandidateId,
#[serde(rename = "score")]
pub score: f64,
#[serde(rename = "detailedScore")]
pub detailed_score: Vec<ScoreDetail>,
}
#[async_trait]
pub trait DataSource: Configurable {
async fn fetch_candidates(&self) -> CachedCandidateData;
}
#[async_trait]
pub trait KeywordOptimizer: Configurable {
async fn optimize(&self, keyword: &str) -> Vec<String>;
fn get_priority(&self) -> u32;
fn input_source(&self) -> KeywordInputSource {
KeywordInputSource::Refined
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum KeywordInputSource {
#[serde(rename = "originalName")]
OriginalName,
#[serde(rename = "normalizedBase")]
NormalizedBase,
#[serde(rename = "refined")]
#[default]
Refined,
#[serde(rename = "optimizerOutput")]
OptimizerOutput {
#[serde(rename = "producerId")]
producer_id: String,
},
}
#[async_trait]
pub trait KeywordInjector: Configurable {
async fn inject_keywords(&self, candidate: &SearchCandidate) -> Vec<String>;
}
#[async_trait]
pub trait SearchEngine: Configurable {
async fn calculate_scores(
&self,
candidates: &CachedCandidateData,
query: &str,
) -> Vec<ScoredCandidate>;
}
#[async_trait]
pub trait ScoreBooster: Configurable {
async fn record(&self, candidate_id: CandidateId, data: &CachedCandidateData, query: &str);
async fn boost(
&self,
candidates: &mut Vec<ScoredCandidate>,
data: &CachedCandidateData,
query: &str,
);
}
#[async_trait]
pub trait ActionExecutor: Configurable {
fn supported_target_types(&self) -> Vec<TargetType>;
fn supported_actions(&self) -> Vec<ResultAction> {
vec![ResultAction {
id: "execute".to_string(),
label: "执行".to_string(),
icon: IconRequest::Path(String::new()),
is_default: true,
shortcut_key: String::new(),
}]
}
async fn execute(&self, ctx: &ExecutionContext, action_id: &str) -> Result<(), ExecutionError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum QueryChannel {
#[default]
#[serde(rename = "ui")]
Ui,
#[serde(rename = "cli")]
Cli,
#[serde(rename = "panel")]
Panel,
}
#[derive(Debug, Clone)]
pub struct QueryRevisionGate {
revision: u64,
latest: Arc<AtomicU64>,
}
impl QueryRevisionGate {
pub fn new(revision: u64, latest: Arc<AtomicU64>) -> Self {
Self { revision, latest }
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn is_current(&self) -> bool {
self.latest.load(Ordering::Relaxed) == self.revision
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginContext {
pub trace_id: String,
pub query_id: Option<String>,
pub plugin_id: Option<String>,
#[serde(skip)]
pub query_revision_gate: Option<QueryRevisionGate>,
#[serde(default)]
pub query_channel: QueryChannel,
#[serde(default)]
pub locale: String,
}
impl PluginContext {
pub fn new(trace_id: &str) -> Self {
Self {
trace_id: trace_id.to_string(),
query_id: None,
plugin_id: None,
query_revision_gate: None,
query_channel: QueryChannel::Ui,
locale: String::new(),
}
}
pub fn with_query(&mut self, query_id: String) {
self.query_id = Some(query_id);
}
pub fn with_plugin_id(&mut self, plugin_id: String) {
self.plugin_id = Some(plugin_id);
}
pub fn set_query_revision_gate(&mut self, gate: QueryRevisionGate) {
self.query_revision_gate = Some(gate);
}
pub fn is_query_current(&self) -> bool {
self.query_revision_gate
.as_ref()
.is_none_or(|g| g.is_current())
}
pub fn query_revision(&self) -> u64 {
self.query_revision_gate
.as_ref()
.map_or(0, |g| g.revision())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Query {
pub id: String,
pub raw_query: String,
pub search_term: String,
#[serde(rename = "confirm", default)]
pub confirm: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PanelQueryTrigger {
#[default]
#[serde(rename = "onInput")]
OnInput,
#[serde(rename = "onEnter")]
OnEnter,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelKeyBinding {
#[serde(rename = "key")]
pub key: String,
#[serde(rename = "action")]
pub action: PanelKeyAction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PanelKeyAction {
#[serde(rename = "confirm")]
Confirm,
#[serde(rename = "executeAction")]
ExecuteAction {
#[serde(rename = "actionId")]
action_id: Option<String>,
},
#[serde(rename = "goBack")]
GoBack,
#[serde(rename = "gotoPanel")]
GotoPanel {
#[serde(rename = "panelId")]
panel_id: String,
},
#[serde(rename = "custom")]
Custom {
#[serde(rename = "action")]
action: String,
#[serde(rename = "args")]
args: serde_json::Value,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PanelInteraction {
#[serde(rename = "queryTrigger", default)]
pub query_trigger: PanelQueryTrigger,
#[serde(rename = "queryDebounceMs", default)]
pub query_debounce_ms: u64,
#[serde(rename = "bindings", default)]
pub bindings: Vec<PanelKeyBinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QueryResponse {
#[serde(rename = "list")]
List {
#[serde(rename = "results")]
results: Vec<ListItem>,
},
#[serde(rename = "customPanel")]
CustomPanel {
#[serde(rename = "panelType")]
panel_type: String,
#[serde(rename = "data")]
data: serde_json::Value,
#[serde(rename = "actions")]
actions: Vec<ResultAction>,
#[serde(rename = "keepSearchBar")]
keep_search_bar: bool,
},
#[serde(rename = "empty")]
Empty,
#[serde(rename = "inlineParam")]
InlineParam {
#[serde(rename = "candidateId")]
candidate_id: CandidateId,
#[serde(rename = "triggerKeyword")]
trigger_keyword: String,
#[serde(rename = "userArgCount")]
user_arg_count: usize,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListItem {
#[serde(rename = "id")]
pub id: CandidateId,
#[serde(rename = "title")]
pub title: String,
#[serde(rename = "subtitle")]
pub subtitle: String,
#[serde(rename = "icon")]
pub icon: IconRequest,
#[serde(rename = "score")]
pub score: f64,
#[serde(rename = "actions")]
pub actions: Vec<ResultAction>,
#[serde(rename = "targetType")]
pub target_type: String,
#[serde(rename = "userArgCount")]
pub user_arg_count: usize,
#[serde(rename = "hasSystemParams")]
pub has_system_params: bool,
#[serde(rename = "triggerKeywords")]
pub trigger_keywords: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResultAction {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "label")]
pub label: String,
#[serde(rename = "icon")]
pub icon: IconRequest,
#[serde(rename = "isDefault")]
pub is_default: bool,
#[serde(rename = "shortcutKey")]
pub shortcut_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "version")]
pub version: String,
#[serde(rename = "description")]
pub description: String,
#[serde(rename = "author")]
pub author: String,
#[serde(rename = "triggerKeywords")]
pub trigger_keywords: Vec<String>,
#[serde(rename = "triggerDescription", default)]
pub trigger_description: Option<String>,
#[serde(rename = "supportedOs")]
pub supported_os: Vec<String>,
#[serde(rename = "priority")]
pub priority: u32,
#[serde(rename = "kind", default)]
pub kind: PluginKind,
#[serde(rename = "hotkey", default)]
pub hotkey: Option<String>,
#[serde(rename = "icon", default)]
pub icon: Option<String>,
#[serde(rename = "mode", default)]
pub mode: PluginMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum PluginMode {
#[serde(rename = "inline")]
Inline,
#[default]
#[serde(rename = "panel")]
Panel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum PluginKind {
#[serde(rename = "builtin")]
Builtin,
#[default]
#[serde(rename = "third-party")]
ThirdParty,
}
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("Plugin not found: {0}")]
NotFound(String),
#[error("Plugin initialization failed: {0}")]
InitFailed(String),
#[error("Query failed: {0}")]
QueryFailed(String),
#[error("Action execution failed: {0}")]
ActionFailed(String),
#[error("Invalid setting: {0}")]
InvalidSetting(String),
}
#[cfg(test)]
mod tests {
use super::{
PanelInteraction, PanelKeyAction, PanelKeyBinding, PanelQueryTrigger, PluginContext,
QueryChannel, QueryRevisionGate,
};
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[test]
fn query_revision_gate_tracks_latest() {
let latest = Arc::new(AtomicU64::new(2));
let gate = QueryRevisionGate::new(2, latest.clone());
assert!(gate.is_current(), "版本号与最新一致时应有效");
assert_eq!(gate.revision(), 2);
latest.fetch_add(1, Ordering::Relaxed);
assert!(!gate.is_current(), "更新的查询到达后应失效");
}
#[test]
fn plugin_context_gate_defaults_to_current() {
let mut ctx = PluginContext::new("trace-1");
assert!(ctx.is_query_current(), "无门控时应恒为最新");
assert_eq!(ctx.query_revision(), 0);
assert_eq!(
ctx.query_channel,
QueryChannel::Ui,
"未显式注入时应缺省为 GUI 通道"
);
let latest = Arc::new(AtomicU64::new(1));
ctx.set_query_revision_gate(QueryRevisionGate::new(1, latest));
assert!(ctx.is_query_current());
}
#[test]
fn plugin_context_skips_gate_in_serialization() {
let mut ctx = PluginContext::new("trace-2");
ctx.set_query_revision_gate(QueryRevisionGate::new(1, Arc::new(AtomicU64::new(1))));
let json = serde_json::to_string(&ctx).expect("上下文应可序列化");
assert!(
!json.contains("revision") && !json.contains("gate"),
"门控字段不应出现在序列化结果中: {}",
json
);
assert!(
!json.contains("handle"),
"句柄字段不应出现在序列化结果中: {}",
json
);
let roundtrip: PluginContext = serde_json::from_str(&json).expect("上下文应可反序列化");
assert!(
roundtrip.query_revision_gate.is_none(),
"反序列化后门控应为 None"
);
assert!(roundtrip.is_query_current());
assert_eq!(
roundtrip.query_channel,
QueryChannel::Ui,
"通道字段应参与序列化且缺省为 GUI 通道"
);
}
#[test]
fn query_channel_and_target_type_serialize_with_stable_keys() {
assert_eq!(
serde_json::to_value(QueryChannel::Ui).unwrap(),
json!("ui"),
"GUI 通道键名应为 ui"
);
assert_eq!(
serde_json::to_value(QueryChannel::Cli).unwrap(),
json!("cli"),
"CLI 通道键名应为 cli"
);
assert_eq!(
serde_json::to_value(QueryChannel::Panel).unwrap(),
json!("panel"),
"面板通道键名应为 panel"
);
use super::TargetType;
assert_eq!(
serde_json::to_value(TargetType::Plugin).unwrap(),
json!("Plugin"),
"插件目标类型键名应与前端词表一致"
);
assert_eq!(
serde_json::to_value(TargetType::Path).unwrap(),
json!("Path")
);
}
#[test]
fn panel_interaction_serializes_with_stable_contract() {
let interaction = PanelInteraction {
query_trigger: PanelQueryTrigger::OnEnter,
query_debounce_ms: 300,
bindings: vec![PanelKeyBinding {
key: "Enter".to_string(),
action: PanelKeyAction::Confirm,
}],
};
let value = serde_json::to_value(&interaction).expect("交互策略应可序列化");
assert_eq!(
value,
json!({
"queryTrigger": "onEnter",
"queryDebounceMs": 300,
"bindings": [{"key": "Enter", "action": {"kind": "confirm"}}],
})
);
let default_value: PanelInteraction =
serde_json::from_value(json!({})).expect("缺失交互策略字段时应使用默认值");
assert_eq!(default_value, PanelInteraction::default());
}
}