Skip to main content

linsight_plugin_sdk/
mirror.rs

1// SPDX-FileCopyrightText: 2026 VisorCraft LLC
2// SPDX-License-Identifier: GPL-3.0-only
3//
4// R-mirror types — `#[stabby::stabby]`-annotated counterparts to the
5// `linsight-core` value types. These cross the plugin FFI boundary
6// (vtable parameter / return slots) because std `String`, `Vec` and
7// `Option` are not `IStable` and therefore cannot appear in a
8// stabbified trait's vtable.
9//
10// **ABI v3 note on enum encoding.** Earlier versions used
11// `#[stabby::stabby] #[repr(stabby)] enum` for tagged unions (RUnit,
12// RReading, RCell). stabby's tagged-enum representation works
13// correctly for `#[repr(u8)]` unit-only enums (RSensorKind,
14// RCategory), but for enums with both unit AND payload variants the
15// generated `match_owned` / `match_ref` dispatchers misroute closures
16// at `opt-level >= 1`. Confirmed bug-for-bug reproducible on stabby
17// 36.2.2: a Percent value round-trips to Celsius, a Scalar to
18// Counter, etc. — a one-off variant misdispatch in the recursive
19// Result-tree the macro emits. Debug builds pass; release builds
20// silently corrupt the wire data.
21//
22// To eliminate the dependency on stabby's enum matcher, every former
23// tagged enum is now a `(kind, payload_fields)` struct: an explicit
24// `#[repr(u8)]` discriminant plus payload fields that are only valid
25// when the corresponding variant is active. The host translates via
26// trivial Rust `match`-on-the-kind expressions that don't rely on
27// stabby-generated dispatch.
28//
29// This is a wire-format break vs ABI v2 — hence
30// `LINSIGHT_PLUGIN_ABI_VERSION = 3` and the renamed factory symbol
31// (`linsight_plugin_v3`). The daemon's version-symbol check refuses
32// any v2 .so at load time.
33//
34// **ABI v4 note.** v4 keeps the same R-mirror encoding scheme as v3.
35// The break is in `RPluginManifest` and `RSensorDescriptor`: the
36// former grows `devices: SVec<RHardwareDevice>`, the latter grows
37// `device_key: SOption<SString>`. The factory symbol moved to
38// `linsight_plugin_v4` so v3 plugins fail symbol lookup at load
39// time. See ADR-0002 and `RHardwareCategoryKind` / `RHardwareDevice`
40// below.
41
42use linsight_core::{Category, Cell, Reading, SensorId, SensorKind, TableRow, Unit};
43use stabby::option::Option as SOption;
44use stabby::string::String as SString;
45use stabby::vec::Vec as SVec;
46
47// ---------------------------------------------------------------------------
48// RSensorId — newtype wrapper carrying a stabby String.
49// ---------------------------------------------------------------------------
50
51/// FFI-safe mirror of [`SensorId`]. Carries an opaque UTF-8 string;
52/// plugins should construct via `SensorId::new(...).into()` rather than
53/// touching the `value` field directly.
54///
55/// **Validation note:** the host runs every `RSensorId` produced by a
56/// plugin through `SensorId::try_new` in [`crate::host_init`] before it
57/// is allowed into the daemon's registry. A plugin that emits an empty
58/// or whitespace-bearing string here is rejected with
59/// [`PluginError::Parse`](crate::PluginError::Parse).
60#[stabby::stabby]
61#[derive(Clone, Debug)]
62pub struct RSensorId {
63    pub value: SString,
64}
65
66impl From<SensorId> for RSensorId {
67    fn from(id: SensorId) -> Self {
68        Self { value: id.as_str().into() }
69    }
70}
71
72impl From<&SensorId> for RSensorId {
73    fn from(id: &SensorId) -> Self {
74        Self { value: id.as_str().into() }
75    }
76}
77
78impl From<RSensorId> for SensorId {
79    fn from(r: RSensorId) -> Self {
80        SensorId::new(r.value.as_str())
81    }
82}
83
84// ---------------------------------------------------------------------------
85// RUnit (struct kind+payload)
86// ---------------------------------------------------------------------------
87
88/// Discriminant for [`RUnit`]. Unit-only enum, fixed `#[repr(u8)]`
89/// layout; new variants are wire-format-breaking and require an ABI
90/// bump.
91#[stabby::stabby]
92#[repr(u8)]
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum RUnitKind {
95    Percent,
96    Celsius,
97    Bytes,
98    BytesPerSec,
99    Hertz,
100    Watts,
101    Volts,
102    Rpm,
103    Count,
104    Custom,
105}
106
107/// FFI-safe mirror of [`Unit`]. Encoded as a `kind` discriminant plus a
108/// `custom` payload that is `Some(label)` only when `kind == Custom`.
109/// All other variants set `custom: None`.
110#[stabby::stabby]
111#[derive(Clone, Debug)]
112pub struct RUnit {
113    pub kind: RUnitKind,
114    pub custom: SOption<SString>,
115}
116
117impl From<Unit> for RUnit {
118    fn from(u: Unit) -> Self {
119        match u {
120            Unit::Percent => Self { kind: RUnitKind::Percent, custom: SOption::None() },
121            Unit::Celsius => Self { kind: RUnitKind::Celsius, custom: SOption::None() },
122            Unit::Bytes => Self { kind: RUnitKind::Bytes, custom: SOption::None() },
123            Unit::BytesPerSec => Self { kind: RUnitKind::BytesPerSec, custom: SOption::None() },
124            Unit::Hertz => Self { kind: RUnitKind::Hertz, custom: SOption::None() },
125            Unit::Watts => Self { kind: RUnitKind::Watts, custom: SOption::None() },
126            Unit::Volts => Self { kind: RUnitKind::Volts, custom: SOption::None() },
127            Unit::Rpm => Self { kind: RUnitKind::Rpm, custom: SOption::None() },
128            Unit::Count => Self { kind: RUnitKind::Count, custom: SOption::None() },
129            Unit::Custom(s) => {
130                Self { kind: RUnitKind::Custom, custom: SOption::Some(s.as_str().into()) }
131            }
132        }
133    }
134}
135
136impl From<RUnit> for Unit {
137    fn from(r: RUnit) -> Self {
138        match r.kind {
139            RUnitKind::Percent => Unit::Percent,
140            RUnitKind::Celsius => Unit::Celsius,
141            RUnitKind::Bytes => Unit::Bytes,
142            RUnitKind::BytesPerSec => Unit::BytesPerSec,
143            RUnitKind::Hertz => Unit::Hertz,
144            RUnitKind::Watts => Unit::Watts,
145            RUnitKind::Volts => Unit::Volts,
146            RUnitKind::Rpm => Unit::Rpm,
147            RUnitKind::Count => Unit::Count,
148            RUnitKind::Custom => {
149                // The wire format places the label in `custom`; an
150                // empty payload here is a malformed message and we
151                // surface it as an explicit fallback rather than
152                // panicking at the FFI seam.
153                let opt: Option<&SString> = r.custom.as_ref();
154                let label = opt.map(|s| s.as_str().to_owned()).unwrap_or_default();
155                Unit::Custom(label)
156            }
157        }
158    }
159}
160
161// ---------------------------------------------------------------------------
162// RSensorKind
163// ---------------------------------------------------------------------------
164
165/// FFI-safe mirror of [`SensorKind`]. Unit-only enum encoded as a single
166/// byte — adding a new variant is a wire-format breaking change.
167#[stabby::stabby]
168#[repr(u8)]
169#[derive(Clone, Copy, Debug)]
170pub enum RSensorKind {
171    Scalar,
172    Counter,
173    Table,
174    State,
175}
176
177impl From<SensorKind> for RSensorKind {
178    fn from(k: SensorKind) -> Self {
179        match k {
180            SensorKind::Scalar => RSensorKind::Scalar,
181            SensorKind::Counter => RSensorKind::Counter,
182            SensorKind::Table => RSensorKind::Table,
183            SensorKind::State => RSensorKind::State,
184        }
185    }
186}
187
188impl From<RSensorKind> for SensorKind {
189    fn from(r: RSensorKind) -> Self {
190        match r {
191            RSensorKind::Scalar => SensorKind::Scalar,
192            RSensorKind::Counter => SensorKind::Counter,
193            RSensorKind::Table => SensorKind::Table,
194            RSensorKind::State => SensorKind::State,
195        }
196    }
197}
198
199// ---------------------------------------------------------------------------
200// RCategory
201// ---------------------------------------------------------------------------
202
203/// FFI-safe mirror of [`Category`]. Same wire-format-stable contract as
204/// [`RSensorKind`].
205#[stabby::stabby]
206#[repr(u8)]
207#[derive(Clone, Copy, Debug)]
208pub enum RCategory {
209    Cpu,
210    Gpu,
211    Memory,
212    Storage,
213    Network,
214    Custom,
215}
216
217impl From<Category> for RCategory {
218    fn from(c: Category) -> Self {
219        match c {
220            Category::Cpu => RCategory::Cpu,
221            Category::Gpu => RCategory::Gpu,
222            Category::Memory => RCategory::Memory,
223            Category::Storage => RCategory::Storage,
224            Category::Network => RCategory::Network,
225            Category::Custom => RCategory::Custom,
226        }
227    }
228}
229
230impl From<RCategory> for Category {
231    fn from(r: RCategory) -> Self {
232        match r {
233            RCategory::Cpu => Category::Cpu,
234            RCategory::Gpu => Category::Gpu,
235            RCategory::Memory => Category::Memory,
236            RCategory::Storage => Category::Storage,
237            RCategory::Network => Category::Network,
238            RCategory::Custom => Category::Custom,
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// RHardwareCategoryKind
245// ---------------------------------------------------------------------------
246
247/// FFI-stable discriminant for `linsight_core::HardwareCategory`. Per
248/// ADR-0001 v3 lessons, ALL discriminants are `#[repr(u8)]` unit-only
249/// enums; payload-bearing variants live on a sibling struct (see
250/// `RHardwareDevice`). This avoids the stabby `match_owned` release-mode
251/// bug entirely.
252#[stabby::stabby]
253#[repr(u8)]
254#[derive(Clone, Copy, Debug, PartialEq)]
255pub enum RHardwareCategoryKind {
256    Gpu,
257    Storage,
258    Network,
259    Cpu,
260    Other,
261}
262
263impl From<linsight_core::HardwareCategory> for RHardwareCategoryKind {
264    fn from(c: linsight_core::HardwareCategory) -> Self {
265        match c {
266            linsight_core::HardwareCategory::Gpu => Self::Gpu,
267            linsight_core::HardwareCategory::Storage => Self::Storage,
268            linsight_core::HardwareCategory::Network => Self::Network,
269            linsight_core::HardwareCategory::Cpu => Self::Cpu,
270            linsight_core::HardwareCategory::Other => Self::Other,
271        }
272    }
273}
274
275impl From<RHardwareCategoryKind> for linsight_core::HardwareCategory {
276    fn from(r: RHardwareCategoryKind) -> Self {
277        match r {
278            RHardwareCategoryKind::Gpu => Self::Gpu,
279            RHardwareCategoryKind::Storage => Self::Storage,
280            RHardwareCategoryKind::Network => Self::Network,
281            RHardwareCategoryKind::Cpu => Self::Cpu,
282            RHardwareCategoryKind::Other => Self::Other,
283        }
284    }
285}
286
287// ---------------------------------------------------------------------------
288// RHardwareDevice
289// ---------------------------------------------------------------------------
290
291/// FFI-stable mirror of `linsight_core::HardwareDevice`. The plugin
292/// emits these alongside its sensors; the daemon validates each one
293/// before integrating into its registry.
294///
295/// Note: `plugin_id` and `sensor_ids` are NOT on the wire from plugin
296/// to host — the daemon fills `plugin_id` from the loader's knowledge
297/// and `sensor_ids` from the manifest's sensors list. Including them
298/// in the FFI mirror would invite plugins to lie about either.
299#[stabby::stabby]
300#[repr(C)]
301#[derive(Clone, Debug)]
302pub struct RHardwareDevice {
303    pub key: SString,
304    pub category_kind: RHardwareCategoryKind,
305    pub model: SString,
306    pub vendor: SOption<SString>,
307    pub location: SOption<SString>,
308    pub plugin_device_id: SString,
309}
310
311impl From<linsight_core::HardwareDevice> for RHardwareDevice {
312    fn from(d: linsight_core::HardwareDevice) -> Self {
313        Self {
314            key: SString::from(d.key.as_str()),
315            category_kind: d.category.into(),
316            model: SString::from(d.model.as_str()),
317            vendor: d.vendor.map(|s| SString::from(s.as_str())).into(),
318            location: d.location.map(|s| SString::from(s.as_str())).into(),
319            plugin_device_id: SString::from(d.plugin_device_id.as_str()),
320        }
321    }
322}
323
324impl From<RHardwareDevice> for linsight_core::HardwareDevice {
325    fn from(r: RHardwareDevice) -> Self {
326        Self {
327            key: linsight_core::HardwareDeviceKey::try_new(r.key.as_str().to_owned())
328                .expect("RHardwareDevice key was validated by host_init"),
329            category: r.category_kind.into(),
330            model: r.model.as_str().to_owned(),
331            vendor: Option::from(r.vendor).map(|s: SString| s.as_str().to_owned()),
332            location: Option::from(r.location).map(|s: SString| s.as_str().to_owned()),
333            plugin_id: String::new(),
334            plugin_device_id: r.plugin_device_id.as_str().to_owned(),
335            sensor_ids: vec![],
336        }
337    }
338}
339
340// ---------------------------------------------------------------------------
341// RCell (struct kind+payload)
342// ---------------------------------------------------------------------------
343
344/// Discriminant for [`RCell`].
345#[stabby::stabby]
346#[repr(u8)]
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
348pub enum RCellKind {
349    Text,
350    Number,
351    Bytes,
352}
353
354/// FFI-safe mirror of [`Cell`] — a single cell in a Table reading.
355/// Three parallel payload fields; the active one is selected by
356/// `kind`. Inactive fields carry default values that are never read.
357#[stabby::stabby]
358#[derive(Clone, Debug)]
359pub struct RCell {
360    pub kind: RCellKind,
361    pub text: SOption<SString>,
362    pub number: f64,
363    pub bytes: u64,
364}
365
366impl From<Cell> for RCell {
367    fn from(c: Cell) -> Self {
368        match c {
369            Cell::Text(s) => Self {
370                kind: RCellKind::Text,
371                text: SOption::Some(s.as_str().into()),
372                number: 0.0,
373                bytes: 0,
374            },
375            Cell::Number(n) => {
376                Self { kind: RCellKind::Number, text: SOption::None(), number: n, bytes: 0 }
377            }
378            Cell::Bytes(b) => {
379                Self { kind: RCellKind::Bytes, text: SOption::None(), number: 0.0, bytes: b }
380            }
381        }
382    }
383}
384
385impl From<RCell> for Cell {
386    fn from(r: RCell) -> Self {
387        match r.kind {
388            RCellKind::Text => {
389                let opt: Option<&SString> = r.text.as_ref();
390                Cell::Text(opt.map(|s| s.as_str().to_owned()).unwrap_or_default())
391            }
392            RCellKind::Number => Cell::Number(r.number),
393            RCellKind::Bytes => Cell::Bytes(r.bytes),
394        }
395    }
396}
397
398/// FFI-safe mirror of [`TableRow`] — one row of an arbitrary-width
399/// [`RReading::Table`].
400#[stabby::stabby]
401#[derive(Clone, Debug)]
402pub struct RTableRow {
403    pub cells: SVec<RCell>,
404}
405
406impl From<TableRow> for RTableRow {
407    fn from(row: TableRow) -> Self {
408        let mut cells = SVec::with_capacity(row.cells.len());
409        for c in row.cells {
410            cells.push(c.into());
411        }
412        Self { cells }
413    }
414}
415
416impl From<RTableRow> for TableRow {
417    fn from(r: RTableRow) -> Self {
418        TableRow { cells: svec_into_std(r.cells) }
419    }
420}
421
422// ---------------------------------------------------------------------------
423// RReading (struct kind+payload)
424// ---------------------------------------------------------------------------
425
426/// Discriminant for [`RReading`].
427#[stabby::stabby]
428#[repr(u8)]
429#[derive(Clone, Copy, Debug, PartialEq, Eq)]
430pub enum RReadingKind {
431    Scalar,
432    Counter,
433    Table,
434    State,
435}
436
437/// FFI-safe mirror of [`Reading`] — every sample a plugin returns is
438/// one of these variants, selected by `kind`. The four payload fields
439/// (scalar / counter / state / table) live alongside the discriminant
440/// and are read individually based on `kind`. Inactive fields carry
441/// defaults that callers must not read.
442#[stabby::stabby]
443#[derive(Clone, Debug)]
444pub struct RReading {
445    pub kind: RReadingKind,
446    pub scalar: f64,
447    pub counter: u64,
448    pub state: SOption<SString>,
449    pub table: SVec<RTableRow>,
450}
451
452impl From<Reading> for RReading {
453    fn from(r: Reading) -> Self {
454        match r {
455            Reading::Scalar(v) => Self {
456                kind: RReadingKind::Scalar,
457                scalar: v,
458                counter: 0,
459                state: SOption::None(),
460                table: SVec::new(),
461            },
462            Reading::Counter(v) => Self {
463                kind: RReadingKind::Counter,
464                scalar: 0.0,
465                counter: v,
466                state: SOption::None(),
467                table: SVec::new(),
468            },
469            Reading::State(s) => Self {
470                kind: RReadingKind::State,
471                scalar: 0.0,
472                counter: 0,
473                state: SOption::Some(s.as_str().into()),
474                table: SVec::new(),
475            },
476            Reading::Table(rows) => {
477                let mut out = SVec::with_capacity(rows.len());
478                for row in rows {
479                    out.push(row.into());
480                }
481                Self {
482                    kind: RReadingKind::Table,
483                    scalar: 0.0,
484                    counter: 0,
485                    state: SOption::None(),
486                    table: out,
487                }
488            }
489        }
490    }
491}
492
493impl From<RReading> for Reading {
494    fn from(r: RReading) -> Self {
495        match r.kind {
496            RReadingKind::Scalar => Reading::Scalar(r.scalar),
497            RReadingKind::Counter => Reading::Counter(r.counter),
498            RReadingKind::State => {
499                let opt: Option<&SString> = r.state.as_ref();
500                Reading::State(opt.map(|s| s.as_str().to_owned()).unwrap_or_default())
501            }
502            RReadingKind::Table => Reading::Table(svec_into_std(r.table)),
503        }
504    }
505}
506
507// ---------------------------------------------------------------------------
508// Helpers
509// ---------------------------------------------------------------------------
510
511/// Drain a `stabby::vec::Vec<R>` into a `std::vec::Vec<T>` using
512/// the `From<R>` impl on `T`, preserving original order.
513pub(crate) fn svec_into_std<R, T>(mut v: SVec<R>) -> Vec<T>
514where
515    T: From<R>,
516    R: stabby::IStable + Clone,
517{
518    let len = v.len();
519    let mut out: Vec<T> = Vec::with_capacity(len);
520    while let Some(item) = v.pop() {
521        out.push(T::from(item));
522    }
523    out.reverse();
524    out
525}
526
527// ---------------------------------------------------------------------------
528// Tests
529// ---------------------------------------------------------------------------
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn unit_round_trips() {
537        for u in [
538            Unit::Percent,
539            Unit::Celsius,
540            Unit::Bytes,
541            Unit::BytesPerSec,
542            Unit::Hertz,
543            Unit::Watts,
544            Unit::Volts,
545            Unit::Rpm,
546            Unit::Count,
547            Unit::Custom("Mb/s".into()),
548        ] {
549            let r: RUnit = u.clone().into();
550            let back: Unit = r.into();
551            assert_eq!(u, back, "round trip failed for variant {u:?}");
552        }
553    }
554
555    #[test]
556    fn sensor_kind_round_trips() {
557        for k in [SensorKind::Scalar, SensorKind::Counter, SensorKind::Table, SensorKind::State] {
558            let r: RSensorKind = k.into();
559            assert_eq!(SensorKind::from(r), k);
560        }
561    }
562
563    #[test]
564    fn category_round_trips() {
565        for c in [
566            Category::Cpu,
567            Category::Gpu,
568            Category::Memory,
569            Category::Storage,
570            Category::Network,
571            Category::Custom,
572        ] {
573            let r: RCategory = c.into();
574            assert_eq!(Category::from(r), c);
575        }
576    }
577
578    #[test]
579    fn reading_table_round_trips() {
580        let r = Reading::Table(vec![TableRow {
581            cells: vec![Cell::Text("p".into()), Cell::Number(1.0), Cell::Bytes(42)],
582        }]);
583        let rr: RReading = r.clone().into();
584        let back: Reading = rr.into();
585        assert_eq!(back, r);
586    }
587
588    #[test]
589    fn reading_scalar_round_trips() {
590        let r = Reading::Scalar(42.5);
591        let rr: RReading = r.clone().into();
592        assert_eq!(Reading::from(rr), r);
593    }
594
595    #[test]
596    fn reading_counter_round_trips() {
597        let r = Reading::Counter(123_456_789);
598        let rr: RReading = r.clone().into();
599        assert_eq!(Reading::from(rr), r);
600    }
601
602    #[test]
603    fn reading_state_round_trips() {
604        let r = Reading::State("up".into());
605        let rr: RReading = r.clone().into();
606        assert_eq!(Reading::from(rr), r);
607    }
608
609    #[test]
610    fn sensor_id_round_trips() {
611        let id = SensorId::new("cpu.util");
612        let r: RSensorId = id.clone().into();
613        assert_eq!(SensorId::from(r), id);
614    }
615
616    #[test]
617    fn hardware_category_kind_round_trips() {
618        use linsight_core::HardwareCategory;
619        for c in [
620            HardwareCategory::Gpu,
621            HardwareCategory::Storage,
622            HardwareCategory::Network,
623            HardwareCategory::Cpu,
624            HardwareCategory::Other,
625        ] {
626            let r: RHardwareCategoryKind = c.into();
627            let back: HardwareCategory = r.into();
628            assert_eq!(back, c);
629        }
630    }
631
632    #[test]
633    fn hardware_device_round_trips_minimal() {
634        use linsight_core::{HardwareCategory, HardwareDevice, HardwareDeviceKey};
635        let dev = HardwareDevice {
636            key: HardwareDeviceKey::try_new("pci:0000:06:00.0").unwrap(),
637            category: HardwareCategory::Gpu,
638            model: "Intel Arc B-series".into(),
639            vendor: None,
640            location: None,
641            plugin_id: String::new(),
642            plugin_device_id: "gpu0".into(),
643            sensor_ids: vec![],
644        };
645        let r: RHardwareDevice = dev.clone().into();
646        let back: HardwareDevice = r.into();
647        assert_eq!(back, dev);
648    }
649
650    #[test]
651    fn hardware_device_round_trips_with_options() {
652        use linsight_core::{HardwareCategory, HardwareDevice, HardwareDeviceKey};
653        let dev = HardwareDevice {
654            key: HardwareDeviceKey::try_new("nvml:uuid:gpu-abc").unwrap(),
655            category: HardwareCategory::Gpu,
656            model: "NVIDIA RTX 5080 Mobile".into(),
657            vendor: Some("NVIDIA".into()),
658            location: Some("PCI 0000:01:00.0".into()),
659            plugin_id: String::new(),
660            plugin_device_id: "gpu0".into(),
661            sensor_ids: vec![],
662        };
663        let r: RHardwareDevice = dev.clone().into();
664        let back: HardwareDevice = r.into();
665        assert_eq!(back, dev);
666    }
667}