Skip to main content

state_recorder/
lib.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Standardized reporting of time series data via Inspect and trace. It supports recording of
6//! **enum states** and **numeric states**.
7//!
8//! For example use, see the [example code][strc].
9//!
10//! For the intro to the library, see the [README.md][rdme].
11//!
12//! [rdme]: https://cs.opensource.google/fuchsia/fuchsia/+/main:sdk/lib/power/state_recorder/README.md
13//! [strc]: https://cs.opensource.google/fuchsia/fuchsia/+/main:examples/power/state_recorder
14//!
15
16use fuchsia_inspect::Inspector;
17use fuchsia_inspect_contrib::nodes::BoundedListNode;
18use fuchsia_sync::Mutex;
19use futures_util::FutureExt;
20use std::cmp::Eq;
21pub use std::collections::HashMap;
22pub use std::ffi::{CStr, CString};
23use std::fmt::{Debug, Display};
24use std::fs::{self as fs, OpenOptions};
25use std::hash::Hash;
26use std::io::Write as OtherWrite;
27use std::marker::PhantomData;
28use std::path::Path;
29use std::str::FromStr;
30use std::sync::{Arc, LazyLock};
31use strum::IntoEnumIterator;
32use {fuchsia_inspect as inspect, fuchsia_trace as ftrace, zx};
33
34static CSTR_POOL: LazyLock<Mutex<HashMap<String, &'static CStr>>> =
35    LazyLock::new(|| Mutex::new(HashMap::new()));
36
37/// Lazily creates &'static CStr values.
38///
39/// Each reference is backed by a CString value that is leaked (and can thus never be deallocated)
40/// to achieve static lifetime. Each value is indexed by its corresponding `str` value, so a given
41/// value will only be created once.
42///
43/// Errors:
44///  - StateRecorderError::IncompatibleString: The provided string could not be converted to a
45///    CString.
46fn lazy_static_cstr(s: &str) -> Result<&'static CStr, StateRecorderError> {
47    let mut pool = CSTR_POOL.lock();
48
49    // If the string is already in our pool, return the existing CStr.
50    if let Some(existing_cstr) = pool.get(s) {
51        return Ok(existing_cstr);
52    }
53
54    // Create the CString and leak it in a box to give it static lifetime.
55    let c_string = CString::new(s)
56        .map_err(|_| StateRecorderError::IncompatibleString(s.to_owned()))?
57        .into_boxed_c_str();
58
59    // We are going to leak the `c_string`, which may trip up the LeakSanitizer. So we need to
60    // explicitly disable and enable when we're running in the sanitizer variant.
61    //
62    // Note that the variant is named variant_asan (for AddressSanitizer), but the specific
63    // sanitizer we are targeting is lsan (LeakSanitizer), which is enabled as part of the asan
64    // variant.
65    #[cfg(any(feature = "variant_asan", feature = "variant_hwasan"))]
66    fn disable_lsan() {
67        unsafe extern "C" {
68            fn __lsan_disable();
69        }
70        unsafe {
71            __lsan_disable();
72        }
73    }
74
75    #[cfg(not(any(feature = "variant_asan", feature = "variant_hwasan")))]
76    fn disable_lsan() {}
77
78    #[cfg(any(feature = "variant_asan", feature = "variant_hwasan"))]
79    fn enable_lsan() {
80        unsafe extern "C" {
81            fn __lsan_enable();
82        }
83        unsafe {
84            __lsan_enable();
85        }
86    }
87
88    #[cfg(not(any(feature = "variant_asan", feature = "variant_hwasan")))]
89    fn enable_lsan() {}
90
91    disable_lsan();
92    let static_cstr: &'static CStr = Box::leak(c_string);
93    enable_lsan();
94
95    pool.insert(s.to_owned(), static_cstr);
96
97    Ok(static_cstr)
98}
99
100static ROOT_NODE_NAME: &str = "power_observability_state_recorders";
101
102// StateRecorderManager for use with the singleton inspector.
103static SINGLETON_MANAGER: LazyLock<Arc<Mutex<StateRecorderManager>>> =
104    LazyLock::new(|| StateRecorderManager::new(inspect::component::inspector()));
105
106pub fn manager() -> Arc<Mutex<StateRecorderManager>> {
107    SINGLETON_MANAGER.clone()
108}
109
110#[derive(thiserror::Error, Debug)]
111pub enum StateRecorderError {
112    #[error("The name \"{0}\" is already in use")]
113    DuplicateName(String),
114    #[error("String \"{0}\" cannot be converted to a CString")]
115    IncompatibleString(String),
116    #[error("Invalid options: {0}")]
117    InvalidOptions(String),
118}
119
120/// Manages the parent node shared by StateRecorder instances, providing protection against name
121/// collisions.
122pub struct StateRecorderManager {
123    pub node: inspect::Node,
124    // Represents a set, but implemented using a Vec due to expected small number of elements.
125    names_in_use: Vec<String>,
126}
127
128impl StateRecorderManager {
129    pub fn new(inspector: &inspect::Inspector) -> Arc<Mutex<Self>> {
130        Arc::new(Mutex::new(Self {
131            node: inspector.root().create_child(ROOT_NODE_NAME),
132            names_in_use: Vec::new(),
133        }))
134    }
135
136    fn register_name(&mut self, name: &str) -> Result<(), StateRecorderError> {
137        if self.names_in_use.iter().any(|s| s == name) {
138            return Err(StateRecorderError::DuplicateName(name.to_owned()));
139        }
140        self.names_in_use.push(name.to_owned());
141        Ok(())
142    }
143
144    fn unregister_name(&mut self, name: &str) {
145        match self.names_in_use.iter().position(|s| s == name) {
146            Some(index) => {
147                self.names_in_use.remove(index);
148            }
149            None => {
150                log::error!("unregister_name called with nonexistent name \"{}\"", name);
151            }
152        }
153    }
154}
155
156// Helpers for sharing logic between Recorders
157fn register_with_manager(
158    manager: &Arc<Mutex<StateRecorderManager>>,
159    name: &str,
160) -> Result<inspect::Node, StateRecorderError> {
161    let mut manager = manager.lock();
162    if let Err(e) = manager.register_name(name) {
163        return Err(e);
164    }
165    Ok(manager.node.create_child(name))
166}
167
168fn setup_recording_backend<T, F>(
169    node: &inspect::Node,
170    options: &RecorderOptions,
171    record_item: F,
172) -> Result<(RecorderHistory<T>, Option<PersistenceHandler<T>>), StateRecorderError>
173where
174    T: Copy + std::fmt::Debug + std::fmt::Display + std::str::FromStr + Send + Sync + 'static,
175    F: Fn(&inspect::Node, &T) + Send + Sync + Clone + 'static,
176{
177    if options.lazy_record {
178        let shared_buffer = if let Some(config) = &options.persistence {
179            let (handler, buffer) = PersistenceHandler::new(config.clone(), options.capacity);
180
181            // Handle Previous Boot Node
182            let prev_data = PersistenceHandler::<T>::read_log(&config.previous_path);
183            if !prev_data.is_empty() {
184                let data_arc = Arc::new(prev_data);
185                let record_item = record_item.clone();
186                node.record_lazy_child("previous_boot_history", move || {
187                    let data = data_arc.clone();
188                    let record_item = record_item.clone();
189                    async move {
190                        let inspector = Inspector::default();
191                        let root = inspector.root();
192                        for (i, (ts, val)) in data.iter().enumerate() {
193                            root.record_child(i.to_string(), |child| {
194                                child.record_int("@time", *ts);
195                                record_item(child, val);
196                            });
197                        }
198                        Ok(inspector)
199                    }
200                    .boxed()
201                });
202            }
203            (Some(handler), buffer)
204        } else {
205            (None, Arc::new(Mutex::new(TimestampRingBuffer::<T>::with_capacity(options.capacity))))
206        };
207
208        // reset_info
209        let buffer_cloned = shared_buffer.1.clone();
210        node.record_lazy_child("reset_info", move || {
211            let history = buffer_cloned.clone();
212            async move {
213                let inspector = Inspector::default();
214                let node = inspector.root();
215                let (count, last_reset_ns) = history.lock().get_reset_info();
216                node.record_int("count", count as i64);
217                node.record_int("last_reset_ns", last_reset_ns);
218                Ok(inspector)
219            }
220            .boxed()
221        });
222
223        // history
224        let buffer_cloned = shared_buffer.1.clone();
225        node.record_lazy_child("history", move || {
226            let history = buffer_cloned.clone();
227            let record_item = record_item.clone();
228            async move {
229                let inspector = Inspector::default();
230                let node = inspector.root();
231                for (i, (timestamp, state_value)) in history.lock().iter().enumerate() {
232                    node.record_child(format!("{}", i), |node| {
233                        node.record_int("@time", timestamp);
234                        record_item(node, &state_value);
235                    });
236                }
237                Ok(inspector)
238            }
239            .boxed()
240        });
241
242        Ok((RecorderHistory::Lazy(shared_buffer.1), shared_buffer.0))
243    } else {
244        if options.persistence.is_some() {
245            return Err(StateRecorderError::InvalidOptions(
246                "Persistence not supported in eager mode".to_string(),
247            ));
248        }
249
250        node.record_child("reset_info", |node| {
251            node.record_int("count", 0);
252            node.record_int("last_reset_ns", zx::BootInstant::get().into_nanos());
253        });
254
255        let history_node = BoundedListNode::new(node.create_child("history"), options.capacity);
256        Ok((RecorderHistory::Eager(history_node), None))
257    }
258}
259
260/// Supertrait that combines traits an enum type must satisfy to be compatible with StateRecorder.
261pub trait RecordableEnum:
262    Copy + Debug + Display + Eq + Hash + IntoEnumIterator + Into<u64> + Send + Sync
263{
264}
265impl<T: Copy + Debug + Display + Eq + Hash + IntoEnumIterator + Into<u64> + Send + Sync>
266    RecordableEnum for T
267{
268}
269
270// To simplify lookups, StateRecorder stores each state name as both CStr (for tracing) and
271// String (for Inspect).
272#[derive(Clone)]
273struct StateName {
274    trace_name: &'static CStr,
275    // This is wrapped in an Arc so that StateRecorder can clone a reference to it that is separated
276    // from a borrow of `self`.
277    //
278    // The alternative -- while preserving `Send` for StateRecorder -- would be to wrap
279    // StateRecorder::trace_state_event and StateRecorder::history in Mutexes.
280    inspect_name: Arc<String>,
281}
282
283/// Records time series data for an named-u64 value state. This is best-suited for categorical
284/// observations, where the name of the state and not a numeric value will be most relevant for
285/// diagnostic and forensic purposes.
286pub struct NamedU64StateRecorder {
287    manager: Arc<Mutex<StateRecorderManager>>,
288    name: String,
289    trace_category: &'static CStr,
290    state_names: HashMap<u64, StateName>,
291    history: RecorderHistory<u64>,
292    persistence: Option<PersistenceHandler<u64>>,
293    _root_node: inspect::Node,
294    vthread: ftrace::VThread<String>,
295    current_state_trace_name: Option<&'static CStr>,
296}
297
298impl std::fmt::Debug for NamedU64StateRecorder {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("NamedU64StateRecorder")
301            .field("metadata", &self.name)
302            .field("trace_category", &self.trace_category)
303            .field("history", &self.history)
304            .finish()
305    }
306}
307
308impl Drop for NamedU64StateRecorder {
309    fn drop(&mut self) {
310        self.manager.lock().unregister_name(&self.name);
311    }
312}
313
314impl NamedU64StateRecorder {
315    /// Creates a new NamedU64StateRecorder with a given name and a map of u64 to state names.
316    ///
317    /// See `RecorderOptions` for more details on options that can be specified.
318    ///
319    /// Errors:
320    ///   - StateRecorderError::DuplicateName: `metadata.name` is already in use by a StateRecorder
321    ///     associated with `manager`.
322    ///   - StateRecorderError::IncompatibleString: Either `name` or the display name of a state
323    ///     cannot be converted to a CString.
324    ///   - StateRecorderError::InvalidOptions: `options` is invalid for the given mode.
325    pub fn new(
326        name: String,
327        trace_category: &'static CStr,
328        state_names_map: HashMap<u64, String>,
329        options: RecorderOptions,
330    ) -> Result<Self, StateRecorderError> {
331        let manager = options.manager.clone().unwrap_or_else(|| SINGLETON_MANAGER.clone());
332        let node = register_with_manager(&manager, &name)?;
333
334        // Build up the map of u64 to state names, returning an error if any name is not a valid
335        // str.
336        let mut state_names = HashMap::new();
337        for (value, name_str) in state_names_map {
338            let inspect_name = Arc::new(name_str);
339            let trace_name = lazy_static_cstr(&inspect_name)?;
340            state_names.insert(value, StateName { inspect_name, trace_name });
341        }
342
343        node.record_child("metadata", |metadata_node| {
344            metadata_node.record_string("name", &name);
345            metadata_node.record_string("type", "enum");
346            metadata_node.record_child("states", |states_node| {
347                for (state_value, state_name) in state_names.iter() {
348                    states_node.record_uint(state_name.inspect_name.as_ref(), *state_value);
349                }
350            });
351        });
352
353        // Closure to format values using the state_names map
354        // We clone the map for use in the closure.
355        let names_map: HashMap<u64, Arc<String>> =
356            state_names.iter().map(|(k, v)| (*k, v.inspect_name.clone())).collect();
357        let names_map_arc = Arc::new(names_map);
358        let record_item = move |node: &inspect::Node, val: &u64| {
359            let name_str = names_map_arc.get(val).map(|s| s.as_str()).unwrap_or("<Unknown>");
360            node.record_string("value", name_str);
361        };
362
363        let (history, persistence) = setup_recording_backend(&node, &options, record_item)?;
364
365        let vthread = ftrace::VThread::new(name.clone(), ftrace::Id::random().into());
366
367        Ok(Self {
368            manager,
369            name,
370            trace_category,
371            state_names,
372            history,
373            persistence,
374            _root_node: node,
375            vthread,
376            current_state_trace_name: None,
377        })
378    }
379
380    fn get_state_name(&self, val: u64) -> StateName {
381        static UNKNOWN_NAME: LazyLock<StateName> = LazyLock::new(|| StateName {
382            trace_name: c"<Unknown>",
383            inspect_name: Arc::new("<Unknown>".to_string()),
384        });
385        self.state_names.get(&val).unwrap_or(&UNKNOWN_NAME).clone()
386    }
387
388    pub fn record(&mut self, val: u64) {
389        static CACHE: ftrace::trace_site_t = ftrace::trace_site_t::new(0);
390        let context = ftrace::TraceCategoryContext::acquire_cached(self.trace_category, &CACHE);
391
392        if let Some(context) = context.as_ref() {
393            if let Some(name) = self.current_state_trace_name {
394                ftrace::vthread_duration_end(context, &name, &self.vthread, &[]);
395            }
396        }
397
398        let StateName { inspect_name, trace_name } = self.get_state_name(val);
399        self.current_state_trace_name = Some(trace_name);
400
401        if let Some(context) = context.as_ref() {
402            ftrace::vthread_duration_begin(context, &trace_name, &self.vthread, &[]);
403        }
404
405        let timestamp = zx::BootInstant::get().into_nanos();
406
407        // If Persistence is on (Lazy), the handler OWNS the buffer update.
408        if let Some(handler) = &mut self.persistence {
409            // Updates the shared buffer inside its lock and handle persistence.
410            handler.append(timestamp, val);
411        } else {
412            // Update manually
413            match &mut self.history {
414                RecorderHistory::Eager(history) => {
415                    history.add_entry(|node| {
416                        node.record_int("@time", timestamp);
417                        node.record_string("value", inspect_name.as_ref());
418                    });
419                }
420                RecorderHistory::Lazy(history) => {
421                    history.lock().insert(timestamp, val);
422                }
423            }
424        }
425    }
426}
427
428/// Records time series data for an enum-valued state. This is best-suited for categorical
429/// observations, where the name of the state and not a numeric value will be most relevant for
430/// diagnostic and forensic purposes.
431pub struct EnumStateRecorder<T: RecordableEnum> {
432    inner: NamedU64StateRecorder,
433    _phantom: PhantomData<T>,
434}
435
436impl<T: RecordableEnum> std::fmt::Debug for EnumStateRecorder<T> {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        f.debug_struct("EnumStateRecorder").field("inner", &self.inner).finish()
439    }
440}
441
442impl<T: RecordableEnum + 'static> EnumStateRecorder<T> {
443    /// Creates a new EnumStateRecorder with a given name.
444    ///
445    /// See `RecorderOptions` for more details on options that can be specified.
446    ///
447    /// Errors:
448    ///   - StateRecorderError::DuplicateName: `metadata.name` is already in use by a StateRecorder
449    ///     associated with `manager`.
450    ///   - StateRecorderError::IncompatibleString: Either `name` or the display name of a state
451    ///     cannot be converted to a CString.
452    ///   - StateRecorderError::InvalidOptions: `options` is invalid for the given mode.
453    pub fn new(
454        name: String,
455        trace_category: &'static CStr,
456        options: RecorderOptions,
457    ) -> Result<Self, StateRecorderError> {
458        let mut map = HashMap::new();
459        for variant in T::iter() {
460            map.insert(variant.into(), variant.to_string());
461        }
462        let inner = NamedU64StateRecorder::new(name, trace_category, map, options)?;
463
464        Ok(Self { inner, _phantom: PhantomData })
465    }
466
467    pub fn record(&mut self, state_enum: T) {
468        self.inner.record(state_enum.into());
469    }
470}
471
472/// To be recordable, a numeric type must, in essence, be able to widen into a trace-compatible
473/// type and an Inspect-compatible type. Users are not expected to implement this trait; this
474/// module implements it for common numeric types below.
475pub trait RecordableNumericType:
476    Copy + Debug + Display + FromStr + Sized + Send + Sync + 'static
477{
478    type TraceType: ftrace::ArgValue;
479
480    fn trace_value(&self) -> Self::TraceType;
481    fn record(&self, node: &inspect::Node, name: &str);
482    fn record_range(range: &(Self, Self), node: &inspect::Node);
483}
484
485macro_rules! impl_recordable_numeric_type {
486    ($numeric_type:ty, $trace_type:ty, u64) => {
487        impl RecordableNumericType for $numeric_type {
488            type TraceType = $trace_type;
489
490            fn trace_value(&self) -> Self::TraceType {
491                *self as Self::TraceType
492            }
493            fn record(&self, node: &inspect::Node, name: &str) {
494                node.record_uint(name, *self as u64);
495            }
496            fn record_range(range: &(Self, Self), node: &inspect::Node) {
497                node.record_uint("min_inc", range.0 as u64);
498                node.record_uint("max_inc", range.1 as u64);
499            }
500        }
501    };
502    ($numeric_type:ty, $trace_type:ty, i64) => {
503        impl RecordableNumericType for $numeric_type {
504            type TraceType = $trace_type;
505
506            fn trace_value(&self) -> Self::TraceType {
507                *self as Self::TraceType
508            }
509            fn record(&self, node: &inspect::Node, name: &str) {
510                node.record_int(name, *self as i64);
511            }
512            fn record_range(range: &(Self, Self), node: &inspect::Node) {
513                node.record_int("min_inc", range.0 as i64);
514                node.record_int("max_inc", range.1 as i64);
515            }
516        }
517    };
518    ($numeric_type:ty, $trace_type:ty, f64) => {
519        impl RecordableNumericType for $numeric_type {
520            type TraceType = $trace_type;
521
522            fn trace_value(&self) -> Self::TraceType {
523                *self as Self::TraceType
524            }
525            fn record(&self, node: &inspect::Node, name: &str) {
526                node.record_double(name, *self as f64);
527            }
528            fn record_range(range: &(Self, Self), node: &inspect::Node) {
529                node.record_double("min_inc", range.0 as f64);
530                node.record_double("max_inc", range.1 as f64);
531            }
532        }
533    };
534}
535
536impl_recordable_numeric_type!(u8, u32, u64);
537impl_recordable_numeric_type!(u16, u32, u64);
538impl_recordable_numeric_type!(u32, u32, u64);
539impl_recordable_numeric_type!(u64, u64, u64);
540impl_recordable_numeric_type!(i8, i32, i64);
541impl_recordable_numeric_type!(i16, i32, i64);
542impl_recordable_numeric_type!(i32, i32, i64);
543impl_recordable_numeric_type!(i64, i64, i64);
544impl_recordable_numeric_type!(f32, f64, f64);
545impl_recordable_numeric_type!(f64, f64, f64);
546
547/// Units supported by NumericStateRecorder. The `units!` macro is recommended for construction.
548///
549/// Bytes and bit-rates are specifically not included yet because they invite the question of
550/// whether they should be restricted to binary prefixes. We'll address that once we instrument a
551/// specific use case.
552#[derive(Copy, Clone, Debug, PartialEq, Eq)]
553pub enum Units {
554    Amps(Option<DecimalPrefix>),
555    AmpHours(Option<DecimalPrefix>),
556    Hertz(Option<DecimalPrefix>),
557    Joules(Option<DecimalPrefix>),
558    Seconds(Option<DecimalPrefix>),
559    Watts(Option<DecimalPrefix>),
560    Volts(Option<DecimalPrefix>),
561    Celsius(Option<DecimalPrefix>),
562    Number(Option<DecimalPrefix>),
563    Percent,
564}
565
566impl Display for Units {
567    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568        fn write_helper(
569            f: &mut std::fmt::Formatter<'_>,
570            prefix: &Option<DecimalPrefix>,
571            unit_str: &str,
572        ) -> std::fmt::Result {
573            match prefix {
574                Some(p) => write!(f, "{}{}", p, unit_str),
575                None => write!(f, "{}", unit_str),
576            }
577        }
578
579        match self {
580            Units::Amps(prefix) => write_helper(f, prefix, "A"),
581            Units::AmpHours(prefix) => write_helper(f, prefix, "AH"),
582            Units::Hertz(prefix) => write_helper(f, prefix, "Hz"),
583            Units::Joules(prefix) => write_helper(f, prefix, "J"),
584            Units::Seconds(prefix) => write_helper(f, prefix, "s"),
585            Units::Watts(prefix) => write_helper(f, prefix, "W"),
586            Units::Volts(prefix) => write_helper(f, prefix, "V"),
587            Units::Celsius(prefix) => write_helper(f, prefix, "C"),
588            Units::Number(prefix) => write_helper(f, prefix, "#"),
589            Units::Percent => write!(f, "%"),
590        }
591    }
592}
593
594/// Decimal prefixes for use with certain `Units`.
595#[derive(Copy, Clone, Debug, PartialEq, Eq)]
596pub enum DecimalPrefix {
597    Nano,
598    Micro,
599    Milli,
600    Centi,
601    Deci,
602    Kilo,
603    Mega,
604    Giga,
605}
606
607impl Display for DecimalPrefix {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        match self {
610            DecimalPrefix::Nano => write!(f, "n"),
611            DecimalPrefix::Micro => write!(f, "u"),
612            DecimalPrefix::Milli => write!(f, "m"),
613            DecimalPrefix::Centi => write!(f, "c"),
614            DecimalPrefix::Deci => write!(f, "d"),
615            DecimalPrefix::Kilo => write!(f, "k"),
616            DecimalPrefix::Mega => write!(f, "M"),
617            DecimalPrefix::Giga => write!(f, "G"),
618        }
619    }
620}
621
622/// Assembles fully-specified measurement units for NumericStateRecorder, combining a base unit
623/// with an optional prefix.
624///
625/// Examples:
626///     - units!(Volt)
627///     - units!(Percent)
628///     - units!(Kilo, Hertz)
629///     - units!(Milli, Amp)
630#[macro_export]
631macro_rules! units {
632    (Percent) => {
633        $crate::Units::Percent
634    };
635    ($base_unit:ident) => {
636        $crate::Units::$base_unit(None)
637    };
638    ($prefix:ident, $base_unit:ident) => {
639        $crate::Units::$base_unit(Some($crate::DecimalPrefix::$prefix))
640    };
641}
642
643// Holds information for persistence
644#[derive(Clone, Debug)]
645pub struct PersistenceOptions {
646    /// Unique name for this recorder (e.g., "battery_level").
647    name: String,
648    /// For current history.
649    current_path: String,
650    /// For previous history.
651    previous_path: String,
652    // A temporary name for current history to achieve atomic persistence.
653    rename_path: String,
654}
655
656impl PersistenceOptions {
657    // Unique name and path to storage and path to volatile directory.
658    pub fn new(name: impl Into<String>) -> Self {
659        let name = name.into();
660        Self {
661            current_path: format!("/data/{}.csv", name),
662            previous_path: format!("/tmp/{}.csv", name),
663            rename_path: format!("/data/{}.tmp", name),
664            name,
665        }
666    }
667
668    pub fn storage_dir(mut self, dir: &str) -> Self {
669        self.current_path = format!("{}/{}.csv", dir, self.name);
670        self.rename_path = format!("{}/{}.tmp", dir, self.name);
671        self
672    }
673
674    pub fn volatile_dir(mut self, dir: &str) -> Self {
675        self.previous_path = format!("{}/{}.csv", dir, self.name);
676        self
677    }
678
679    // Helper to generate paths
680    fn paths(&self) -> (&str, &str, &str) {
681        (&self.current_path, &self.previous_path, &self.rename_path)
682    }
683}
684
685/// Handles persistence using TimestampRingBuffer as the backing store to save memory.
686struct PersistenceHandler<T: Copy> {
687    config: PersistenceOptions,
688    // We reuse TimestampRingBuffer for memory-optimized storage (i32 offsets)
689    buffer: Arc<Mutex<TimestampRingBuffer<T>>>,
690}
691
692impl<T: Copy + FromStr + Display> PersistenceHandler<T> {
693    fn new(
694        config: PersistenceOptions,
695        capacity: usize,
696    ) -> (Self, Arc<Mutex<TimestampRingBuffer<T>>>) {
697        let (curr, prev, _) = config.paths();
698
699        // Perform rotation before loading data
700        Self::prepare_files(&curr, &prev);
701
702        // Load any data remaining in 'current' (crash recovery)
703        let initial_data = Self::read_log(&curr);
704
705        // 3. Hydrate our internal ring buffer
706        let mut buffer = TimestampRingBuffer::with_capacity(capacity);
707        for (ts, val) in initial_data {
708            buffer.insert(ts, val);
709        }
710
711        let shared_buffer = Arc::new(Mutex::new(buffer));
712
713        (Self { config, buffer: shared_buffer.clone() }, shared_buffer)
714    }
715
716    /// Handles the rotation logic:
717    /// If PREV doesn't exist (reboot), move CURR to PREV.
718    /// If PREV exists (crash), leave CURR alone (it contains valuable pre-crash data).
719    fn prepare_files(curr_path: &str, prev_path: &str) {
720        if Path::new(prev_path).exists() {
721            // Previous file exists -> Crash recovery.
722            // Do not overwrite it. Do not touch current file.
723            log::warn!("Not moving history, {} already exists", prev_path);
724            return;
725        }
726
727        // Move content by reading then writing it for moves from /data to /tmp.
728        let Ok(content) = std::fs::read_to_string(curr_path).map_err(|e| {
729            log::info!("Could not read current history, not moving: {}", e);
730        }) else {
731            return;
732        };
733
734        if let Err(e) = std::fs::write(prev_path, &content) {
735            log::warn!("Could not write previous boot history: {}", e);
736            return;
737        }
738
739        if let Err(e) = std::fs::File::create(curr_path) {
740            log::warn!("Could not clear current boot history: {}", e);
741        }
742    }
743
744    fn flush(&self, buffer_guard: &TimestampRingBuffer<T>) {
745        let (curr, _, temp) = self.config.paths();
746        let try_write = || -> std::io::Result<()> {
747            let mut file =
748                OpenOptions::new().write(true).create(true).truncate(true).open(&temp)?;
749
750            // Iterate the ring buffer (which converts internal 32-bit offsets back to 64-bit TS)
751            for (ts, val) in buffer_guard.iter() {
752                writeln!(file, "{},{}", ts, val)?;
753            }
754
755            file.sync_data()?;
756            fs::rename(&temp, &curr)?;
757            Ok(())
758        };
759
760        if let Err(e) = try_write() {
761            log::error!("StateRecorder: Persist failed for {}: {:?}", self.config.name, e);
762        }
763    }
764
765    /// Appends data to memory and syncs to disk.
766    fn append(&mut self, timestamp: i64, value: T) {
767        let mut guard = self.buffer.lock();
768        guard.insert(timestamp, value);
769        self.flush(&guard);
770    }
771
772    /// Static helper to read log from disk into a vector.
773    fn read_log(path: &str) -> Vec<(i64, T)> {
774        let Ok(content) = fs::read_to_string(path) else {
775            return Vec::new();
776        };
777        content
778            .lines()
779            .filter_map(|line| {
780                let line = line.trim();
781                let mut parts = line.splitn(2, ',');
782                let ts = parts.next()?.trim().parse::<i64>().ok()?;
783                let val = parts.next()?.trim().parse::<T>().ok()?;
784                Some((ts, val))
785            })
786            .collect()
787    }
788}
789
790/// Options for NumericStateRecorder and EnumStateRecorder
791#[derive(Default)]
792pub struct RecorderOptions {
793    // If true, recorder will lazily record values to inspect. Otherwise, will record eagerly.
794    pub lazy_record: bool,
795    /// Maximum number of recorded values to store on a rolling basis.
796    pub capacity: usize,
797    /// Optional. If not set, the Recorder will be linked to this module's singleton
798    /// StateRecorderManager, which in turn corresponds to the singleton Inspector.
799    /// If set, the manager supplied here will be used.
800    pub manager: Option<Arc<Mutex<StateRecorderManager>>>,
801    // Optional persistence config
802    pub persistence: Option<PersistenceOptions>,
803}
804
805#[derive(Debug)]
806enum RecorderHistory<T: Copy + Debug> {
807    Eager(BoundedListNode),
808    Lazy(Arc<Mutex<TimestampRingBuffer<T>>>),
809}
810
811#[derive(Debug)]
812/// A fixed-size ring buffer with timestamps for each insertion.
813/// All input and output are in nanoseconds, but will be rounded down to
814/// the nearest millisecond and stored as milliseconds internally.
815/// When the capacity is reached, insertions will wrap around and continue
816/// from the beginning of the buffer. There is a maximum delta of ~24.8 days
817/// between insertions. If this maximum is exceeded, the buffer will drop
818/// all data except for the newest insertion.
819struct TimestampRingBuffer<T: Copy> {
820    /// Initial timestamp in milliseconds, used as basis for offsets.
821    start_timestamp_ms: i64,
822    /// Last timestamp inserted, in milliseconds.
823    last_timestamp_ms: i64,
824    /// Index where the next element should be inserted.
825    next_index: usize,
826    /// Store timestamps as millisecond offsets from `last_timestamp_ms`.
827    offset_ms: Vec<i32>,
828    /// Data to be stored in the buffer.
829    data: Vec<T>,
830    /// Number of times the buffer has been reset (due to max delta exceeded).
831    reset_count: u32,
832    /// Timestamp of the last buffer reset
833    last_reset_ms: i64,
834}
835
836const NANOSECONDS_PER_MILLISECOND: i64 = 1_000_000;
837
838fn ms_to_ns(ms: i64) -> i64 {
839    ms * NANOSECONDS_PER_MILLISECOND
840}
841
842fn ns_to_ms(ns: i64) -> i64 {
843    ns / NANOSECONDS_PER_MILLISECOND
844}
845
846impl<T: Copy> TimestampRingBuffer<T> {
847    fn with_capacity(capacity: usize) -> Self {
848        let now_ms = ns_to_ms(zx::BootInstant::get().into_nanos());
849        Self {
850            start_timestamp_ms: now_ms,
851            last_timestamp_ms: now_ms,
852            next_index: 0,
853            offset_ms: Vec::with_capacity(capacity),
854            data: Vec::with_capacity(capacity),
855            reset_count: 0,
856            last_reset_ms: now_ms,
857        }
858    }
859
860    fn insert(&mut self, timestamp_ns: i64, value: T) {
861        let timestamp_ms = ns_to_ms(timestamp_ns);
862        // Attempt to down-convert the offset from last_timestamp_ms to an i32
863        let offset_ms = match i32::try_from(timestamp_ms - self.last_timestamp_ms) {
864            Ok(offset_ms) => offset_ms,
865            Err(_) => {
866                // Offset from last_timestamp_ms exceeds maximum allowable,
867                // reset the buffer.
868                self.offset_ms.clear();
869                self.data.clear();
870                self.start_timestamp_ms = timestamp_ms;
871                self.next_index = 0;
872                self.reset_count += 1;
873                self.last_reset_ms = self.start_timestamp_ms;
874                0
875            }
876        };
877        if self.offset_ms.len() < self.offset_ms.capacity() {
878            // Buffer isn't full yet, just append.
879            self.offset_ms.push(offset_ms);
880            self.data.push(value);
881        } else {
882            // Buffer is full, shift `start_timestamp_ms` forward by the oldest
883            // offset, then overwrite that entry with the new data.
884            self.start_timestamp_ms += self.offset_ms[self.next_index] as i64;
885            self.offset_ms[self.next_index] = offset_ms;
886            self.data[self.next_index] = value;
887        }
888        self.last_timestamp_ms = timestamp_ms;
889        self.next_index = (self.next_index + 1) % self.offset_ms.capacity();
890    }
891
892    /// Returns the reset count, and the timestamp of the last reset in nanoseconds.
893    fn get_reset_info(&self) -> (u32, i64) {
894        (self.reset_count, ms_to_ns(self.last_reset_ms))
895    }
896
897    /// Returns an Iterator of (timestamp in nanoseconds, T), starting
898    /// from the oldest entry.
899    fn iter(&self) -> TimestampRingBufferIter<'_, T> {
900        TimestampRingBufferIter::new(self)
901    }
902}
903
904struct TimestampRingBufferIter<'a, T: Copy> {
905    buffer: &'a TimestampRingBuffer<T>,
906    index: usize,
907    last_timestamp_ms: i64,
908}
909
910impl<'a, T: Copy> TimestampRingBufferIter<'a, T> {
911    fn new(buffer: &'a TimestampRingBuffer<T>) -> Self {
912        Self { buffer, index: 0, last_timestamp_ms: buffer.start_timestamp_ms }
913    }
914}
915
916/// Iterate over the wrapped buffer, returning (timestamp in nanoseconds, T),
917/// starting from the oldest entry.
918impl<T: Copy> Iterator for TimestampRingBufferIter<'_, T> {
919    type Item = (i64, T);
920
921    fn next(&mut self) -> Option<(i64, T)> {
922        if self.index >= self.buffer.offset_ms.len() {
923            return None;
924        }
925        // Start from the oldest insertion and wrap around.
926        let index = (self.index + self.buffer.next_index) % self.buffer.offset_ms.len();
927        let timestamp_ms = self.last_timestamp_ms + self.buffer.offset_ms[index] as i64;
928        self.index += 1;
929        self.last_timestamp_ms = timestamp_ms;
930        Some((ms_to_ns(timestamp_ms), self.buffer.data[index]))
931    }
932}
933
934pub struct NumericStateRecorder<T: RecordableNumericType> {
935    manager: Arc<Mutex<StateRecorderManager>>,
936    name: String,
937    trace_category: &'static CStr,
938    trace_name: &'static CStr,
939    units: String,
940    history: RecorderHistory<T>,
941    persistence: Option<PersistenceHandler<T>>,
942    _root_node: inspect::Node,
943    trace_id: ftrace::Id,
944    _phantom: PhantomData<T>,
945}
946
947impl<T: RecordableNumericType> NumericStateRecorder<T> {
948    /// Creates a new NumericStateRecorder.
949    ///
950    /// See `RecorderOptions` for more details on options that can be specified.
951    ///
952    /// Errors:
953    ///   - StateRecorderError::DuplicateName: `metadata.name` is already in use by a StateRecorder
954    ///     associated with `manager`.
955    ///   - StateRecorderError::IncompatibleString: Either `name` or the display name of a state
956    ///     cannot be converted to a CString.
957    ///   - StateRecorderError::InvalidOptions: `options` is invalid for the given mode.
958    pub fn new(
959        name: String,
960        trace_category: &'static CStr,
961        units: Units,
962        range: Option<(T, T)>,
963        options: RecorderOptions,
964    ) -> Result<Self, StateRecorderError> {
965        let manager = options.manager.clone().unwrap_or_else(|| SINGLETON_MANAGER.clone());
966        let node = register_with_manager(&manager, &name)?;
967
968        let trace_name = lazy_static_cstr(&name)?;
969        let units_str = format!("{}", units);
970
971        node.record_child("metadata", |metadata_node| {
972            metadata_node.record_string("name", &name);
973            metadata_node.record_string("type", "numeric");
974            metadata_node.record_string("units", &units_str);
975            match range {
976                Some(r) => metadata_node.record_child("range", |node| T::record_range(&r, node)),
977                None => metadata_node.record_string("range", "<Unspecified>"),
978            }
979        });
980
981        let record_item = |node: &inspect::Node, val: &T| {
982            val.record(node, "value");
983        };
984
985        let (history, persistence) = setup_recording_backend(&node, &options, record_item)?;
986
987        Ok(Self {
988            manager,
989            name,
990            trace_category,
991            trace_name,
992            units: units_str,
993            history,
994            persistence,
995            _root_node: node,
996            trace_id: ftrace::Id::random(),
997            _phantom: PhantomData,
998        })
999    }
1000
1001    pub fn record(&mut self, state_value: T) {
1002        let timestamp = zx::BootInstant::get().into_nanos();
1003
1004        ftrace::counter!(
1005            self.trace_category,
1006            self.trace_name,
1007            self.trace_id.into(),
1008            &self.units.to_string() => state_value.trace_value()
1009        );
1010
1011        // If Persistence is on (Lazy), the handler OWNS the shared buffer update.
1012        if let Some(handler) = &mut self.persistence {
1013            handler.append(timestamp, state_value);
1014        } else {
1015            match &mut self.history {
1016                RecorderHistory::Eager(history) => {
1017                    history.add_entry(|node| {
1018                        node.record_int("@time", timestamp);
1019                        state_value.record(node, "value");
1020                    });
1021                }
1022                RecorderHistory::Lazy(history) => {
1023                    history.lock().insert(timestamp, state_value);
1024                }
1025            }
1026        }
1027    }
1028}
1029
1030impl<T: RecordableNumericType> Drop for NumericStateRecorder<T> {
1031    fn drop(&mut self) {
1032        self.manager.lock().unregister_name(&self.name);
1033    }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039    use diagnostics_assertions::{AnyIntProperty, assert_data_tree};
1040    use fuchsia_inspect::Inspector;
1041    use strum_macros::{Display, EnumIter, EnumString};
1042    use test_case::test_case;
1043
1044    #[derive(Copy, Clone, Debug, Display, EnumIter, EnumString, Eq, PartialEq, Hash)]
1045    #[repr(u8)]
1046    enum SwitchState {
1047        OFF = 0,
1048        ON = 1,
1049    }
1050
1051    impl From<SwitchState> for u64 {
1052        fn from(value: SwitchState) -> Self {
1053            value as Self
1054        }
1055    }
1056
1057    #[fuchsia::test]
1058    async fn test_timestamp_ring_buffer() {
1059        let mut buffer = TimestampRingBuffer::<i32>::with_capacity(3);
1060        let start_ms = buffer.start_timestamp_ms;
1061
1062        let t1 = (ms_to_ns(start_ms + 1000), 1);
1063        // t2's timestamp is before t1, which will result in a negative offset.
1064        let t2 = (ms_to_ns(start_ms + 900), 2);
1065        let t3 = (ms_to_ns(start_ms + 3000), 3);
1066
1067        buffer.insert(t1.0, t1.1);
1068        buffer.insert(t2.0, t2.1);
1069        buffer.insert(t3.0, t3.1);
1070
1071        assert_eq!(vec![t1, t2, t3], buffer.iter().collect::<Vec<_>>());
1072        assert_eq!((0, ms_to_ns(start_ms)), buffer.get_reset_info());
1073
1074        // Buffer is already at capacity, so this should overwrite the first element.
1075        let t4 = (ms_to_ns(start_ms + 4000), 4);
1076        buffer.insert(t4.0, t4.1);
1077        assert_eq!(vec![t2, t3, t4], buffer.iter().collect::<Vec<_>>());
1078        assert_eq!((0, ms_to_ns(start_ms)), buffer.get_reset_info());
1079    }
1080
1081    #[fuchsia::test]
1082    async fn test_timestamp_ring_buffer_resets_on_maximum_offset() {
1083        let mut buffer = TimestampRingBuffer::<i32>::with_capacity(3);
1084        let start_ms = buffer.start_timestamp_ms;
1085
1086        const MAX_OFFSET_MS: i64 = i32::MAX as i64;
1087        let t1 = (ms_to_ns(start_ms + 1000), 1);
1088        let t2 = (t1.0 + ms_to_ns(MAX_OFFSET_MS), 2);
1089
1090        buffer.insert(t1.0, t1.1);
1091        buffer.insert(t2.0, t2.1);
1092
1093        assert_eq!(vec![t1, t2], buffer.iter().collect::<Vec<_>>());
1094        assert_eq!((0, ms_to_ns(start_ms)), buffer.get_reset_info());
1095
1096        // This should exceed the maximum allowable timestamp offset,
1097        // causing the buffer to reset.
1098        let t3 = (t2.0 + ms_to_ns(MAX_OFFSET_MS + 1), 3);
1099        buffer.insert(t3.0, t3.1);
1100        assert_eq!(vec![t3], buffer.iter().collect::<Vec<_>>());
1101        assert_eq!((1, t3.0), buffer.get_reset_info());
1102    }
1103
1104    #[test_case(false; "eager")]
1105    #[test_case(true; "lazy")]
1106    #[fuchsia::test]
1107    async fn test_enum_off_on(lazy_record: bool) {
1108        let inspector = Inspector::default();
1109        let manager = StateRecorderManager::new(&inspector);
1110
1111        let mut recorder = EnumStateRecorder::new(
1112            "my_switch".into(),
1113            c"power_test",
1114            RecorderOptions {
1115                lazy_record,
1116                capacity: 10,
1117                manager: Some(manager),
1118                persistence: None,
1119            },
1120        )
1121        .unwrap();
1122
1123        recorder.record(SwitchState::OFF);
1124        recorder.record(SwitchState::ON);
1125        recorder.record(SwitchState::OFF);
1126        recorder.record(SwitchState::ON);
1127        assert_data_tree!(inspector, root: {
1128            power_observability_state_recorders: {
1129                my_switch: {
1130                    metadata: {
1131                        name: "my_switch",
1132                        type: "enum",
1133                        states: {
1134                            "OFF": 0u64,
1135                            "ON": 1u64,
1136                        }
1137                    },
1138                    history: {
1139                        "0": {
1140                            "@time": AnyIntProperty,
1141                            "value": "OFF",
1142                        },
1143                        "1": {
1144                            "@time": AnyIntProperty,
1145                            "value": "ON",
1146                        },
1147                        "2": {
1148                            "@time": AnyIntProperty,
1149                            "value": "OFF",
1150                        },
1151                        "3": {
1152                            "@time": AnyIntProperty,
1153                            "value": "ON",
1154                        },
1155                    },
1156                    reset_info: {
1157                        count: 0,
1158                        last_reset_ns: AnyIntProperty,
1159                    }
1160                }
1161            }
1162        });
1163    }
1164
1165    #[test_case(false; "eager")]
1166    #[test_case(true; "lazy")]
1167    #[fuchsia::test]
1168    async fn test_multiple_recorders(lazy_record: bool) {
1169        #[derive(Copy, Clone, Debug, Display, EnumIter, EnumString, Eq, PartialEq, Hash)]
1170        #[repr(u8)]
1171        enum EnablementState {
1172            DISABLED = 0,
1173            ENABLED = 1,
1174        }
1175        impl From<EnablementState> for u64 {
1176            fn from(value: EnablementState) -> Self {
1177                value as Self
1178            }
1179        }
1180
1181        let inspector = Inspector::default();
1182        let manager = StateRecorderManager::new(&inspector);
1183
1184        let mut recorder_0 = EnumStateRecorder::new(
1185            "switch_0".into(),
1186            c"power_test",
1187            RecorderOptions {
1188                lazy_record,
1189                capacity: 10,
1190                manager: Some(manager.clone()),
1191                persistence: None,
1192            },
1193        )
1194        .unwrap();
1195        let mut recorder_1 = EnumStateRecorder::new(
1196            "switch_1".into(),
1197            c"power_test",
1198            RecorderOptions {
1199                lazy_record,
1200                capacity: 10,
1201                manager: Some(manager),
1202                persistence: None,
1203            },
1204        )
1205        .unwrap();
1206        recorder_0.record(SwitchState::OFF);
1207        recorder_0.record(SwitchState::ON);
1208        recorder_1.record(EnablementState::ENABLED);
1209        recorder_1.record(EnablementState::DISABLED);
1210
1211        assert_data_tree!(inspector, root: {
1212            power_observability_state_recorders: {
1213                switch_0: {
1214                    metadata: {
1215                        name: "switch_0",
1216                        type: "enum",
1217                        states: {
1218                            "OFF": 0u64,
1219                            "ON": 1u64,
1220                        }
1221                    },
1222                    history: {
1223                        "0": {
1224                            "@time": AnyIntProperty,
1225                            "value": "OFF",
1226                        },
1227                        "1": {
1228                            "@time": AnyIntProperty,
1229                            "value": "ON",
1230                        },
1231                    },
1232                    reset_info: {
1233                        count: 0,
1234                        last_reset_ns: AnyIntProperty,
1235                    }
1236                },
1237               switch_1: {
1238                    metadata: {
1239                        name: "switch_1",
1240                        type: "enum",
1241                        states: {
1242                            "DISABLED": 0u64,
1243                            "ENABLED": 1u64,
1244                        }
1245                    },
1246                    history: {
1247                        "0": {
1248                            "@time": AnyIntProperty,
1249                            "value": "ENABLED",
1250                        },
1251                        "1": {
1252                            "@time": AnyIntProperty,
1253                            "value": "DISABLED",
1254                        },
1255                    },
1256                    reset_info: {
1257                        count: 0,
1258                        last_reset_ns: AnyIntProperty,
1259                    }
1260                }
1261            }
1262        })
1263    }
1264
1265    #[test_case(false; "eager")]
1266    #[test_case(true; "lazy")]
1267    #[fuchsia::test]
1268    async fn test_enum_three_states(lazy_record: bool) {
1269        #[derive(Copy, Clone, Debug, Display, EnumIter, EnumString, Eq, PartialEq, Hash)]
1270        #[repr(u8)]
1271        enum FanSpeed {
1272            OFF = 0,
1273            LOW = 1,
1274            HIGH = 2,
1275        }
1276
1277        impl From<FanSpeed> for u64 {
1278            fn from(value: FanSpeed) -> Self {
1279                value as Self
1280            }
1281        }
1282
1283        let inspector = Inspector::default();
1284        let manager = StateRecorderManager::new(&inspector);
1285
1286        let mut recorder = EnumStateRecorder::new(
1287            "the_best_fan".into(),
1288            c"power_test",
1289            RecorderOptions {
1290                lazy_record,
1291                capacity: 10,
1292                manager: Some(manager),
1293                persistence: None,
1294            },
1295        )
1296        .unwrap();
1297
1298        recorder.record(FanSpeed::OFF);
1299        recorder.record(FanSpeed::LOW);
1300        recorder.record(FanSpeed::HIGH);
1301        recorder.record(FanSpeed::OFF);
1302        recorder.record(FanSpeed::HIGH);
1303        assert_data_tree!(inspector, root: {
1304            power_observability_state_recorders: {
1305                the_best_fan: {
1306                    metadata: {
1307                        name: "the_best_fan",
1308                        type: "enum",
1309                        states: {
1310                            "OFF": 0u64,
1311                            "LOW": 1u64,
1312                            "HIGH": 2u64,
1313                        }
1314                    },
1315                    history: {
1316                        "0": {
1317                            "@time": AnyIntProperty,
1318                            "value": "OFF",
1319                        },
1320                        "1": {
1321                            "@time": AnyIntProperty,
1322                            "value": "LOW",
1323                        },
1324                        "2": {
1325                            "@time": AnyIntProperty,
1326                            "value": "HIGH",
1327                        },
1328                        "3": {
1329                            "@time": AnyIntProperty,
1330                            "value": "OFF",
1331                        },
1332                        "4": {
1333                            "@time": AnyIntProperty,
1334                            "value": "HIGH",
1335                        },
1336                    },
1337                    reset_info: {
1338                        count: 0,
1339                        last_reset_ns: AnyIntProperty,
1340                    }
1341                }
1342            }
1343        });
1344    }
1345
1346    #[test_case(false; "eager")]
1347    #[test_case(true; "lazy")]
1348    #[fuchsia::test]
1349    async fn test_name_reuse_not_allowed(lazy_record: bool) {
1350        let inspector = Inspector::default();
1351        let manager = StateRecorderManager::new(&inspector);
1352
1353        let recorder = EnumStateRecorder::<SwitchState>::new(
1354            "my_switch".into(),
1355            c"power_test",
1356            RecorderOptions {
1357                lazy_record,
1358                capacity: 10,
1359                manager: Some(manager.clone()),
1360                persistence: None,
1361            },
1362        )
1363        .unwrap();
1364
1365        // While `recorder` is still in scope, its name cannot be reused.
1366        let result = EnumStateRecorder::<SwitchState>::new(
1367            "my_switch".into(),
1368            c"power_test",
1369            RecorderOptions {
1370                lazy_record,
1371                capacity: 10,
1372                manager: Some(manager.clone()),
1373                persistence: None,
1374            },
1375        );
1376        assert!(result.is_err());
1377
1378        // After `recorder` is dropped, its name can be used again.
1379        drop(recorder);
1380        let result = EnumStateRecorder::<SwitchState>::new(
1381            "my_switch".into(),
1382            c"power_test",
1383            RecorderOptions {
1384                lazy_record,
1385                capacity: 10,
1386                manager: Some(manager.clone()),
1387                persistence: None,
1388            },
1389        );
1390        assert!(result.is_ok());
1391    }
1392
1393    #[test_case(false; "eager")]
1394    #[test_case(true; "lazy")]
1395    #[fuchsia::test]
1396    async fn test_singleton_manager(lazy_record: bool) {
1397        let mut recorder = EnumStateRecorder::new(
1398            "my_switch".into(),
1399            c"power_test",
1400            RecorderOptions { lazy_record, capacity: 10, manager: None, persistence: None },
1401        )
1402        .unwrap();
1403
1404        recorder.record(SwitchState::OFF);
1405        recorder.record(SwitchState::ON);
1406        assert_data_tree!(inspect::component::inspector(), root: {
1407            power_observability_state_recorders: {
1408                my_switch: {
1409                    metadata: {
1410                        name: "my_switch",
1411                        type: "enum",
1412                        states: {
1413                            "OFF": 0u64,
1414                            "ON": 1u64,
1415                        }
1416                    },
1417                    history: {
1418                        "0": {
1419                            "@time": AnyIntProperty,
1420                            "value": "OFF",
1421                        },
1422                        "1": {
1423                            "@time": AnyIntProperty,
1424                            "value": "ON",
1425                        },
1426                    },
1427                    reset_info: {
1428                        count: 0,
1429                        last_reset_ns: AnyIntProperty,
1430                    }
1431                }
1432            }
1433        });
1434    }
1435
1436    #[fuchsia::test]
1437    async fn test_recorder_is_send() {
1438        fn assert_send<T: Send>() {}
1439        assert_send::<EnumStateRecorder<SwitchState>>();
1440    }
1441
1442    async fn test_uint_numeric_type<T: RecordableNumericType>(lazy_record: bool)
1443    where
1444        T: Into<u64> + From<u8>,
1445    {
1446        let inspector = Inspector::default();
1447        let manager = StateRecorderManager::new(&inspector);
1448        let mut recorder = NumericStateRecorder::new(
1449            "my_stateful_thing".into(),
1450            c"power_test",
1451            units!(Percent),
1452            Some((T::from(0), T::from(255))),
1453            RecorderOptions {
1454                lazy_record,
1455                capacity: 10,
1456                manager: Some(manager),
1457                persistence: None,
1458            },
1459        )
1460        .unwrap();
1461
1462        recorder.record(T::from(10));
1463        recorder.record(T::from(0));
1464        assert_data_tree!(inspector, root: {
1465            power_observability_state_recorders: {
1466                my_stateful_thing: {
1467                    metadata: {
1468                        name: "my_stateful_thing",
1469                        type: "numeric",
1470                        units: "%",
1471                        range: {
1472                            min_inc: 0u64,
1473                            max_inc: 255u64
1474                        },
1475                    },
1476                    history: {
1477                        "0": {
1478                            "@time": AnyIntProperty,
1479                            "value": 10u64,
1480                        },
1481                        "1": {
1482                            "@time": AnyIntProperty,
1483                            "value": 0u64,
1484                        },
1485                    },
1486                    reset_info: {
1487                        count: 0,
1488                        last_reset_ns: AnyIntProperty,
1489                    }
1490                }
1491            }
1492        });
1493    }
1494
1495    #[test_case(false; "eager")]
1496    #[test_case(true; "lazy")]
1497    #[fuchsia::test]
1498    async fn test_uint_numeric_types(lazy_record: bool) {
1499        test_uint_numeric_type::<u8>(lazy_record).await;
1500        test_uint_numeric_type::<u16>(lazy_record).await;
1501        test_uint_numeric_type::<u32>(lazy_record).await;
1502        test_uint_numeric_type::<u64>(lazy_record).await;
1503    }
1504
1505    async fn test_int_numeric_type<T: RecordableNumericType>(lazy_record: bool)
1506    where
1507        T: Into<i64> + From<i8>,
1508    {
1509        let inspector = Inspector::default();
1510        let manager = StateRecorderManager::new(&inspector);
1511        let mut recorder = NumericStateRecorder::new(
1512            "my_stateful_thing".into(),
1513            c"power_test",
1514            units!(Number),
1515            Some((T::from(-128), T::from(127))),
1516            RecorderOptions {
1517                lazy_record,
1518                capacity: 10,
1519                manager: Some(manager),
1520                persistence: None,
1521            },
1522        )
1523        .unwrap();
1524
1525        recorder.record(T::from(10));
1526        recorder.record(T::from(0));
1527        assert_data_tree!(inspector, root: {
1528            power_observability_state_recorders: {
1529                my_stateful_thing: {
1530                    metadata: {
1531                        name: "my_stateful_thing",
1532                        type: "numeric",
1533                        units: "#",
1534                        range: {
1535                            min_inc: -128i64,
1536                            max_inc: 127i64
1537                        },
1538                    },
1539                    history: {
1540                        "0": {
1541                            "@time": AnyIntProperty,
1542                            "value": 10i64,
1543                        },
1544                        "1": {
1545                            "@time": AnyIntProperty,
1546                            "value": 0i64,
1547                        },
1548                    },
1549                    reset_info: {
1550                        count: 0,
1551                        last_reset_ns: AnyIntProperty,
1552                    }
1553                }
1554            }
1555        });
1556    }
1557
1558    #[test_case(false; "eager")]
1559    #[test_case(true; "lazy")]
1560    #[fuchsia::test]
1561    async fn test_int_numeric_types(lazy_record: bool) {
1562        test_int_numeric_type::<i8>(lazy_record).await;
1563        test_int_numeric_type::<i16>(lazy_record).await;
1564        test_int_numeric_type::<i32>(lazy_record).await;
1565        test_int_numeric_type::<i64>(lazy_record).await;
1566    }
1567
1568    async fn test_float_numeric_type<T: RecordableNumericType>(lazy_record: bool)
1569    where
1570        T: Into<f64> + From<u8>,
1571    {
1572        let inspector = Inspector::default();
1573        let manager = StateRecorderManager::new(&inspector);
1574        let mut recorder = NumericStateRecorder::new(
1575            "my_stateful_thing".into(),
1576            c"power_test",
1577            units!(Kilo, Hertz),
1578            Some((T::from(0), T::from(255))),
1579            RecorderOptions {
1580                lazy_record,
1581                capacity: 10,
1582                manager: Some(manager),
1583                persistence: None,
1584            },
1585        )
1586        .unwrap();
1587
1588        recorder.record(T::from(10));
1589        recorder.record(T::from(0));
1590        assert_data_tree!(inspector, root: {
1591            power_observability_state_recorders: {
1592                my_stateful_thing: {
1593                    metadata: {
1594                        name: "my_stateful_thing",
1595                        type: "numeric",
1596                        units: "kHz",
1597                        range: {
1598                            min_inc: 0.0,
1599                            max_inc: 255.0
1600                        },
1601                    },
1602                    history: {
1603                        "0": {
1604                            "@time": AnyIntProperty,
1605                            "value": 10.0,
1606                        },
1607                        "1": {
1608                            "@time": AnyIntProperty,
1609                            "value": 0.0,
1610                        },
1611                    },
1612                    reset_info: {
1613                        count: 0,
1614                        last_reset_ns: AnyIntProperty,
1615                    }
1616                }
1617            }
1618        });
1619    }
1620
1621    #[test_case(false; "eager")]
1622    #[test_case(true; "lazy")]
1623    #[fuchsia::test]
1624    async fn test_float_numeric_types(lazy_record: bool) {
1625        test_float_numeric_type::<f32>(lazy_record).await;
1626        test_float_numeric_type::<f64>(lazy_record).await;
1627    }
1628
1629    #[test_case(true; "lazy")]
1630    #[fuchsia::test]
1631    async fn test_persistence_crash_recovery(lazy_record: bool) {
1632        use std::fs;
1633        use tempfile::tempdir;
1634
1635        // 1. Setup isolated environment
1636        let dir = tempdir().unwrap();
1637        let storage_path = dir.path().join("data");
1638        let volatile_path = dir.path().join("tmp");
1639        fs::create_dir(&storage_path).unwrap();
1640        fs::create_dir(&volatile_path).unwrap();
1641
1642        let inspector = Inspector::default();
1643        let manager = StateRecorderManager::new(&inspector);
1644
1645        // Helper to generate options
1646        let create_options = |manager_ref| RecorderOptions {
1647            lazy_record, // Passed from test_case argument
1648            capacity: 10,
1649            manager: Some(manager_ref),
1650            persistence: Some(
1651                PersistenceOptions::new("crash_test".to_string())
1652                    .storage_dir(storage_path.to_str().unwrap())
1653                    .volatile_dir(volatile_path.to_str().unwrap()),
1654            ),
1655        };
1656
1657        // 2. START RECORDER 1 (Fill data)
1658        {
1659            let mut recorder = EnumStateRecorder::<SwitchState>::new(
1660                "crash_test".into(),
1661                c"power_test",
1662                create_options(manager.clone()),
1663            )
1664            .unwrap();
1665
1666            recorder.record(SwitchState::ON);
1667            recorder.record(SwitchState::OFF);
1668
1669            // Scope ends, data is persisted to disk
1670        }
1671
1672        // Verify disk content
1673        let curr_csv = storage_path.join("crash_test.csv");
1674        let content = fs::read_to_string(curr_csv).unwrap();
1675        // Should contain integer values (ON=1, OFF=0) in order
1676        let lines: Vec<&str> = content.trim().lines().collect();
1677        assert_eq!(lines.len(), 2, "Expected 2 lines of recorded history");
1678
1679        // First record: ON (1)
1680        let parts0: Vec<&str> = lines[0].split(',').collect();
1681        assert_eq!(parts0.len(), 2, "Invalid CSV format in line 1");
1682        assert_eq!(parts0[1], "1", "First record should be ON (1)");
1683
1684        // Second record: OFF (0)
1685        let parts1: Vec<&str> = lines[1].split(',').collect();
1686        assert_eq!(parts1.len(), 2, "Invalid CSV format in line 2");
1687        assert_eq!(parts1[1], "0", "Second record should be OFF (0)");
1688
1689        // 3. FORCE "CRASH" STATE
1690        // Create 'Previous' file so library thinks this is a crash restart, not a reboot.
1691        // This forces it to READ from storage_path without overwriting it.
1692        let prev_csv = volatile_path.join("crash_test.csv");
1693        fs::write(&prev_csv, "").unwrap();
1694
1695        // 4. START RECORDER 2 (Simulate Restart)
1696        // This triggers hydration from disk into (Lazy: RingBuffer) or (Eager: BoundedListNode)
1697        let mut recorder_restarted = EnumStateRecorder::<SwitchState>::new(
1698            "crash_test".into(),
1699            c"power_test",
1700            create_options(manager),
1701        )
1702        .unwrap();
1703
1704        // ASSERTIONS
1705        assert_data_tree!(inspector, root: {
1706            power_observability_state_recorders: {
1707                crash_test: {
1708                    metadata: {
1709                        name: "crash_test",
1710                        type: "enum",
1711                        states: {
1712                            "OFF": 0u64,
1713                            "ON": 1u64,
1714                        }
1715                    },
1716                    history: {
1717                        "0": {
1718                            "@time": AnyIntProperty,
1719                            "value": "ON",
1720                        },
1721                        "1": {
1722                            "@time": AnyIntProperty,
1723                            "value": "OFF",
1724                        },
1725                    },
1726                    reset_info: {
1727                        count: 0i64, // Matches both lazy (casted i64) and eager (0 literal)
1728                        last_reset_ns: AnyIntProperty,
1729                    },
1730                }
1731            }
1732        });
1733
1734        // 5. RECORD NEW DATA
1735        recorder_restarted.record(SwitchState::ON);
1736        assert_data_tree!(inspector, root: {
1737            power_observability_state_recorders: {
1738                crash_test: {
1739                    metadata: {
1740                        name: "crash_test",
1741                        type: "enum",
1742                        states: {
1743                            "OFF": 0u64,
1744                            "ON": 1u64,
1745                        }
1746                    },
1747                    history: {
1748                        "0": {
1749                            "@time": AnyIntProperty,
1750                            "value": "ON",
1751                        },
1752                        "1": {
1753                            "@time": AnyIntProperty,
1754                            "value": "OFF",
1755                        },
1756                        "2": {
1757                            "@time": AnyIntProperty,
1758                            "value": "ON",
1759                        },
1760                    },
1761                    reset_info: {
1762                        count: 0i64,
1763                        last_reset_ns: AnyIntProperty,
1764                    },
1765                }
1766            }
1767        });
1768    }
1769
1770    #[test_case(true; "lazy")]
1771    #[fuchsia::test]
1772    async fn test_persistence_reboot(lazy_record: bool) {
1773        use std::fs;
1774        use tempfile::tempdir;
1775
1776        // 1. Setup isolated environment
1777        let dir = tempdir().unwrap();
1778        let storage_path = dir.path().join("data");
1779        let volatile_path = dir.path().join("tmp");
1780        fs::create_dir(&storage_path).unwrap();
1781        fs::create_dir(&volatile_path).unwrap();
1782
1783        let inspector = Inspector::default();
1784        let manager = StateRecorderManager::new(&inspector);
1785
1786        // Helper to generate options pointing to our temp dirs
1787        let create_options = |manager_ref| RecorderOptions {
1788            lazy_record,
1789            capacity: 10,
1790            manager: Some(manager_ref),
1791            persistence: Some(
1792                PersistenceOptions::new("reboot_test".to_string())
1793                    .storage_dir(storage_path.to_str().unwrap())
1794                    .volatile_dir(volatile_path.to_str().unwrap()),
1795            ),
1796        };
1797
1798        // 2. SIMULATE FRESH REBOOT STATE
1799        // - "Current" file exists in persistent storage (saved from previous run).
1800        // - "Previous" file in volatile storage is MISSING (cleared by OS reboot).
1801        let curr_csv = storage_path.join("reboot_test.csv");
1802        // Write raw CSV data simulating timestamps 1000 and 2000 with integers (ON=1, OFF=0)
1803        fs::write(&curr_csv, "1000,1\n2000,0\n").unwrap();
1804
1805        // Ensure volatile file doesn't exist (simulating clean /tmp)
1806        let prev_csv = volatile_path.join("reboot_test.csv");
1807        assert!(!prev_csv.exists());
1808
1809        // 3. START RECORDER (Trigger Logic)
1810        let mut recorder = EnumStateRecorder::<SwitchState>::new(
1811            "reboot_test".into(),
1812            c"power_test",
1813            create_options(manager),
1814        )
1815        .unwrap();
1816
1817        // 4. VERIFY FILESYSTEM (Rotation)
1818        // The file should have been moved from 'data' to 'tmp'.
1819        assert!(prev_csv.exists(), "Library should have rotated curr -> prev");
1820        assert!(curr_csv.exists(), "Library should have create a new current file");
1821
1822        let rotated_content = fs::read_to_string(&prev_csv).unwrap();
1823        assert_eq!(rotated_content, "1000,1\n2000,0\n");
1824
1825        // 5. ASSERTIONS (Inspect)
1826        assert_data_tree!(inspector, root: {
1827            power_observability_state_recorders: {
1828                reboot_test: {
1829                    metadata: {
1830                        name: "reboot_test",
1831                        type: "enum",
1832                        states: {
1833                            "OFF": 0u64,
1834                            "ON": 1u64,
1835                        }
1836                    },
1837                    // DATA FROM FILE IS HERE (Read Only / Static)
1838                    previous_boot_history: {
1839                        "0": {
1840                            "@time": 1000i64,
1841                            "value": "ON",
1842                        },
1843                        "1": {
1844                            "@time": 2000i64,
1845                            "value": "OFF",
1846                        },
1847                    },
1848                    // ACTIVE HISTORY IS EMPTY (Fresh start)
1849                    history: {},
1850                    reset_info: {
1851                        count: 0i64,
1852                        last_reset_ns: AnyIntProperty,
1853                    },
1854                }
1855            }
1856        });
1857
1858        // 6. RECORD NEW DATA AFTER REBOOT
1859        recorder.record(SwitchState::ON);
1860        recorder.record(SwitchState::OFF);
1861        assert_data_tree!(inspector, root: {
1862            power_observability_state_recorders: {
1863                reboot_test: {
1864                    metadata: {
1865                        name: "reboot_test",
1866                        type: "enum",
1867                        states: {
1868                            "OFF": 0u64,
1869                            "ON": 1u64,
1870                        }
1871                    },
1872                    // DATA FROM FILE IS HERE (Read Only / Static)
1873                    previous_boot_history: {
1874                        "0": {
1875                            "@time": 1000i64,
1876                            "value": "ON",
1877                        },
1878                        "1": {
1879                            "@time": 2000i64,
1880                            "value": "OFF",
1881                        },
1882                    },
1883                    // ACTIVE HISTORY IS NOW POPULATED WITH NEW DATA
1884                    history: {
1885                        "0": {
1886                            "@time": AnyIntProperty, // New timestamp
1887                            "value": "ON",
1888                        },
1889                        "1": {
1890                            "@time": AnyIntProperty, // New timestamp
1891                            "value": "OFF",
1892                        },
1893                    },
1894                    reset_info: {
1895                        count: 0i64,
1896                        last_reset_ns: AnyIntProperty,
1897                    },
1898                }
1899            }
1900        });
1901    }
1902
1903    #[test_case(false; "eager")]
1904    #[test_case(true; "lazy")]
1905    #[fuchsia::test]
1906    async fn test_named_u64_recorder(lazy_record: bool) {
1907        use std::fs;
1908        use tempfile::tempdir;
1909
1910        // Setup isolated persistence environment
1911        let dir = tempdir().unwrap();
1912        let storage_path = dir.path().join("data");
1913        let volatile_path = dir.path().join("tmp");
1914        fs::create_dir(&storage_path).unwrap();
1915        fs::create_dir(&volatile_path).unwrap();
1916
1917        let inspector = Inspector::default();
1918        let manager = StateRecorderManager::new(&inspector);
1919
1920        let mut map = HashMap::new();
1921        map.insert(100, "Hundred".to_string());
1922        map.insert(200, "TwoHundred".to_string());
1923
1924        let persistence_opts = if lazy_record {
1925            Some(
1926                PersistenceOptions::new("my_u64_metrics_p".to_string())
1927                    .storage_dir(storage_path.to_str().unwrap())
1928                    .volatile_dir(volatile_path.to_str().unwrap()),
1929            )
1930        } else {
1931            None
1932        };
1933
1934        // 1. Start Recorder and Record Data
1935        let mut recorder = NamedU64StateRecorder::new(
1936            "my_u64_metrics_p".into(),
1937            c"power_test",
1938            map.clone(),
1939            RecorderOptions {
1940                lazy_record,
1941                capacity: 10,
1942                manager: Some(manager.clone()),
1943                persistence: persistence_opts.clone(),
1944            },
1945        )
1946        .unwrap();
1947
1948        recorder.record(100);
1949        recorder.record(200);
1950        recorder.record(300); // Unknown
1951
1952        // 2. Verify Persistence (Lazy mode only)
1953        if lazy_record {
1954            drop(recorder); // Drop to ensure flush and release name
1955
1956            let curr_csv = storage_path.join("my_u64_metrics_p.csv");
1957            let content = fs::read_to_string(&curr_csv).unwrap();
1958            let lines: Vec<&str> = content.trim().lines().collect();
1959            assert_eq!(lines.len(), 3, "Expected 3 lines of recorded history");
1960
1961            // First record: 100
1962            let parts0: Vec<&str> = lines[0].split(',').collect();
1963            assert_eq!(parts0.len(), 2, "Invalid CSV format in line 1");
1964            assert_eq!(parts0[1], "100", "First record should be 100");
1965
1966            // Second record: 200
1967            let parts1: Vec<&str> = lines[1].split(',').collect();
1968            assert_eq!(parts1.len(), 2, "Invalid CSV format in line 2");
1969            assert_eq!(parts1[1], "200", "Second record should be 200");
1970
1971            // Third record: 300
1972            let parts2: Vec<&str> = lines[2].split(',').collect();
1973            assert_eq!(parts2.len(), 2, "Invalid CSV format in line 3");
1974            assert_eq!(parts2[1], "300", "Third record should be 300");
1975
1976            // 3. Restart Recorder (Simulate Reboot)
1977            let mut _recorder_restarted = NamedU64StateRecorder::new(
1978                "my_u64_metrics_p".into(),
1979                c"power_test",
1980                map,
1981                RecorderOptions {
1982                    lazy_record,
1983                    capacity: 10,
1984                    manager: Some(manager),
1985                    persistence: persistence_opts,
1986                },
1987            )
1988            .unwrap();
1989
1990            assert_data_tree!(inspector, root: {
1991                power_observability_state_recorders: {
1992                    my_u64_metrics_p: {
1993                        metadata: {
1994                            name: "my_u64_metrics_p",
1995                            type: "enum",
1996                            states: {
1997                                "Hundred": 100u64,
1998                                "TwoHundred": 200u64,
1999                            }
2000                        },
2001                        previous_boot_history: {
2002                            "0": {
2003                                "@time": AnyIntProperty,
2004                                "value": "Hundred",
2005                            },
2006                            "1": {
2007                                "@time": AnyIntProperty,
2008                                "value": "TwoHundred",
2009                            },
2010                             "2": {
2011                                "@time": AnyIntProperty,
2012                                "value": "<Unknown>",
2013                            },
2014                        },
2015                        history: {},
2016                        reset_info: {
2017                            count: 0,
2018                            last_reset_ns: AnyIntProperty,
2019                        }
2020                    }
2021                }
2022            });
2023        } else {
2024            // Eager mode
2025            // Recorder IS ALIVE here, so node exists.
2026            assert_data_tree!(inspector, root: {
2027                power_observability_state_recorders: {
2028                    my_u64_metrics_p: {
2029                        metadata: {
2030                            name: "my_u64_metrics_p",
2031                            type: "enum",
2032                            states: {
2033                                "Hundred": 100u64,
2034                                "TwoHundred": 200u64,
2035                            }
2036                        },
2037                        history: {
2038                            "0": {
2039                                "@time": AnyIntProperty,
2040                                "value": "Hundred",
2041                            },
2042                            "1": {
2043                                "@time": AnyIntProperty,
2044                                "value": "TwoHundred",
2045                            },
2046                             "2": {
2047                                "@time": AnyIntProperty,
2048                                "value": "<Unknown>",
2049                            },
2050                        },
2051                        reset_info: {
2052                            count: 0,
2053                            last_reset_ns: AnyIntProperty,
2054                        }
2055                    }
2056                }
2057            });
2058        }
2059    }
2060
2061    #[test_case(true; "lazy")]
2062    #[fuchsia::test]
2063    async fn test_numeric_persistence_reboot(lazy_record: bool) {
2064        use std::fs;
2065        use tempfile::tempdir;
2066
2067        // 1. Setup isolated persistence environment
2068        let dir = tempdir().unwrap();
2069        let storage_path = dir.path().join("data");
2070        let volatile_path = dir.path().join("tmp");
2071        fs::create_dir(&storage_path).unwrap();
2072        fs::create_dir(&volatile_path).unwrap();
2073
2074        let inspector = Inspector::default();
2075        let manager = StateRecorderManager::new(&inspector);
2076
2077        let create_options = |manager_ref| RecorderOptions {
2078            lazy_record,
2079            capacity: 10,
2080            manager: Some(manager_ref),
2081            persistence: Some(
2082                PersistenceOptions::new("num_reboot_test".to_string())
2083                    .storage_dir(storage_path.to_str().unwrap())
2084                    .volatile_dir(volatile_path.to_str().unwrap()),
2085            ),
2086        };
2087
2088        // 2. SIMULATE FRESH REBOOT STATE
2089        // - "Current" file exists (saved from previous run).
2090        // - "Previous" file in volatile is MISSING.
2091        let curr_csv = storage_path.join("num_reboot_test.csv");
2092        // Write raw CSV data: time,value
2093        fs::write(&curr_csv, "1000,42\n2000,100\n").unwrap();
2094
2095        let prev_csv = volatile_path.join("num_reboot_test.csv");
2096        assert!(!prev_csv.exists());
2097
2098        // 3. START RECORDER
2099        let mut _recorder = NumericStateRecorder::new(
2100            "num_reboot_test".into(),
2101            c"power_test",
2102            units!(Number),
2103            Some((0u64, 200u64)),
2104            create_options(manager),
2105        )
2106        .unwrap();
2107
2108        // 4. VERIFY FILESYSTEM (Rotation)
2109        // The file should have been moved from 'data' to 'tmp'.
2110        assert!(prev_csv.exists(), "Library should have rotated curr -> prev");
2111        assert!(curr_csv.exists(), "Library should have create a new current file");
2112
2113        let rotated_content = fs::read_to_string(&prev_csv).unwrap();
2114        assert_eq!(rotated_content, "1000,42\n2000,100\n");
2115
2116        // 5. ASSERTIONS (Inspect)
2117        assert_data_tree!(inspector, root: {
2118            power_observability_state_recorders: {
2119                num_reboot_test: {
2120                    metadata: {
2121                        name: "num_reboot_test",
2122                        type: "numeric",
2123                        units: "#",
2124                        range: {
2125                            min_inc: 0u64,
2126                            max_inc: 200u64,
2127                        }
2128                    },
2129                    previous_boot_history: {
2130                        "0": {
2131                            "@time": 1000i64,
2132                            "value": 42u64,
2133                        },
2134                        "1": {
2135                            "@time": 2000i64,
2136                            "value": 100u64,
2137                        },
2138                    },
2139                    history: {},
2140                    reset_info: {
2141                        count: 0i64,
2142                        last_reset_ns: AnyIntProperty,
2143                    }
2144                }
2145            }
2146        });
2147    }
2148}