Skip to main content

linsight_plugin_sdk/
manifest.rs

1// SPDX-FileCopyrightText: 2026 VisorCraft LLC
2// SPDX-License-Identifier: GPL-3.0-only
3
4use linsight_core::{Category, HardwareDevice, HardwareDeviceKey, SensorId, SensorKind, Unit};
5use stabby::option::Option as SOption;
6use stabby::string::String as SString;
7use stabby::vec::Vec as SVec;
8
9use crate::mirror::{RCategory, RHardwareDevice, RSensorId, RSensorKind, RUnit};
10use crate::plugin::PluginError;
11
12/// Lower bound of the scheduler's accepted sampling rate, in Hz. Below this
13/// the scheduler's period math (`1e6 / rate`) starts producing unreasonably
14/// large gaps; we floor here instead.
15pub const MIN_RATE_HZ: f32 = 0.1;
16
17/// Upper bound of the scheduler's accepted sampling rate, in Hz. Above this
18/// most sensors saturate `/sys` read latency without producing useful new
19/// data; we cap here to protect the daemon from misconfigured clients.
20pub const MAX_RATE_HZ: f32 = 20.0;
21
22// ---------------------------------------------------------------------------
23// Host-facing (std) types — what the daemon stores in its registry after
24// translating the R-mirror returned across the FFI boundary.
25// ---------------------------------------------------------------------------
26
27#[derive(Clone, Debug, PartialEq)]
28pub struct PluginManifest {
29    pub plugin_id: String,
30    pub display_name: String,
31    pub version: String,
32    pub sensors: Vec<SensorDescriptor>,
33    /// ABI v4: per-plugin hardware devices the host should integrate into
34    /// its Hardware page + nickname store. The daemon validates each
35    /// device key, ensures uniqueness within the manifest, and rejects
36    /// dangling `SensorDescriptor::device_key` references before the
37    /// manifest enters the registry. See ADR-0002.
38    pub devices: Vec<HardwareDevice>,
39}
40
41#[derive(Clone, Debug, PartialEq)]
42pub struct PluginMetadata {
43    pub plugin_id: String,
44    pub display_name: String,
45    pub version: String,
46}
47
48#[derive(Clone, Debug, PartialEq)]
49pub struct SensorDescriptor {
50    pub id: SensorId,
51    pub display_name: String,
52    pub unit: Unit,
53    pub kind: SensorKind,
54    pub category: Category,
55    pub native_rate_hz: f32,
56    pub min: Option<f64>,
57    pub max: Option<f64>,
58    pub device_id: Option<String>,
59    /// ABI v4: optional pointer to a `HardwareDevice` declared on the
60    /// owning [`PluginManifest::devices`]. The host validates referential
61    /// integrity in `host_init` — a sensor pointing at a key that's not
62    /// in the manifest's `devices` list is a `PluginError::Manifest`.
63    pub device_key: Option<HardwareDeviceKey>,
64    /// NEW: sensor tags for filtering and grouping in the UI.
65    pub tags: Vec<String>,
66}
67
68impl SensorDescriptor {
69    /// Native rate hint, clamped into the scheduler's accepted range.
70    pub fn clamped_rate_hz(&self) -> f32 {
71        self.native_rate_hz.clamp(MIN_RATE_HZ, MAX_RATE_HZ)
72    }
73}
74
75// ---------------------------------------------------------------------------
76// R-mirror types — what the stabbified trait actually returns. These are
77// what crosses the FFI boundary; the host converts to the std types above.
78// ---------------------------------------------------------------------------
79
80#[stabby::stabby]
81#[derive(Clone, Debug)]
82pub struct RSensorDescriptor {
83    pub id: RSensorId,
84    pub display_name: SString,
85    pub unit: RUnit,
86    pub kind: RSensorKind,
87    pub category: RCategory,
88    pub native_rate_hz: f32,
89    pub min: SOption<f64>,
90    pub max: SOption<f64>,
91    pub device_id: SOption<SString>,
92    /// ABI v4: optional hardware-device key (raw string form). Validated
93    /// by the host via `HardwareDeviceKey::try_new` in `host_init`.
94    pub device_key: SOption<SString>,
95    /// NEW: sensor tags for filtering and grouping in the UI.
96    pub tags: SVec<SString>,
97}
98
99#[stabby::stabby]
100#[derive(Clone, Debug)]
101pub struct RPluginManifest {
102    pub plugin_id: SString,
103    pub display_name: SString,
104    pub version: SString,
105    pub sensors: SVec<RSensorDescriptor>,
106    /// ABI v4: plugin-declared hardware devices. See ADR-0002.
107    pub devices: SVec<RHardwareDevice>,
108}
109
110#[stabby::stabby]
111#[derive(Clone, Debug)]
112pub struct RPluginMetadata {
113    pub plugin_id: SString,
114    pub display_name: SString,
115    pub version: SString,
116}
117
118// ---------------------------------------------------------------------------
119// Conversions
120// ---------------------------------------------------------------------------
121
122impl From<SensorDescriptor> for RSensorDescriptor {
123    fn from(d: SensorDescriptor) -> Self {
124        Self {
125            id: d.id.into(),
126            display_name: d.display_name.as_str().into(),
127            unit: d.unit.into(),
128            kind: d.kind.into(),
129            category: d.category.into(),
130            native_rate_hz: d.native_rate_hz,
131            min: d.min.into(),
132            max: d.max.into(),
133            device_id: d.device_id.map(|s| SString::from(s.as_str())).into(),
134            device_key: d.device_key.map(|k| SString::from(k.as_str())).into(),
135            tags: d.tags.iter().map(|t| t.as_str().into()).collect::<SVec<_>>(),
136        }
137    }
138}
139
140impl From<RSensorDescriptor> for SensorDescriptor {
141    fn from(r: RSensorDescriptor) -> Self {
142        let device_id: Option<SString> = r.device_id.into();
143        let device_key: Option<HardwareDeviceKey> = Option::from(r.device_key).map(|s: SString| {
144            HardwareDeviceKey::try_new(s.as_str().to_owned()).expect("validated by host_init")
145        });
146        Self {
147            id: r.id.into(),
148            display_name: r.display_name.as_str().to_owned(),
149            unit: r.unit.into(),
150            kind: r.kind.into(),
151            category: r.category.into(),
152            native_rate_hz: r.native_rate_hz,
153            min: r.min.into(),
154            max: r.max.into(),
155            device_id: device_id.map(|s| s.as_str().to_owned()),
156            device_key,
157            tags: crate::mirror::svec_into_std(r.tags),
158        }
159    }
160}
161
162impl From<PluginManifest> for RPluginManifest {
163    fn from(m: PluginManifest) -> Self {
164        let mut sensors = SVec::with_capacity(m.sensors.len());
165        for s in m.sensors {
166            sensors.push(s.into());
167        }
168        let mut devices = SVec::with_capacity(m.devices.len());
169        for d in m.devices {
170            devices.push(d.into());
171        }
172        Self {
173            plugin_id: m.plugin_id.as_str().into(),
174            display_name: m.display_name.as_str().into(),
175            version: m.version.as_str().into(),
176            sensors,
177            devices,
178        }
179    }
180}
181
182impl From<RPluginManifest> for PluginManifest {
183    fn from(r: RPluginManifest) -> Self {
184        // Use the shared `svec_into_std` helper rather than the
185        // previous duplicated 2N-allocation reverse-then-reverse.
186        let sensors: Vec<SensorDescriptor> = crate::mirror::svec_into_std(r.sensors);
187        let devices: Vec<HardwareDevice> = crate::mirror::svec_into_std(r.devices);
188        Self {
189            plugin_id: r.plugin_id.as_str().to_owned(),
190            display_name: r.display_name.as_str().to_owned(),
191            version: r.version.as_str().to_owned(),
192            sensors,
193            devices,
194        }
195    }
196}
197
198impl From<PluginMetadata> for RPluginMetadata {
199    fn from(m: PluginMetadata) -> Self {
200        Self {
201            plugin_id: m.plugin_id.as_str().into(),
202            display_name: m.display_name.as_str().into(),
203            version: m.version.as_str().into(),
204        }
205    }
206}
207
208impl From<RPluginMetadata> for PluginMetadata {
209    fn from(r: RPluginMetadata) -> Self {
210        Self {
211            plugin_id: r.plugin_id.as_str().to_owned(),
212            display_name: r.display_name.as_str().to_owned(),
213            version: r.version.as_str().to_owned(),
214        }
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Host-side v4 manifest validation.
220//
221// Walks the raw R-mirror (BEFORE conversion to std types so we can produce a
222// `PluginError::Manifest` instead of crashing on the conversion's
223// `expect("validated by host_init")` panic). Three rules:
224//
225//   1. Every `RHardwareDevice::key` must parse via `HardwareDeviceKey::try_new`.
226//   2. Device keys are unique within the manifest's `devices` vector.
227//   3. Every `RSensorDescriptor::device_key` (if present) names a key
228//      that appears in `devices`.
229//
230// Called from `host_init`; see ADR-0002 for context.
231// ---------------------------------------------------------------------------
232
233pub(crate) fn validate_manifest(m: &RPluginManifest) -> Result<(), PluginError> {
234    use std::collections::HashSet;
235    let mut keys = HashSet::new();
236    for dev in m.devices.iter() {
237        let key_str = dev.key.as_str();
238        if let Err(e) = HardwareDeviceKey::try_new(key_str.to_owned()) {
239            return Err(PluginError::Manifest(format!("invalid device key {:?}: {}", key_str, e)));
240        }
241        if !keys.insert(key_str.to_owned()) {
242            return Err(PluginError::Manifest(format!(
243                "duplicate device key in manifest: {:?}",
244                key_str
245            )));
246        }
247    }
248    for s in m.sensors.iter() {
249        // stabby's `Option<T>` doesn't expose Rust-level `Some`/`None`
250        // variants — `Some()` and `None()` are inherent associated
251        // functions on the type, not enum variants — so a normal
252        // pattern match is rejected as a function call in a pattern.
253        // Convert through stabby's `as_ref()` -> `Option<&T>` shape,
254        // which DOES support std-style pattern matching.
255        let opt: Option<&SString> = s.device_key.as_ref();
256        if let Some(key_s) = opt {
257            let k = key_s.as_str();
258            if !keys.contains(k) {
259                return Err(PluginError::Manifest(format!(
260                    "sensor {} references device_key {:?} not in manifest.devices",
261                    s.id.value.as_str(),
262                    k
263                )));
264            }
265        }
266    }
267    Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn sensor_descriptor_clamps_native_rate() {
276        let d = SensorDescriptor {
277            id: SensorId::new("foo"),
278            display_name: "Foo".into(),
279            unit: Unit::Percent,
280            kind: SensorKind::Scalar,
281            category: Category::Cpu,
282            native_rate_hz: 99.0,
283            min: None,
284            max: None,
285            device_id: None,
286            device_key: None,
287            tags: vec![],
288        };
289        assert_eq!(d.clamped_rate_hz(), 20.0);
290    }
291
292    #[test]
293    fn sensor_descriptor_clamps_low_rate() {
294        let d = SensorDescriptor {
295            id: SensorId::new("foo"),
296            display_name: "Foo".into(),
297            unit: Unit::Percent,
298            kind: SensorKind::Scalar,
299            category: Category::Cpu,
300            native_rate_hz: 0.001,
301            min: None,
302            max: None,
303            device_id: None,
304            device_key: None,
305            tags: vec![],
306        };
307        assert_eq!(d.clamped_rate_hz(), 0.1);
308    }
309
310    #[test]
311    fn descriptor_round_trips_through_r_mirror() {
312        let d = SensorDescriptor {
313            id: SensorId::new("net.eth0.rx_bytes"),
314            display_name: "rx".into(),
315            unit: Unit::Custom("Mb/s".into()),
316            kind: SensorKind::Counter,
317            category: Category::Network,
318            native_rate_hz: 2.0,
319            min: Some(0.0),
320            max: None,
321            device_id: Some("eth0".into()),
322            device_key: None,
323            tags: vec!["network".into()],
324        };
325        let r: RSensorDescriptor = d.clone().into();
326        let back: SensorDescriptor = r.into();
327        assert_eq!(d, back);
328    }
329
330    #[test]
331    fn manifest_round_trips_through_r_mirror() {
332        let m = PluginManifest {
333            plugin_id: "io.example".into(),
334            display_name: "Example".into(),
335            version: "1.2.3".into(),
336            sensors: vec![SensorDescriptor {
337                id: SensorId::new("ex.s"),
338                display_name: "s".into(),
339                unit: Unit::Percent,
340                kind: SensorKind::Scalar,
341                category: Category::Custom,
342                native_rate_hz: 1.0,
343                min: Some(0.0),
344                max: Some(100.0),
345                device_id: None,
346                device_key: None,
347                tags: vec![],
348            }],
349            devices: vec![],
350        };
351        let r: RPluginManifest = m.clone().into();
352        let back: PluginManifest = r.into();
353        assert_eq!(m, back);
354    }
355
356    #[test]
357    fn manifest_with_many_sensors_preserves_order() {
358        // Regression guard for the previous pop-twice antipattern: a
359        // multi-sensor manifest must round-trip with sensor descriptors
360        // in their original order (the host registry uses iteration
361        // order to assign plugin indices).
362        let sensors: Vec<SensorDescriptor> = (0..10)
363            .map(|i| SensorDescriptor {
364                id: SensorId::new(format!("sensor.{i}")),
365                display_name: format!("Sensor {i}"),
366                unit: Unit::Count,
367                kind: SensorKind::Scalar,
368                category: Category::Custom,
369                native_rate_hz: 1.0,
370                min: None,
371                max: None,
372                device_id: None,
373                device_key: None,
374                tags: vec![],
375            })
376            .collect();
377        let m = PluginManifest {
378            plugin_id: "io.example".into(),
379            display_name: "Example".into(),
380            version: "1.2.3".into(),
381            sensors: sensors.clone(),
382            devices: vec![],
383        };
384        let r: RPluginManifest = m.into();
385        let back: PluginManifest = r.into();
386        let back_ids: Vec<String> = back.sensors.iter().map(|s| s.id.to_string()).collect();
387        let expect_ids: Vec<String> = sensors.iter().map(|s| s.id.to_string()).collect();
388        assert_eq!(back_ids, expect_ids, "sensor order must be preserved through FFI mirror");
389    }
390
391    // -----------------------------------------------------------------------
392    // host_init manifest-validation tests (ABI v4).
393    //
394    // These exercise `validate_manifest` against synthetic R-mirror
395    // manifests. Building the R-mirror by hand (rather than via the
396    // std-typed `PluginManifest` and `.into()`) lets us inject invariants
397    // the std API would reject up-front — duplicate keys, malformed keys,
398    // and dangling `device_key` references all need the raw-FFI form.
399    // -----------------------------------------------------------------------
400
401    use crate::mirror::{RHardwareCategoryKind, RHardwareDevice};
402
403    fn make_minimal_manifest() -> RPluginManifest {
404        RPluginManifest {
405            plugin_id: "test.minimal".into(),
406            display_name: "Minimal".into(),
407            version: "0.0.1".into(),
408            sensors: SVec::new(),
409            devices: SVec::new(),
410        }
411    }
412
413    fn r_dev(key: &str, category: RHardwareCategoryKind, model: &str) -> RHardwareDevice {
414        RHardwareDevice {
415            key: key.into(),
416            category_kind: category,
417            model: model.into(),
418            vendor: SOption::None(),
419            location: SOption::None(),
420            plugin_device_id: "dev0".into(),
421        }
422    }
423
424    fn make_minimal_manifest_with_one_device() -> RPluginManifest {
425        let mut m = make_minimal_manifest();
426        let mut devs = SVec::new();
427        devs.push(r_dev("pci:0000:06:00.0", RHardwareCategoryKind::Gpu, "Arc B-series"));
428        m.devices = devs;
429        m
430    }
431
432    #[test]
433    fn host_init_rejects_invalid_device_key() {
434        let mut m = make_minimal_manifest();
435        let mut devs = SVec::new();
436        devs.push(r_dev("BAD_KEY", RHardwareCategoryKind::Gpu, "bogus"));
437        m.devices = devs;
438        let err = validate_manifest(&m).unwrap_err();
439        match err {
440            PluginError::Manifest(msg) => {
441                assert!(msg.contains("BAD_KEY"), "expected error to name the bad key; got: {msg}",);
442            }
443            other => panic!("expected PluginError::Manifest, got {other:?}"),
444        }
445    }
446
447    #[test]
448    fn host_init_rejects_sensor_pointing_at_absent_device() {
449        let mut m = make_minimal_manifest_with_one_device();
450        let mut sensors = SVec::new();
451        sensors.push(RSensorDescriptor {
452            id: RSensorId { value: "test.dangling".into() },
453            display_name: "Dangling".into(),
454            unit: crate::mirror::RUnit {
455                kind: crate::mirror::RUnitKind::Count,
456                custom: SOption::None(),
457            },
458            kind: RSensorKind::Scalar,
459            category: RCategory::Custom,
460            native_rate_hz: 1.0,
461            min: SOption::None(),
462            max: SOption::None(),
463            device_id: SOption::None(),
464            device_key: SOption::Some(SString::from("pci:0000:99:00.0")),
465            tags: SVec::new(),
466        });
467        m.sensors = sensors;
468        let err = validate_manifest(&m).unwrap_err();
469        match err {
470            PluginError::Manifest(msg) => {
471                assert!(
472                    msg.contains("test.dangling") && msg.contains("pci:0000:99:00.0"),
473                    "expected error to name the sensor and the dangling key; got: {msg}",
474                );
475            }
476            other => panic!("expected PluginError::Manifest, got {other:?}"),
477        }
478    }
479
480    #[test]
481    fn host_init_rejects_duplicate_device_keys_within_manifest() {
482        let mut m = make_minimal_manifest();
483        let mut devs = SVec::new();
484        devs.push(r_dev("pci:0000:06:00.0", RHardwareCategoryKind::Gpu, "first"));
485        devs.push(r_dev("pci:0000:06:00.0", RHardwareCategoryKind::Gpu, "duplicate"));
486        m.devices = devs;
487        let err = validate_manifest(&m).unwrap_err();
488        match err {
489            PluginError::Manifest(msg) => {
490                assert!(
491                    msg.contains("duplicate") && msg.contains("pci:0000:06:00.0"),
492                    "expected error to flag duplicate and the key; got: {msg}",
493                );
494            }
495            other => panic!("expected PluginError::Manifest, got {other:?}"),
496        }
497    }
498
499    #[test]
500    fn host_init_accepts_valid_v4_manifest_with_devices_and_sensor_reference() {
501        // Positive smoke test: a well-formed v4 manifest with one device
502        // and one sensor that points at it should validate cleanly.
503        let mut m = make_minimal_manifest_with_one_device();
504        let mut sensors = SVec::new();
505        sensors.push(RSensorDescriptor {
506            id: RSensorId { value: "test.linked".into() },
507            display_name: "Linked".into(),
508            unit: crate::mirror::RUnit {
509                kind: crate::mirror::RUnitKind::Percent,
510                custom: SOption::None(),
511            },
512            kind: RSensorKind::Scalar,
513            category: RCategory::Gpu,
514            native_rate_hz: 1.0,
515            min: SOption::None(),
516            max: SOption::None(),
517            device_id: SOption::None(),
518            device_key: SOption::Some(SString::from("pci:0000:06:00.0")),
519            tags: SVec::new(),
520        });
521        m.sensors = sensors;
522        assert!(validate_manifest(&m).is_ok());
523    }
524}