Skip to main content

nex_core/
plugin_sdk.rs

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}