1use std::path::{Path, PathBuf};
16
17use crate::skills::manifest::{SidecarLifecycle, SidecarManifest, SidecarModel};
18use crate::skills::Plugin;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DiscoveredSidecar {
24 pub plugin_name: String,
26 pub plugin_root: PathBuf,
28 pub binary: PathBuf,
30 pub protocol_version: u16,
32 pub setup_script: Option<PathBuf>,
34 pub model: Option<SidecarModel>,
36 pub lifecycle: Option<SidecarLifecycle>,
42}
43
44impl DiscoveredSidecar {
45 fn from_plugin(plugin: &Plugin, sidecar: &SidecarManifest) -> Self {
46 let binary = resolve_relative(&plugin.root, &sidecar.command);
47 let setup_script = sidecar
48 .setup
49 .as_deref()
50 .map(|s| resolve_relative(&plugin.root, s));
51 Self {
52 plugin_name: plugin.name.clone(),
53 plugin_root: plugin.root.clone(),
54 binary,
55 protocol_version: sidecar.protocol_version,
56 setup_script,
57 model: sidecar.model.clone(),
58 lifecycle: sidecar.lifecycle.clone(),
59 }
60 }
61}
62
63pub fn discover_in(plugins: &[Plugin]) -> Option<DiscoveredSidecar> {
69 discover_all_in(plugins).into_iter().next()
70}
71
72pub fn discover_all_in(plugins: &[Plugin]) -> Vec<DiscoveredSidecar> {
78 let mut out = Vec::new();
79 for plugin in plugins {
80 let Some(manifest) = plugin.manifest.as_ref() else {
81 continue;
82 };
83 let Some(provides) = manifest.provides.as_ref() else {
84 continue;
85 };
86 let Some(sidecar) = provides.sidecar.as_ref() else {
87 continue;
88 };
89 out.push(DiscoveredSidecar::from_plugin(plugin, sidecar));
90 }
91 out
92}
93
94pub fn discover() -> Option<DiscoveredSidecar> {
98 discover_all().into_iter().next()
99}
100
101pub fn discover_all() -> Vec<DiscoveredSidecar> {
103 let (plugins, _) = crate::skills::loader::load_all(&crate::skills::loader::default_roots());
104 discover_all_in(&plugins)
105}
106
107fn resolve_relative(root: &Path, candidate: &str) -> PathBuf {
108 let path = PathBuf::from(candidate);
109 if path.is_absolute() {
110 path
111 } else {
112 root.join(path)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::skills::manifest::PluginManifest;
120 use std::path::PathBuf;
121
122 fn sidecar_plugin() -> Plugin {
123 let manifest_json = r#"{
125 "name": "sample-sidecar",
126 "provides": {
127 "sidecar": {
128 "command": "bin/sample-sidecar",
129 "setup": "scripts/setup.sh",
130 "protocol_version": 1,
131 "model": {
132 "default_path": "~/.synaps-cli/models/sample/model.bin",
133 "required": true
134 }
135 }
136 }
137 }"#;
138 let manifest: PluginManifest = serde_json::from_str(manifest_json).unwrap();
139 Plugin {
140 name: "sample-sidecar".into(),
141 root: PathBuf::from("/opt/synaps-skills/sample-sidecar"),
142 marketplace: None,
143 version: None,
144 description: None,
145 extension: None,
146 manifest: Some(manifest),
147 }
148 }
149
150 fn plain_plugin(name: &str) -> Plugin {
151 let manifest_json = format!(r#"{{"name":"{}"}}"#, name);
152 let manifest: PluginManifest = serde_json::from_str(&manifest_json).unwrap();
153 Plugin {
154 name: name.into(),
155 root: PathBuf::from(format!("/opt/synaps-skills/{}", name)),
156 marketplace: None,
157 version: None,
158 description: None,
159 extension: None,
160 manifest: Some(manifest),
161 }
162 }
163
164 #[test]
165 fn discover_returns_none_when_no_plugin_provides_a_sidecar() {
166 let plugins = vec![plain_plugin("a"), plain_plugin("b")];
167 assert_eq!(discover_in(&plugins), None);
168 }
169
170 #[test]
171 fn discover_resolves_relative_binary_under_plugin_root() {
172 let plugins = vec![sidecar_plugin()];
173 let sidecar = discover_in(&plugins).expect("sidecar plugin should be discovered");
174 assert_eq!(sidecar.plugin_name, "sample-sidecar");
175 assert_eq!(
176 sidecar.binary,
177 PathBuf::from("/opt/synaps-skills/sample-sidecar/bin/sample-sidecar")
178 );
179 assert_eq!(
180 sidecar.setup_script.as_deref(),
181 Some(PathBuf::from(
182 "/opt/synaps-skills/sample-sidecar/scripts/setup.sh"
183 ))
184 .as_deref()
185 );
186 assert_eq!(sidecar.protocol_version, 1);
187 }
188
189 #[test]
190 fn discover_keeps_absolute_binary_path_unchanged() {
191 let plugin_json = r#"{
192 "name": "abs-sidecar",
193 "provides": {
194 "sidecar": {
195 "command": "/usr/local/bin/sidecar",
196 "protocol_version": 1
197 }
198 }
199 }"#;
200 let manifest: PluginManifest = serde_json::from_str(plugin_json).unwrap();
201 let plugin = Plugin {
202 name: "abs-sidecar".into(),
203 root: PathBuf::from("/opt/abs-sidecar"),
204 marketplace: None,
205 version: None,
206 description: None,
207 extension: None,
208 manifest: Some(manifest),
209 };
210 let sidecar = discover_in(&[plugin]).expect("absolute path should be discovered");
211 assert_eq!(sidecar.binary, PathBuf::from("/usr/local/bin/sidecar"));
212 }
213
214 #[test]
215 fn discover_picks_first_plugin_with_a_sidecar() {
216 let plugins = vec![plain_plugin("zzz"), sidecar_plugin(), plain_plugin("aaa")];
217 let sidecar = discover_in(&plugins).expect("should find sidecar plugin");
218 assert_eq!(sidecar.plugin_name, "sample-sidecar");
219 }
220
221 #[test]
222 fn discover_accepts_canonical_sidecar_field() {
223 let plugin_json = r#"{
226 "name": "modality-neutral",
227 "provides": {
228 "sidecar": {
229 "command": "bin/sidecar",
230 "protocol_version": 1
231 }
232 }
233 }"#;
234 let manifest: PluginManifest = serde_json::from_str(plugin_json).unwrap();
235 let plugin = Plugin {
236 name: "modality-neutral".into(),
237 root: PathBuf::from("/opt/modality-neutral"),
238 marketplace: None,
239 version: None,
240 description: None,
241 extension: None,
242 manifest: Some(manifest),
243 };
244 let sidecar = discover_in(&[plugin]).expect("canonical field should be discovered");
245 assert_eq!(sidecar.plugin_name, "modality-neutral");
246 assert_eq!(
247 sidecar.binary,
248 PathBuf::from("/opt/modality-neutral/bin/sidecar")
249 );
250 }
251
252 fn plugin_with_lifecycle(name: &str, lifecycle_command: &str, importance: i32) -> Plugin {
255 let manifest_json = format!(
256 r#"{{
257 "name": "{name}",
258 "provides": {{
259 "sidecar": {{
260 "command": "bin/{name}-sidecar",
261 "protocol_version": 1,
262 "lifecycle": {{
263 "command": "{lifecycle_command}",
264 "settings_category": "{lifecycle_command}",
265 "display_name": "{lifecycle_command}-display",
266 "importance": {importance}
267 }}
268 }}
269 }}
270 }}"#
271 );
272 let manifest: PluginManifest = serde_json::from_str(&manifest_json).unwrap();
273 Plugin {
274 name: name.into(),
275 root: PathBuf::from(format!("/opt/{name}")),
276 marketplace: None,
277 version: None,
278 description: None,
279 extension: None,
280 manifest: Some(manifest),
281 }
282 }
283
284 #[test]
285 fn discovered_propagates_lifecycle_when_present() {
286 let plugin = plugin_with_lifecycle("p", "sensor", 50);
287 let s = discover_in(&[plugin]).expect("should discover");
288 let lc = s.lifecycle.expect("lifecycle should propagate");
289 assert_eq!(lc.command, "sensor");
290 assert_eq!(lc.importance, 50);
291 assert_eq!(lc.effective_display_name(), "sensor-display");
292 }
293
294 #[test]
295 fn discovered_lifecycle_is_none_when_absent() {
296 let plugins = vec![sidecar_plugin()];
297 let s = discover_in(&plugins).unwrap();
298 assert!(
299 s.lifecycle.is_none(),
300 "no lifecycle declared → should be None"
301 );
302 }
303
304 #[test]
305 fn discover_all_returns_every_sidecar_in_input_order() {
306 let plugins = vec![
307 plugin_with_lifecycle("a", "alpha", 10),
308 plain_plugin("no-sidecar-here"),
309 plugin_with_lifecycle("b", "beta", 90),
310 plugin_with_lifecycle("c", "gamma", -5),
311 ];
312 let all = discover_all_in(&plugins);
313 assert_eq!(all.len(), 3);
314 assert_eq!(all[0].plugin_name, "a");
315 assert_eq!(all[1].plugin_name, "b");
316 assert_eq!(all[2].plugin_name, "c");
317 }
318
319 #[test]
320 fn discover_all_returns_empty_when_no_sidecars() {
321 let plugins = vec![plain_plugin("x"), plain_plugin("y")];
322 assert!(discover_all_in(&plugins).is_empty());
323 }
324
325 #[test]
326 fn discover_in_matches_discover_all_in_first_for_compatibility() {
327 let plugins = vec![
328 plain_plugin("a"),
329 sidecar_plugin(),
330 plugin_with_lifecycle("b", "beta", 0),
331 ];
332 let single = discover_in(&plugins).unwrap();
333 let multi = discover_all_in(&plugins);
334 assert_eq!(single, multi[0]);
335 }
336}