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!(sidecar.binary, PathBuf::from("/opt/modality-neutral/bin/sidecar"));
247 }
248
249 fn plugin_with_lifecycle(name: &str, lifecycle_command: &str, importance: i32) -> Plugin {
252 let manifest_json = format!(
253 r#"{{
254 "name": "{name}",
255 "provides": {{
256 "sidecar": {{
257 "command": "bin/{name}-sidecar",
258 "protocol_version": 1,
259 "lifecycle": {{
260 "command": "{lifecycle_command}",
261 "settings_category": "{lifecycle_command}",
262 "display_name": "{lifecycle_command}-display",
263 "importance": {importance}
264 }}
265 }}
266 }}
267 }}"#
268 );
269 let manifest: PluginManifest = serde_json::from_str(&manifest_json).unwrap();
270 Plugin {
271 name: name.into(),
272 root: PathBuf::from(format!("/opt/{name}")),
273 marketplace: None,
274 version: None,
275 description: None,
276 extension: None,
277 manifest: Some(manifest),
278 }
279 }
280
281 #[test]
282 fn discovered_propagates_lifecycle_when_present() {
283 let plugin = plugin_with_lifecycle("p", "sensor", 50);
284 let s = discover_in(&[plugin]).expect("should discover");
285 let lc = s.lifecycle.expect("lifecycle should propagate");
286 assert_eq!(lc.command, "sensor");
287 assert_eq!(lc.importance, 50);
288 assert_eq!(lc.effective_display_name(), "sensor-display");
289 }
290
291 #[test]
292 fn discovered_lifecycle_is_none_when_absent() {
293 let plugins = vec![sidecar_plugin()];
294 let s = discover_in(&plugins).unwrap();
295 assert!(s.lifecycle.is_none(), "no lifecycle declared → should be None");
296 }
297
298 #[test]
299 fn discover_all_returns_every_sidecar_in_input_order() {
300 let plugins = vec![
301 plugin_with_lifecycle("a", "alpha", 10),
302 plain_plugin("no-sidecar-here"),
303 plugin_with_lifecycle("b", "beta", 90),
304 plugin_with_lifecycle("c", "gamma", -5),
305 ];
306 let all = discover_all_in(&plugins);
307 assert_eq!(all.len(), 3);
308 assert_eq!(all[0].plugin_name, "a");
309 assert_eq!(all[1].plugin_name, "b");
310 assert_eq!(all[2].plugin_name, "c");
311 }
312
313 #[test]
314 fn discover_all_returns_empty_when_no_sidecars() {
315 let plugins = vec![plain_plugin("x"), plain_plugin("y")];
316 assert!(discover_all_in(&plugins).is_empty());
317 }
318
319 #[test]
320 fn discover_in_matches_discover_all_in_first_for_compatibility() {
321 let plugins = vec![
322 plain_plugin("a"),
323 sidecar_plugin(),
324 plugin_with_lifecycle("b", "beta", 0),
325 ];
326 let single = discover_in(&plugins).unwrap();
327 let multi = discover_all_in(&plugins);
328 assert_eq!(single, multi[0]);
329 }
330}