Skip to main content

synaps_cli/sidecar/
discovery.rs

1//! Discover a sidecar from loaded plugin manifests.
2//!
3//! Walks the loaded plugin set and returns the first plugin that
4//! declares a sidecar binary in its manifest. Synaps CLI today supports
5//! at most one active sidecar per session.
6//!
7//! The `command` field from the manifest is resolved to an absolute
8//! path: relative paths are joined to the plugin root.
9//!
10//! ## Manifest schema
11//!
12//! Plugins declare a sidecar via `provides.sidecar` in their plugin
13//! manifest.
14
15use std::path::{Path, PathBuf};
16
17use crate::skills::manifest::{SidecarLifecycle, SidecarManifest, SidecarModel};
18use crate::skills::Plugin;
19
20/// A discovered sidecar, resolved against its plugin root and ready to
21/// be spawned by [`crate::sidecar::manager::SidecarManager`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DiscoveredSidecar {
24    /// Plugin name from the manifest.
25    pub plugin_name: String,
26    /// Absolute path to the plugin's root directory.
27    pub plugin_root: PathBuf,
28    /// Absolute path to the sidecar binary.
29    pub binary: PathBuf,
30    /// Sidecar wire-protocol version declared by the plugin.
31    pub protocol_version: u16,
32    /// Optional setup script path (resolved against plugin root).
33    pub setup_script: Option<PathBuf>,
34    /// Optional model metadata (modality-specific; opaque to core).
35    pub model: Option<SidecarModel>,
36    /// Optional plugin-claimed lifecycle UX (Phase 8). When `Some`,
37    /// core auto-registers `<lifecycle.command> toggle/status` and
38    /// uses `display_name` for the pill / status / errors. When
39    /// `None`, the plugin is reachable via the generic `/sidecar`
40    /// fallback only.
41    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
63/// Discover the first sidecar declared by any plugin in `plugins`.
64///
65/// Phase 8 transition: this is a thin wrapper over [`discover_all_in`]
66/// that returns the first result. New code should prefer
67/// [`discover_all_in`] / [`discover_all`] which return every sidecar.
68pub fn discover_in(plugins: &[Plugin]) -> Option<DiscoveredSidecar> {
69    discover_all_in(plugins).into_iter().next()
70}
71
72/// Discover every sidecar declared by any plugin in `plugins`.
73///
74/// Order matches the input plugin order — caller is responsible for
75/// sorting (e.g. by `lifecycle.importance`) if a deterministic display
76/// order is needed.
77pub 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
94/// Discover by walking the default plugin roots — a thin wrapper
95/// around [`crate::skills::loader::load_all`] for callers that don't
96/// already hold the plugin set.
97pub fn discover() -> Option<DiscoveredSidecar> {
98    discover_all().into_iter().next()
99}
100
101/// Discover every sidecar by walking the default plugin roots.
102pub 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        // Canonical `provides.sidecar` fixture.
124        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        // Phase 7 slice G: new plugins should declare `provides.sidecar`
224        // This test guards the canonical manifest shape.
225        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    // ---- Phase 8 slice 8A: lifecycle propagation + discover_all -------------
250
251    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}