1use crate::config::Config;
2use crate::model::SearchItem;
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum PluginActionKind {
9 OpenPath { path: String },
10 Command { command: String, args: Vec<String> },
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct PluginAction {
15 pub result_id: String,
16 pub plugin_id: String,
17 pub action_id: String,
18 pub title: String,
19 pub subtitle: String,
20 pub keywords: Vec<String>,
21 pub kind: PluginActionKind,
22}
23
24#[derive(Debug, Default, Clone)]
25pub struct PluginRegistry {
26 pub provider_items: Vec<SearchItem>,
27 pub action_items: Vec<SearchItem>,
28 pub actions_by_result_id: HashMap<String, PluginAction>,
29 pub load_warnings: Vec<String>,
30}
31
32impl PluginRegistry {
33 pub fn load_from_config(cfg: &Config) -> Self {
34 if !cfg.plugins_enabled {
35 return Self::default();
36 }
37
38 let mut registry = Self::default();
39 for path in &cfg.plugin_paths {
40 for manifest_path in discover_manifest_paths(path) {
41 match load_manifest(&manifest_path) {
42 Ok(manifest) => append_manifest(&mut registry, manifest),
43 Err(error) => registry.load_warnings.push(format!(
44 "plugin manifest '{}' failed: {error}",
45 manifest_path.display()
46 )),
47 }
48 }
49 }
50 registry
51 }
52}
53
54#[derive(Debug, Deserialize)]
55#[serde(default)]
56struct PluginManifest {
57 id: String,
58 name: String,
59 version: String,
60 enabled: bool,
61 provider_items: Vec<ManifestProviderItem>,
62 actions: Vec<ManifestAction>,
63}
64
65impl Default for PluginManifest {
66 fn default() -> Self {
67 Self {
68 id: String::new(),
69 name: String::new(),
70 version: String::new(),
71 enabled: true,
72 provider_items: Vec::new(),
73 actions: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Deserialize, Default)]
79#[serde(default)]
80struct ManifestProviderItem {
81 id: String,
82 kind: String,
83 title: String,
84 path: String,
85}
86
87#[derive(Debug, Deserialize)]
88#[serde(default)]
89struct ManifestAction {
90 id: String,
91 title: String,
92 subtitle: String,
93 keywords: Vec<String>,
94 #[serde(rename = "type")]
95 action_type: String,
96 path: String,
97 command: String,
98 args: Vec<String>,
99}
100
101impl Default for ManifestAction {
102 fn default() -> Self {
103 Self {
104 id: String::new(),
105 title: String::new(),
106 subtitle: String::new(),
107 keywords: Vec::new(),
108 action_type: "open_path".to_string(),
109 path: String::new(),
110 command: String::new(),
111 args: Vec::new(),
112 }
113 }
114}
115
116fn discover_manifest_paths(path: &Path) -> Vec<PathBuf> {
117 if path.is_file() {
118 return vec![path.to_path_buf()];
119 }
120 if !path.is_dir() {
121 return Vec::new();
122 }
123
124 let mut out = Vec::new();
125 if let Ok(entries) = std::fs::read_dir(path) {
126 for entry in entries.flatten() {
127 let entry_path = entry.path();
128 if entry_path.is_file()
129 && entry_path
130 .extension()
131 .and_then(|v| v.to_str())
132 .is_some_and(|v| v.eq_ignore_ascii_case("json"))
133 {
134 out.push(entry_path);
135 }
136 }
137 }
138 out
139}
140
141fn load_manifest(path: &Path) -> Result<PluginManifest, String> {
142 let raw = std::fs::read_to_string(path)
143 .map_err(|e| format!("read failed for '{}': {e}", path.display()))?;
144 let manifest: PluginManifest = serde_json::from_str(&raw)
145 .map_err(|e| format!("invalid json in '{}': {e}", path.display()))?;
146 if manifest.id.trim().is_empty() {
147 return Err("missing plugin id".to_string());
148 }
149 Ok(manifest)
150}
151
152fn append_manifest(registry: &mut PluginRegistry, manifest: PluginManifest) {
153 if !manifest.enabled {
154 return;
155 }
156
157 let plugin_id = manifest.id.trim().to_string();
158 let plugin_label = if manifest.name.trim().is_empty() {
159 plugin_id.clone()
160 } else {
161 manifest.name.trim().to_string()
162 };
163
164 for item in manifest.provider_items {
165 let item_id = item.id.trim();
166 let title = item.title.trim();
167 if item_id.is_empty() || title.is_empty() {
168 continue;
169 }
170 let result_id = format!("plugin:{plugin_id}:item:{item_id}");
171 let kind = if item.kind.trim().is_empty() {
172 "file".to_string()
173 } else {
174 item.kind.trim().to_string()
175 };
176 registry
177 .provider_items
178 .push(SearchItem::new(&result_id, &kind, title, item.path.trim()));
179 }
180
181 for action in manifest.actions {
182 let action_id = action.id.trim();
183 let action_title = action.title.trim();
184 if action_id.is_empty() || action_title.is_empty() {
185 continue;
186 }
187 let result_id = format!("plugin:{plugin_id}:action:{action_id}");
188 let subtitle = if action.subtitle.trim().is_empty() {
189 format!("{plugin_label} plugin action")
190 } else {
191 action.subtitle.trim().to_string()
192 };
193 let kind = parse_action_kind(&action);
194 let plugin_action = PluginAction {
195 result_id: result_id.clone(),
196 plugin_id: plugin_id.clone(),
197 action_id: action_id.to_string(),
198 title: action_title.to_string(),
199 subtitle: subtitle.clone(),
200 keywords: action.keywords,
201 kind,
202 };
203 let keyword_suffix = if plugin_action.keywords.is_empty() {
204 String::new()
205 } else {
206 format!(" {}", plugin_action.keywords.join(" "))
207 };
208 registry.action_items.push(SearchItem::new(
209 &result_id,
210 "action",
211 action_title,
212 &format!("{subtitle}{keyword_suffix}"),
213 ));
214 registry
215 .actions_by_result_id
216 .insert(result_id, plugin_action);
217 }
218}
219
220fn parse_action_kind(action: &ManifestAction) -> PluginActionKind {
221 let normalized = action.action_type.trim().to_ascii_lowercase();
222 if normalized == "command" {
223 return PluginActionKind::Command {
224 command: action.command.trim().to_string(),
225 args: action.args.clone(),
226 };
227 }
228 PluginActionKind::OpenPath {
229 path: action.path.trim().to_string(),
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::parse_action_kind;
236 use super::{ManifestAction, PluginActionKind};
237
238 #[test]
239 fn parses_command_action_kind() {
240 let action = ManifestAction {
241 action_type: "command".to_string(),
242 command: "cmd".to_string(),
243 args: vec!["/C".to_string(), "echo".to_string()],
244 ..Default::default()
245 };
246 let kind = parse_action_kind(&action);
247 assert!(matches!(kind, PluginActionKind::Command { .. }));
248 }
249}