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    Watts(Option<DecimalPrefix>),
559    Volts(Option<DecimalPrefix>),
560    Celsius(Option<DecimalPrefix>),
561    Number(Option<DecimalPrefix>),
562    Percent,
563}
564
565impl Display for Units {
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        fn write_helper(
568            f: &mut std::fmt::Formatter<'_>,
569            prefix: &Option<DecimalPrefix>,
570            unit_str: &str,
571        ) -> std::fmt::Result {
572            match prefix {
573                Some(p) => write!(f, "{}{}", p, unit_str),
574                None => write!(f, "{}", unit_str),
575            }
576        }
577
578        match self {
579            Units::Amps(prefix) => write_helper(f, prefix, "A"),
580            Units::AmpHours(prefix) => write_helper(f, prefix, "AH"),
581            Units::Hertz(prefix) => write_helper(f, prefix, "Hz"),
582            Units::Joules(prefix) => write_helper(f, prefix, "J"),
583            Units::Watts(prefix) => write_helper(f, prefix, "W"),
584            Units::Volts(prefix) => write_helper(f, prefix, "V"),
585            Units::Celsius(prefix) => write_helper(f, prefix, "C"),
586            Units::Number(prefix) => write_helper(f, prefix, "#"),
587            Units::Percent => write!(f, "%"),
588        }
589    }
590}
591
592/// Decimal prefixes for use with certain `Units`.
593#[derive(Copy, Clone, Debug, PartialEq, Eq)]
594pub enum DecimalPrefix {
595    Nano,
596    Micro,
597    Milli,
598    Centi,
599    Deci,
600    Kilo,
601    Mega,
602    Giga,
603}
604
605impl Display for DecimalPrefix {
606    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
607        match self {
608            DecimalPrefix::Nano => write!(f, "n"),
609            DecimalPrefix::Micro => write!(f, "u"),
610            DecimalPrefix::Milli => write!(f, "m"),
611            DecimalPrefix::Centi => write!(f, "c"),
612            DecimalPrefix::Deci => write!(f, "d"),
613            DecimalPrefix::Kilo => write!(f, "k"),
614            DecimalPrefix::Mega => write!(f, "M"),
615            DecimalPrefix::Giga => write!(f, "G"),
616        }
617    }
618}
619
620/// Assembles fully-specified measurement units for NumericStateRecorder, combining a base unit
621/// with an optional prefix.
622///
623/// Examples:
624///     - units!(Volt)
625///     - units!(Percent)
626///     - units!(Kilo, Hertz)
627///     - units!(Milli, Amp)
628#[macro_export]
629macro_rules! units {
630    (Percent) => {
631        $crate::Units::Percent
632    };
633    ($base_unit:ident) => {
634        $crate::Units::$base_unit(None)
635    };
636    ($prefix:ident, $base_unit:ident) => {
637        $crate::Units::$base_unit(Some($crate::DecimalPrefix::$prefix))
638    };
639}
640
641// Holds information for persistence
642#[derive(Clone, Debug)]
643pub struct PersistenceOptions {
644    /// Unique name for this recorder (e.g., "battery_level").
645    name: String,
646    /// For current history.
647    current_path: String,
648    /// For previous history.
649    previous_path: String,
650    // A temporary name for current history to achieve atomic persistence.
651    rename_path: String,
652}
653
654impl PersistenceOptions {
655    // Unique name and path to storage and path to volatile directory.
656    pub fn new(name: impl Into<String>) -> Self {
657        let name = name.into();
658        Self {
659            current_path: format!("/data/{}.csv", name),
660            previous_path: format!("/tmp/{}.csv", name),
661            rename_path: format!("/data/{}.tmp", name),
662            name,
663        }
664    }
665
666    pub fn storage_dir(mut self, dir: &str) -> Self {
667        self.current_path = format!("{}/{}.csv", dir, self.name);
668        self.rename_path = format!("{}/{}.tmp", dir, self.name);
669        self
670    }
671
672    pub fn volatile_dir(mut self, dir: &str) -> Self {
673        self.previous_path = format!("{}/{}.csv", dir, self.name);
674        self
675    }
676
677    // Helper to generate paths
678    fn paths(&self) -> (&str, &str, &str) {
679        (&self.current_path, &self.previous_path, &self.rename_path)
680    }
681}
682
683/// Handles persistence using TimestampRingBuffer as the backing store to save memory.
684struct PersistenceHandler<T: Copy> {
685    config: PersistenceOptions,
686    // We reuse TimestampRingBuffer for memory-optimized storage (i32 offsets)
687    buffer: Arc<Mutex<TimestampRingBuffer<T>>>,
688}
689
690impl<T: Copy + FromStr + Display> PersistenceHandler<T> {
691    fn new(
692        config: PersistenceOptions,
693        capacity: usize,
694    ) -> (Self, Arc<Mutex<TimestampRingBuffer<T>>>) {
695        let (curr, prev, _) = config.paths();
696
697        // Perform rotation before loading data
698        Self::prepare_files(&curr, &prev);
699
700        // Load any data remaining in 'current' (crash recovery)
701        let initial_data = Self::read_log(&curr);
702
703        // 3. Hydrate our internal ring buffer
704        let mut buffer = TimestampRingBuffer::with_capacity(capacity);
705        for (ts, val) in initial_data {
706            buffer.insert(ts, val);
707        }
708
709        let shared_buffer = Arc::new(Mutex::new(buffer));
710
711        (Self { config, buffer: shared_buffer.clone() }, shared_buffer)
712    }
713
714    /// Handles the rotation logic:
715    /// If PREV doesn't exist (reboot), move CURR to PREV.
716    /// If PREV exists (crash), leave CURR alone (it contains valuable pre-crash data).
717    fn prepare_files(curr_path: &str, prev_path: &str) {
718        if Path::new(prev_path).exists() {
719            // Previous file exists -> Crash recovery.
720            // Do not overwrite it. Do not touch current file.
721            log::warn!("Not moving history, {} already exists", prev_path);
722            return;
723        }
724
725        // Move content by reading then writing it for moves from /data to /tmp.
726        let Ok(content) = std::fs::read_to_string(curr_path).map_err(|e| {
727            log::info!("Could not read current history, not moving: {}", e);
728        }) else {
729            return;
730        };
731
732        if let Err(e) = std::fs::write(prev_path, &content) {
733            log::warn!("Could not write previous boot history: {}", e);
734            return;
735        }
736
737        if let Err(e) = std::fs::File::create(curr_path) {
738            log::warn!("Could not clear current boot history: {}", e);
739        }
740    }
741
742    fn flush(&self, buffer_guard: &TimestampRingBuffer<T>) {
743        let (curr, _, temp) = self.config.paths();
744        let try_write = || -> std::io::Result<()> {
745            let mut file =
746                OpenOptions::new().write(true).create(true).truncate(true).open(&temp)?;
747
748            // Iterate the ring buffer (which converts internal 32-bit offsets back to 64-bit TS)
749            for (ts, val) in buffer_guard.iter() {
750                writeln!(file, "{},{}", ts, val)?;
751            }
752
753            file.sync_data()?;
754            fs::rename(&temp, &curr)?;
755            Ok(())
756        };
757
758        if let Err(e) = try_write() {
759            log::error!("StateRecorder: Persist failed for {}: {:?}", self.config.name, e);
760        }
761    }
762
763    /// Appends data to memory and syncs to disk.
764    fn append(&mut self, timestamp: i64, value: T) {
765        let mut guard = self.buffer.lock();
766        guard.insert(timestamp, value);
767        self.flush(&guard);
768    }
769
770    /// Static helper to read log from disk into a vector.
771    fn read_log(path: &str) -> Vec<(i64, T)> {
772        let Ok(content) = fs::read_to_string(path) else {
773            return Vec::new();
774        };
775        content
776            .lines()
777            .filter_map(|line| {
778                let line = line.trim();
779                let mut parts = line.splitn(2, ',');
780                let ts = parts.next()?.trim().parse::<i64>().ok()?;
781                let val = parts.next()?.trim().parse::<T>().ok()?;
782                Some((ts, val))
783            })
784            .collect()
785    }
786}
787
788/// Options for NumericStateRecorder and EnumStateRecorder
789#[derive(Default)]
790pub struct RecorderOptions {
791    // If true, recorder will lazily record values to inspect. Otherwise, will record eagerly.
792    pub lazy_record: bool,
793    /// Maximum number of recorded values to store on a rolling basis.
794    pub capacity: usize,
795    /// Optional. If not set, the Recorder will be linked to this module's singleton
796    /// StateRecorderManager, which in turn corresponds to the singleton Inspector.
797    /// If set, the manager supplied here will be used.
798    pub manager: Option<Arc<Mutex<StateRecorderManager>>>,
799    // Optional persistence config
800    pub persistence: Option<PersistenceOptions>,
801}
802
803#[derive(Debug)]
804enum RecorderHistory<T: Copy + Debug> {
805    Eager(BoundedListNode),
806    Lazy(Arc<Mutex<TimestampRingBuffer<T>>>),
807}
808
809#[derive(Debug)]
810/// A fixed-size ring buffer with timestamps for each insertion.
811/// All input and output are in nanoseconds, but will be rounded down to
812/// the nearest millisecond and stored as milliseconds internally.
813/// When the capacity is reached, insertions will wrap around and continue
814/// from the beginning of the buffer. There is a maximum delta of ~24.8 days
815/// between insertions. If this maximum is exceeded, the buffer will drop
816/// all data except for the newest insertion.
817struct TimestampRingBuffer<T: Copy> {
818    /// Initial timestamp in milliseconds, used as basis for offsets.
819    start_timestamp_ms: i64,
820    /// Last timestamp inserted, in milliseconds.
821    last_timestamp_ms: i64,
822    /// Index where the next element should be inserted.
823    next_index: usize,
824    /// Store timestamps as millisecond offsets from `last_timestamp_ms`.
825    offset_ms: Vec<i32>,
826    /// Data to be stored in the buffer.
827    data: Vec<T>,
828    /// Number of times the buffer has been reset (due to max delta exceeded).
829    reset_count: u32,
830    /// Timestamp of the last buffer reset
831    last_reset_ms: i64,
832}
833
834const NANOSECONDS_PER_MILLISECOND: i64 = 1_000_000;
835
836fn ms_to_ns(ms: i64) -> i64 {
837    ms * NANOSECONDS_PER_MILLISECOND
838}
839
840fn ns_to_ms(ns: i64) -> i64 {
841    ns / NANOSECONDS_PER_MILLISECOND
842}
843
844impl<T: Copy> TimestampRingBuffer<T> {
845    fn with_capacity(capacity: usize) -> Self {
846        let now_ms = ns_to_ms(zx::BootInstant::get().into_nanos());
847        Self {
848            start_timestamp_ms: now_ms,
849            last_timestamp_ms: now_ms,
850            next_index: 0,
851            offset_ms: Vec::with_capacity(capacity),
852            data: Vec::with_capacity(capacity),
853            reset_count: 0,
854            last_reset_ms: now_ms,
855        }
856    }
857
858    fn insert(&mut self, timestamp_ns: i64, value: T) {
859        let timestamp_ms = ns_to_ms(timestamp_ns);
860        // Attempt to down-convert the offset from last_timestamp_ms to an i32
861        let offset_ms = match i32::try_from(timestamp_ms - self.last_timestamp_ms) {
862            Ok(offset_ms) => offset_ms,
863            Err(_) => {
864                // Offset from last_timestamp_ms exceeds maximum allowable,
865                // reset the buffer.
866                self.offset_ms.clear();
867                self.data.clear();
868                self.start_timestamp_ms = timestamp_ms;
869                self.next_index = 0;
870                self.reset_count += 1;
871                self.last_reset_ms = self.start_timestamp_ms;
872                0
873            }
874        };
875        if self.offset_ms.len() < self.offset_ms.capacity() {
876            // Buffer isn't full yet, just append.
877            self.offset_ms.push(offset_ms);
878            self.data.push(value);
879        } else {
880            // Buffer is full, shift `start_timestamp_ms` forward by the oldest
881            // offset, then overwrite that entry with the new data.
882            self.start_timestamp_ms += self.offset_ms[self.next_index] as i64;
883            self.offset_ms[self.next_index] = offset_ms;
884            self.data[self.next_index] = value;
885        }
886        self.last_timestamp_ms = timestamp_ms;
887        self.next_index = (self.next_index + 1) % self.offset_ms.capacity();
888    }
889
890    /// Returns the reset count, and the timestamp of the last reset in nanoseconds.
891    fn get_reset_info(&self) -> (u32, i64) {
892        (self.reset_count, ms_to_ns(self.last_reset_ms))
893    }
894
895    /// Returns an Iterator of (timestamp in nanoseconds, T), starting
896    /// from the oldest entry.
897    fn iter(&self) -> TimestampRingBufferIter<'_, T> {
898        TimestampRingBufferIter::new(self)
899    }
900}
901
902struct TimestampRingBufferIter<'a, T: Copy> {
903    buffer: &'a TimestampRingBuffer<T>,
904    index: usize,
905    last_timestamp_ms: i64,
906}
907
908impl<'a, T: Copy> TimestampRingBufferIter<'a, T> {
909    fn new(buffer: &'a TimestampRingBuffer<T>) -> Self {
910        Self { buffer, index: 0, last_timestamp_ms: buffer.start_timestamp_ms }
911    }
912}
913
914/// Iterate over the wrapped buffer, returning (timestamp in nanoseconds, T),
915/// starting from the oldest entry.
916impl<T: Copy> Iterator for TimestampRingBufferIter<'_, T> {
917    type Item = (i64, T);
918
919    fn next(&mut self) -> Option<(i64, T)> {
920        if self.index >= self.buffer.offset_ms.len() {
921            return None;
922        }
923        // Start from the oldest insertion and wrap around.
924        let index = (self.index + self.buffer.next_index) % self.buffer.offset_ms.len();
925        let timestamp_ms = self.last_timestamp_ms + self.buffer.offset_ms[index] as i64;
926        self.index += 1;
927        self.last_timestamp_ms = timestamp_ms;
928        Some((ms_to_ns(timestamp_ms), self.buffer.data[index]))
929    }
930}
931
932pub struct NumericStateRecorder<T: RecordableNumericType> {
933    manager: Arc<Mutex<StateRecorderManager>>,
934    name: String,
935    trace_category: &'static CStr,
936    trace_name: &'static CStr,
937    units: String,
938    history: RecorderHistory<T>,
939    persistence: Option<PersistenceHandler<T>>,
940    _root_node: inspect::Node,
941    trace_id: ftrace::Id,
942    _phantom: PhantomData<T>,
943}
944
945impl<T: RecordableNumericType> NumericStateRecorder<T> {
946    /// Creates a new NumericStateRecorder.
947    ///
948    /// See `RecorderOptions` for more details on options that can be specified.
949    ///
950    /// Errors:
951    ///   - StateRecorderError::DuplicateName: `metadata.name` is already in use by a StateRecorder
952    ///     associated with `manager`.
953    ///   - StateRecorderError::IncompatibleString: Either `name` or the display name of a state
954    ///     cannot be converted to a CString.
955    ///   - StateRecorderError::InvalidOptions: `options` is invalid for the given mode.
956    pub fn new(
957        name: String,
958        trace_category: &'static CStr,
959        units: Units,
960        range: Option<(T, T)>,
961        options: RecorderOptions,
962    ) -> Result<Self, StateRecorderError> {
963        let manager = options.manager.clone().unwrap_or_else(|| SINGLETON_MANAGER.clone());
964        let node = register_with_manager(&manager, &name)?;
965
966        let trace_name = lazy_static_cstr(&name)?;
967        let units_str = format!("{}", units);
968
969        node.record_child("metadata", |metadata_node| {
970            metadata_node.record_string("name", &name);
971            metadata_node.record_string("type", "numeric");
972            metadata_node.record_string("units", &units_str);
973            match range {
974                Some(r) => metadata_node.record_child("range", |node| T::record_range(&r, node)),
975                None => metadata_node.record_string("range", "<Unspecified>"),
976            }
977        });
978
979        let record_item = |node: &inspect::Node, val: &T| {
980            val.record(node, "value");
981        };
982
983        let (history, persistence) = setup_recording_backend(&node, &options, record_item)?;
984
985        Ok(Self {
986            manager,
987            name,
988            trace_category,
989            trace_name,
990            units: units_str,
991            history,
992            persistence,
993            _root_node: node,
994            trace_id: ftrace::Id::random(),
995            _phantom: PhantomData,
996        })
997    }
998
999    pub fn record(&mut self, state_value: T) {
1000        let timestamp = zx::BootInstant::get().into_nanos();
1001
1002        ftrace::counter!(
1003            self.trace_category,
1004            self.trace_name,
1005            self.trace_id.into(),
1006            &self.units.to_string() => state_value.trace_value()
1007        );
1008
1009        // If Persistence is on (Lazy), the handler OWNS the shared buffer update.
1010        if let Some(handler) = &mut self.persistence {
1011            handler.append(timestamp, state_value);
1012        } else {
1013            match &mut self.history {
1014                RecorderHistory::Eager(history) => {
1015                    history.add_entry(|node| {
1016                        node.record_int("@time", timestamp);
1017                        state_value.record(node, "value");
1018                    });
1019                }
1020                RecorderHistory::Lazy(history) => {
1021                    history.lock().insert(timestamp, state_value);
1022                }
1023            }
1024        }
1025    }
1026}
1027
1028impl<T: RecordableNumericType> Drop for NumericStateRecorder<T> {
1029    fn drop(&mut self) {
1030        self.manager.lock().unregister_name(&self.name);
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use diagnostics_assertions::{AnyIntProperty, assert_data_tree};
1038    use fuchsia_inspect::Inspector;
1039    use strum_macros::{Display, EnumIter, EnumString};
1040    use test_case::test_case;
1041
1042    #[derive(Copy, Clone, Debug, Display, EnumIter, EnumString, Eq, PartialEq, Hash)]
1043    #[repr(u8)]
1044    enum SwitchState {
1045        OFF = 0,
1046        ON = 1,
1047    }
1048
1049    impl From<SwitchState> for u64 {
1050        fn from(value: SwitchState) -> Self {
1051            value as Self
1052        }
1053    }
1054
1055    #[fuchsia::test]
1056    async fn test_timestamp_ring_buffer() {
1057        let mut buffer = TimestampRingBuffer::<i32>::with_capacity(3);
1058        let start_ms = buffer.start_timestamp_ms;
1059
1060        let t1 = (ms_to_ns(start_ms + 1000), 1);
1061        // t2's timestamp is before t1, which will result in a negative offset.
1062        let t2 = (ms_to_ns(start_ms + 900), 2);
1063        let t3 = (ms_to_ns(start_ms + 3000), 3);
1064
1065        buffer.insert(t1.0, t1.1);
1066        buffer.insert(t2.0, t2.1);
1067        buffer.insert(t3.0, t3.1);
1068
1069        assert_eq!(vec![t1, t2, t3], buffer.iter().collect::<Vec<_>>());
1070        assert_eq!((0, ms_to_ns(start_ms)), buffer.get_reset_info());
1071
1072        // Buffer is already at capacity, so this should overwrite the first element.
1073        let t4 = (ms_to_ns(start_ms + 4000), 4);
1074        buffer.insert(t4.0, t4.1);
1075        assert_eq!(vec![t2, t3, t4], buffer.iter().collect::<Vec<_>>());
1076        assert_eq!((0, ms_to_ns(start_ms)), buffer.get_reset_info());
1077    }
1078
1079    #[fuchsia::test]
1080    async fn test_timestamp_ring_buffer_resets_on_maximum_offset() {
1081        let mut buffer = TimestampRingBuffer::<i32>::with_capacity(3);
1082        let start_ms = buffer.start_timestamp_ms;
1083
1084        const MAX_OFFSET_MS: i64 = i32::MAX as i64;
1085        let t1 = (ms_to_ns(start_ms + 1000), 1);
1086        let t2 = (t1.0 + ms_to_ns(MAX_OFFSET_MS), 2);
1087
1088        buffer.insert(t1.0, t1.1);
1089        buffer.insert(t2.0, t2.1);
1090
1091        assert_eq!(vec![t1, t2], buffer.iter().collect::<Vec<_>>());
1092        assert_eq!((0, ms_to_ns(start_ms)), buffer.get_reset_info());
1093
1094        // This should exceed the maximum allowable timestamp offset,
1095        // causing the buffer to reset.
1096        let t3 = (t2.0 + ms_to_ns(MAX_OFFSET_MS + 1), 3);
1097        buffer.insert(t3.0, t3.1);
1098        assert_eq!(vec![t3], buffer.iter().collect::<Vec<_>>());
1099        assert_eq!((1, t3.0), buffer.get_reset_info());
1100    }
1101
1102    #[test_case(false; "eager")]
1103    #[test_case(true; "lazy")]
1104    #[fuchsia::test]
1105    async fn test_enum_off_on(lazy_record: bool) {
1106        let inspector = Inspector::default();
1107        let manager = StateRecorderManager::new(&inspector);
1108
1109        let mut recorder = EnumStateRecorder::new(
1110            "my_switch".into(),
1111            c"power_test",
1112            RecorderOptions {
1113                lazy_record,
1114                capacity: 10,
1115                manager: Some(manager),
1116                persistence: None,
1117            },
1118        )
1119        .unwrap();
1120
1121        recorder.record(SwitchState::OFF);
1122        recorder.record(SwitchState::ON);
1123        recorder.record(SwitchState::OFF);
1124        recorder.record(SwitchState::ON);
1125        assert_data_tree!(inspector, root: {
1126            power_observability_state_recorders: {
1127                my_switch: {
1128                    metadata: {
1129                        name: "my_switch",
1130                        type: "enum",
1131                        states: {
1132                            "OFF": 0u64,
1133                            "ON": 1u64,
1134                        }
1135                    },
1136                    history: {
1137                        "0": {
1138                            "@time": AnyIntProperty,
1139                            "value": "OFF",
1140                        },
1141                        "1": {
1142                            "@time": AnyIntProperty,
1143                            "value": "ON",
1144                        },
1145                        "2": {
1146                            "@time": AnyIntProperty,
1147                            "value": "OFF",
1148                        },
1149                        "3": {
1150                            "@time": AnyIntProperty,
1151                            "value": "ON",
1152                        },
1153                    },
1154                    reset_info: {
1155                        count: 0,
1156                        last_reset_ns: AnyIntProperty,
1157                    }
1158                }
1159            }
1160        });
1161    }
1162
1163    #[test_case(false; "eager")]
1164    #[test_case(true; "lazy")]
1165    #[fuchsia::test]
1166    async fn test_multiple_recorders(lazy_record: bool) {
1167        #[derive(Copy, Clone, Debug, Display, EnumIter, EnumString, Eq, PartialEq, Hash)]
1168        #[repr(u8)]
1169        enum EnablementState {
1170            DISABLED = 0,
1171            ENABLED = 1,
1172        }
1173        impl From<EnablementState> for u64 {
1174            fn from(value: EnablementState) -> Self {
1175                value as Self
1176            }
1177        }
1178
1179        let inspector = Inspector::default();
1180        let manager = StateRecorderManager::new(&inspector);
1181
1182        let mut recorder_0 = EnumStateRecorder::new(
1183            "switch_0".into(),
1184            c"power_test",
1185            RecorderOptions {
1186                lazy_record,
1187                capacity: 10,
1188                manager: Some(manager.clone()),
1189                persistence: None,
1190            },
1191        )
1192        .unwrap();
1193        let mut recorder_1 = EnumStateRecorder::new(
1194            "switch_1".into(),
1195            c"power_test",
1196            RecorderOptions {
1197                lazy_record,
1198                capacity: 10,
1199                manager: Some(manager),
1200                persistence: None,
1201            },
1202        )
1203        .unwrap();
1204        recorder_0.record(SwitchState::OFF);
1205        recorder_0.record(SwitchState::ON);
1206        recorder_1.record(EnablementState::ENABLED);
1207        recorder_1.record(EnablementState::DISABLED);
1208
1209        assert_data_tree!(inspector, root: {
1210            power_observability_state_recorders: {
1211                switch_0: {
1212                    metadata: {
1213                        name: "switch_0",
1214                        type: "enum",
1215                        states: {
1216                            "OFF": 0u64,
1217                            "ON": 1u64,
1218                        }
1219                    },
1220                    history: {
1221                        "0": {
1222                            "@time": AnyIntProperty,
1223                            "value": "OFF",
1224                        },
1225                        "1": {
1226                            "@time": AnyIntProperty,
1227                            "value": "ON",
1228                        },
1229                    },
1230                    reset_info: {
1231                        count: 0,
1232                        last_reset_ns: AnyIntProperty,
1233                    }
1234                },
1235               switch_1: {
1236                    metadata: {
1237                        name: "switch_1",
1238                        type: "enum",
1239                        states: {
1240                            "DISABLED": 0u64,
1241                            "ENABLED": 1u64,
1242                        }
1243                    },
1244                    history: {
1245                        "0": {
1246                            "@time": AnyIntProperty,
1247                            "value": "ENABLED",
1248                        },
1249                        "1": {
1250                            "@time": AnyIntProperty,
1251                            "value": "DISABLED",
1252                        },
1253                    },
1254                    reset_info: {
1255                        count: 0,
1256                        last_reset_ns: AnyIntProperty,
1257                    }
1258                }
1259            }
1260        })
1261    }
1262
1263    #[test_case(false; "eager")]
1264    #[test_case(true; "lazy")]
1265    #[fuchsia::test]
1266    async fn test_enum_three_states(lazy_record: bool) {
1267        #[derive(Copy, Clone, Debug, Display, EnumIter, EnumString, Eq, PartialEq, Hash)]
1268        #[repr(u8)]
1269        enum FanSpeed {
1270            OFF = 0,
1271            LOW = 1,
1272            HIGH = 2,
1273        }
1274
1275        impl From<FanSpeed> for u64 {
1276            fn from(value: FanSpeed) -> Self {
1277                value as Self
1278            }
1279        }
1280
1281        let inspector = Inspector::default();
1282        let manager = StateRecorderManager::new(&inspector);
1283
1284        let mut recorder = EnumStateRecorder::new(
1285            "the_best_fan".into(),
1286            c"power_test",
1287            RecorderOptions {
1288                lazy_record,
1289                capacity: 10,
1290                manager: Some(manager),
1291                persistence: None,
1292            },
1293        )
1294        .unwrap();
1295
1296        recorder.record(FanSpeed::OFF);
1297        recorder.record(FanSpeed::LOW);
1298        recorder.record(FanSpeed::HIGH);
1299        recorder.record(FanSpeed::OFF);
1300        recorder.record(FanSpeed::HIGH);
1301        assert_data_tree!(inspector, root: {
1302            power_observability_state_recorders: {
1303                the_best_fan: {
1304                    metadata: {
1305                        name: "the_best_fan",
1306                        type: "enum",
1307                        states: {
1308                            "OFF": 0u64,
1309                            "LOW": 1u64,
1310                            "HIGH": 2u64,
1311                        }
1312                    },
1313                    history: {
1314                        "0": {
1315                            "@time": AnyIntProperty,
1316                            "value": "OFF",
1317                        },
1318                        "1": {
1319                            "@time": AnyIntProperty,
1320                            "value": "LOW",
1321                        },
1322                        "2": {
1323                            "@time": AnyIntProperty,
1324                            "value": "HIGH",
1325                        },
1326                        "3": {
1327                            "@time": AnyIntProperty,
1328                            "value": "OFF",
1329                        },
1330                        "4": {
1331                            "@time": AnyIntProperty,
1332                            "value": "HIGH",
1333                        },
1334                    },
1335                    reset_info: {
1336                        count: 0,
1337                        last_reset_ns: AnyIntProperty,
1338                    }
1339                }
1340            }
1341        });
1342    }
1343
1344    #[test_case(false; "eager")]
1345    #[test_case(true; "lazy")]
1346    #[fuchsia::test]
1347    async fn test_name_reuse_not_allowed(lazy_record: bool) {
1348        let inspector = Inspector::default();
1349        let manager = StateRecorderManager::new(&inspector);
1350
1351        let recorder = EnumStateRecorder::<SwitchState>::new(
1352            "my_switch".into(),
1353            c"power_test",
1354            RecorderOptions {
1355                lazy_record,
1356                capacity: 10,
1357                manager: Some(manager.clone()),
1358                persistence: None,
1359            },
1360        )
1361        .unwrap();
1362
1363        // While `recorder` is still in scope, its name cannot be reused.
1364        let result = EnumStateRecorder::<SwitchState>::new(
1365            "my_switch".into(),
1366            c"power_test",
1367            RecorderOptions {
1368                lazy_record,
1369                capacity: 10,
1370                manager: Some(manager.clone()),
1371                persistence: None,
1372            },
1373        );
1374        assert!(result.is_err());
1375
1376        // After `recorder` is dropped, its name can be used again.
1377        drop(recorder);
1378        let result = EnumStateRecorder::<SwitchState>::new(
1379            "my_switch".into(),
1380            c"power_test",
1381            RecorderOptions {
1382                lazy_record,
1383                capacity: 10,
1384                manager: Some(manager.clone()),
1385                persistence: None,
1386            },
1387        );
1388        assert!(result.is_ok());
1389    }
1390
1391    #[test_case(false; "eager")]
1392    #[test_case(true; "lazy")]
1393    #[fuchsia::test]
1394    async fn test_singleton_manager(lazy_record: bool) {
1395        let mut recorder = EnumStateRecorder::new(
1396            "my_switch".into(),
1397            c"power_test",
1398            RecorderOptions { lazy_record, capacity: 10, manager: None, persistence: None },
1399        )
1400        .unwrap();
1401
1402        recorder.record(SwitchState::OFF);
1403        recorder.record(SwitchState::ON);
1404        assert_data_tree!(inspect::component::inspector(), root: {
1405            power_observability_state_recorders: {
1406                my_switch: {
1407                    metadata: {
1408                        name: "my_switch",
1409                        type: "enum",
1410                        states: {
1411                            "OFF": 0u64,
1412                            "ON": 1u64,
1413                        }
1414                    },
1415                    history: {
1416                        "0": {
1417                            "@time": AnyIntProperty,
1418                            "value": "OFF",
1419                        },
1420                        "1": {
1421                            "@time": AnyIntProperty,
1422                            "value": "ON",
1423                        },
1424                    },
1425                    reset_info: {
1426                        count: 0,
1427                        last_reset_ns: AnyIntProperty,
1428                    }
1429                }
1430            }
1431        });
1432    }
1433
1434    #[fuchsia::test]
1435    async fn test_recorder_is_send() {
1436        fn assert_send<T: Send>() {}
1437        assert_send::<EnumStateRecorder<SwitchState>>();
1438    }
1439
1440    async fn test_uint_numeric_type<T: RecordableNumericType>(lazy_record: bool)
1441    where
1442        T: Into<u64> + From<u8>,
1443    {
1444        let inspector = Inspector::default();
1445        let manager = StateRecorderManager::new(&inspector);
1446        let mut recorder = NumericStateRecorder::new(
1447            "my_stateful_thing".into(),
1448            c"power_test",
1449            units!(Percent),
1450            Some((T::from(0), T::from(255))),
1451            RecorderOptions {
1452                lazy_record,
1453                capacity: 10,
1454                manager: Some(manager),
1455                persistence: None,
1456            },
1457        )
1458        .unwrap();
1459
1460        recorder.record(T::from(10));
1461        recorder.record(T::from(0));
1462        assert_data_tree!(inspector, root: {
1463            power_observability_state_recorders: {
1464                my_stateful_thing: {
1465                    metadata: {
1466                        name: "my_stateful_thing",
1467                        type: "numeric",
1468                        units: "%",
1469                        range: {
1470                            min_inc: 0u64,
1471                            max_inc: 255u64
1472                        },
1473                    },
1474                    history: {
1475                        "0": {
1476                            "@time": AnyIntProperty,
1477                            "value": 10u64,
1478                        },
1479                        "1": {
1480                            "@time": AnyIntProperty,
1481                            "value": 0u64,
1482                        },
1483                    },
1484                    reset_info: {
1485                        count: 0,
1486                        last_reset_ns: AnyIntProperty,
1487                    }
1488                }
1489            }
1490        });
1491    }
1492
1493    #[test_case(false; "eager")]
1494    #[test_case(true; "lazy")]
1495    #[fuchsia::test]
1496    async fn test_uint_numeric_types(lazy_record: bool) {
1497        test_uint_numeric_type::<u8>(lazy_record).await;
1498        test_uint_numeric_type::<u16>(lazy_record).await;
1499        test_uint_numeric_type::<u32>(lazy_record).await;
1500        test_uint_numeric_type::<u64>(lazy_record).await;
1501    }
1502
1503    async fn test_int_numeric_type<T: RecordableNumericType>(lazy_record: bool)
1504    where
1505        T: Into<i64> + From<i8>,
1506    {
1507        let inspector = Inspector::default();
1508        let manager = StateRecorderManager::new(&inspector);
1509        let mut recorder = NumericStateRecorder::new(
1510            "my_stateful_thing".into(),
1511            c"power_test",
1512            units!(Number),
1513            Some((T::from(-128), T::from(127))),
1514            RecorderOptions {
1515                lazy_record,
1516                capacity: 10,
1517                manager: Some(manager),
1518                persistence: None,
1519            },
1520        )
1521        .unwrap();
1522
1523        recorder.record(T::from(10));
1524        recorder.record(T::from(0));
1525        assert_data_tree!(inspector, root: {
1526            power_observability_state_recorders: {
1527                my_stateful_thing: {
1528                    metadata: {
1529                        name: "my_stateful_thing",
1530                        type: "numeric",
1531                        units: "#",
1532                        range: {
1533                            min_inc: -128i64,
1534                            max_inc: 127i64
1535                        },
1536                    },
1537                    history: {
1538                        "0": {
1539                            "@time": AnyIntProperty,
1540                            "value": 10i64,
1541                        },
1542                        "1": {
1543                            "@time": AnyIntProperty,
1544                            "value": 0i64,
1545                        },
1546                    },
1547                    reset_info: {
1548                        count: 0,
1549                        last_reset_ns: AnyIntProperty,
1550                    }
1551                }
1552            }
1553        });
1554    }
1555
1556    #[test_case(false; "eager")]
1557    #[test_case(true; "lazy")]
1558    #[fuchsia::test]
1559    async fn test_int_numeric_types(lazy_record: bool) {
1560        test_int_numeric_type::<i8>(lazy_record).await;
1561        test_int_numeric_type::<i16>(lazy_record).await;
1562        test_int_numeric_type::<i32>(lazy_record).await;
1563        test_int_numeric_type::<i64>(lazy_record).await;
1564    }
1565
1566    async fn test_float_numeric_type<T: RecordableNumericType>(lazy_record: bool)
1567    where
1568        T: Into<f64> + From<u8>,
1569    {
1570        let inspector = Inspector::default();
1571        let manager = StateRecorderManager::new(&inspector);
1572        let mut recorder = NumericStateRecorder::new(
1573            "my_stateful_thing".into(),
1574            c"power_test",
1575            units!(Kilo, Hertz),
1576            Some((T::from(0), T::from(255))),
1577            RecorderOptions {
1578                lazy_record,
1579                capacity: 10,
1580                manager: Some(manager),
1581                persistence: None,
1582            },
1583        )
1584        .unwrap();
1585
1586        recorder.record(T::from(10));
1587        recorder.record(T::from(0));
1588        assert_data_tree!(inspector, root: {
1589            power_observability_state_recorders: {
1590                my_stateful_thing: {
1591                    metadata: {
1592                        name: "my_stateful_thing",
1593                        type: "numeric",
1594                        units: "kHz",
1595                        range: {
1596                            min_inc: 0.0,
1597                            max_inc: 255.0
1598                        },
1599                    },
1600                    history: {
1601                        "0": {
1602                            "@time": AnyIntProperty,
1603                            "value": 10.0,
1604                        },
1605                        "1": {
1606                            "@time": AnyIntProperty,
1607                            "value": 0.0,
1608                        },
1609                    },
1610                    reset_info: {
1611                        count: 0,
1612                        last_reset_ns: AnyIntProperty,
1613                    }
1614                }
1615            }
1616        });
1617    }
1618
1619    #[test_case(false; "eager")]
1620    #[test_case(true; "lazy")]
1621    #[fuchsia::test]
1622    async fn test_float_numeric_types(lazy_record: bool) {
1623        test_float_numeric_type::<f32>(lazy_record).await;
1624        test_float_numeric_type::<f64>(lazy_record).await;
1625    }
1626
1627    #[test_case(true; "lazy")]
1628    #[fuchsia::test]
1629    async fn test_persistence_crash_recovery(lazy_record: bool) {
1630        use std::fs;
1631        use tempfile::tempdir;
1632
1633        // 1. Setup isolated environment
1634        let dir = tempdir().unwrap();
1635        let storage_path = dir.path().join("data");
1636        let volatile_path = dir.path().join("tmp");
1637        fs::create_dir(&storage_path).unwrap();
1638        fs::create_dir(&volatile_path).unwrap();
1639
1640        let inspector = Inspector::default();
1641        let manager = StateRecorderManager::new(&inspector);
1642
1643        // Helper to generate options
1644        let create_options = |manager_ref| RecorderOptions {
1645            lazy_record, // Passed from test_case argument
1646            capacity: 10,
1647            manager: Some(manager_ref),
1648            persistence: Some(
1649                PersistenceOptions::new("crash_test".to_string())
1650                    .storage_dir(storage_path.to_str().unwrap())
1651                    .volatile_dir(volatile_path.to_str().unwrap()),
1652            ),
1653        };
1654
1655        // 2. START RECORDER 1 (Fill data)
1656        {
1657            let mut recorder = EnumStateRecorder::<SwitchState>::new(
1658                "crash_test".into(),
1659                c"power_test",
1660                create_options(manager.clone()),
1661            )
1662            .unwrap();
1663
1664            recorder.record(SwitchState::ON);
1665            recorder.record(SwitchState::OFF);
1666
1667            // Scope ends, data is persisted to disk
1668        }
1669
1670        // Verify disk content
1671        let curr_csv = storage_path.join("crash_test.csv");
1672        let content = fs::read_to_string(curr_csv).unwrap();
1673        // Should contain integer values (ON=1, OFF=0) in order
1674        let lines: Vec<&str> = content.trim().lines().collect();
1675        assert_eq!(lines.len(), 2, "Expected 2 lines of recorded history");
1676
1677        // First record: ON (1)
1678        let parts0: Vec<&str> = lines[0].split(',').collect();
1679        assert_eq!(parts0.len(), 2, "Invalid CSV format in line 1");
1680        assert_eq!(parts0[1], "1", "First record should be ON (1)");
1681
1682        // Second record: OFF (0)
1683        let parts1: Vec<&str> = lines[1].split(',').collect();
1684        assert_eq!(parts1.len(), 2, "Invalid CSV format in line 2");
1685        assert_eq!(parts1[1], "0", "Second record should be OFF (0)");
1686
1687        // 3. FORCE "CRASH" STATE
1688        // Create 'Previous' file so library thinks this is a crash restart, not a reboot.
1689        // This forces it to READ from storage_path without overwriting it.
1690        let prev_csv = volatile_path.join("crash_test.csv");
1691        fs::write(&prev_csv, "").unwrap();
1692
1693        // 4. START RECORDER 2 (Simulate Restart)
1694        // This triggers hydration from disk into (Lazy: RingBuffer) or (Eager: BoundedListNode)
1695        let mut recorder_restarted = EnumStateRecorder::<SwitchState>::new(
1696            "crash_test".into(),
1697            c"power_test",
1698            create_options(manager),
1699        )
1700        .unwrap();
1701
1702        // ASSERTIONS
1703        assert_data_tree!(inspector, root: {
1704            power_observability_state_recorders: {
1705                crash_test: {
1706                    metadata: {
1707                        name: "crash_test",
1708                        type: "enum",
1709                        states: {
1710                            "OFF": 0u64,
1711                            "ON": 1u64,
1712                        }
1713                    },
1714                    history: {
1715                        "0": {
1716                            "@time": AnyIntProperty,
1717                            "value": "ON",
1718                        },
1719                        "1": {
1720                            "@time": AnyIntProperty,
1721                            "value": "OFF",
1722                        },
1723                    },
1724                    reset_info: {
1725                        count: 0i64, // Matches both lazy (casted i64) and eager (0 literal)
1726                        last_reset_ns: AnyIntProperty,
1727                    },
1728                }
1729            }
1730        });
1731
1732        // 5. RECORD NEW DATA
1733        recorder_restarted.record(SwitchState::ON);
1734        assert_data_tree!(inspector, root: {
1735            power_observability_state_recorders: {
1736                crash_test: {
1737                    metadata: {
1738                        name: "crash_test",
1739                        type: "enum",
1740                        states: {
1741                            "OFF": 0u64,
1742                            "ON": 1u64,
1743                        }
1744                    },
1745                    history: {
1746                        "0": {
1747                            "@time": AnyIntProperty,
1748                            "value": "ON",
1749                        },
1750                        "1": {
1751                            "@time": AnyIntProperty,
1752                            "value": "OFF",
1753                        },
1754                        "2": {
1755                            "@time": AnyIntProperty,
1756                            "value": "ON",
1757                        },
1758                    },
1759                    reset_info: {
1760                        count: 0i64,
1761                        last_reset_ns: AnyIntProperty,
1762                    },
1763                }
1764            }
1765        });
1766    }
1767
1768    #[test_case(true; "lazy")]
1769    #[fuchsia::test]
1770    async fn test_persistence_reboot(lazy_record: bool) {
1771        use std::fs;
1772        use tempfile::tempdir;
1773
1774        // 1. Setup isolated environment
1775        let dir = tempdir().unwrap();
1776        let storage_path = dir.path().join("data");
1777        let volatile_path = dir.path().join("tmp");
1778        fs::create_dir(&storage_path).unwrap();
1779        fs::create_dir(&volatile_path).unwrap();
1780
1781        let inspector = Inspector::default();
1782        let manager = StateRecorderManager::new(&inspector);
1783
1784        // Helper to generate options pointing to our temp dirs
1785        let create_options = |manager_ref| RecorderOptions {
1786            lazy_record,
1787            capacity: 10,
1788            manager: Some(manager_ref),
1789            persistence: Some(
1790                PersistenceOptions::new("reboot_test".to_string())
1791                    .storage_dir(storage_path.to_str().unwrap())
1792                    .volatile_dir(volatile_path.to_str().unwrap()),
1793            ),
1794        };
1795
1796        // 2. SIMULATE FRESH REBOOT STATE
1797        // - "Current" file exists in persistent storage (saved from previous run).
1798        // - "Previous" file in volatile storage is MISSING (cleared by OS reboot).
1799        let curr_csv = storage_path.join("reboot_test.csv");
1800        // Write raw CSV data simulating timestamps 1000 and 2000 with integers (ON=1, OFF=0)
1801        fs::write(&curr_csv, "1000,1\n2000,0\n").unwrap();
1802
1803        // Ensure volatile file doesn't exist (simulating clean /tmp)
1804        let prev_csv = volatile_path.join("reboot_test.csv");
1805        assert!(!prev_csv.exists());
1806
1807        // 3. START RECORDER (Trigger Logic)
1808        let mut recorder = EnumStateRecorder::<SwitchState>::new(
1809            "reboot_test".into(),
1810            c"power_test",
1811            create_options(manager),
1812        )
1813        .unwrap();
1814
1815        // 4. VERIFY FILESYSTEM (Rotation)
1816        // The file should have been moved from 'data' to 'tmp'.
1817        assert!(prev_csv.exists(), "Library should have rotated curr -> prev");
1818        assert!(curr_csv.exists(), "Library should have create a new current file");
1819
1820        let rotated_content = fs::read_to_string(&prev_csv).unwrap();
1821        assert_eq!(rotated_content, "1000,1\n2000,0\n");
1822
1823        // 5. ASSERTIONS (Inspect)
1824        assert_data_tree!(inspector, root: {
1825            power_observability_state_recorders: {
1826                reboot_test: {
1827                    metadata: {
1828                        name: "reboot_test",
1829                        type: "enum",
1830                        states: {
1831                            "OFF": 0u64,
1832                            "ON": 1u64,
1833                        }
1834                    },
1835                    // DATA FROM FILE IS HERE (Read Only / Static)
1836                    previous_boot_history: {
1837                        "0": {
1838                            "@time": 1000i64,
1839                            "value": "ON",
1840                        },
1841                        "1": {
1842                            "@time": 2000i64,
1843                            "value": "OFF",
1844                        },
1845                    },
1846                    // ACTIVE HISTORY IS EMPTY (Fresh start)
1847                    history: {},
1848                    reset_info: {
1849                        count: 0i64,
1850                        last_reset_ns: AnyIntProperty,
1851                    },
1852                }
1853            }
1854        });
1855
1856        // 6. RECORD NEW DATA AFTER REBOOT
1857        recorder.record(SwitchState::ON);
1858        recorder.record(SwitchState::OFF);
1859        assert_data_tree!(inspector, root: {
1860            power_observability_state_recorders: {
1861                reboot_test: {
1862                    metadata: {
1863                        name: "reboot_test",
1864                        type: "enum",
1865                        states: {
1866                            "OFF": 0u64,
1867                            "ON": 1u64,
1868                        }
1869                    },
1870                    // DATA FROM FILE IS HERE (Read Only / Static)
1871                    previous_boot_history: {
1872                        "0": {
1873                            "@time": 1000i64,
1874                            "value": "ON",
1875                        },
1876                        "1": {
1877                            "@time": 2000i64,
1878                            "value": "OFF",
1879                        },
1880                    },
1881                    // ACTIVE HISTORY IS NOW POPULATED WITH NEW DATA
1882                    history: {
1883                        "0": {
1884                            "@time": AnyIntProperty, // New timestamp
1885                            "value": "ON",
1886                        },
1887                        "1": {
1888                            "@time": AnyIntProperty, // New timestamp
1889                            "value": "OFF",
1890                        },
1891                    },
1892                    reset_info: {
1893                        count: 0i64,
1894                        last_reset_ns: AnyIntProperty,
1895                    },
1896                }
1897            }
1898        });
1899    }
1900
1901    #[test_case(false; "eager")]
1902    #[test_case(true; "lazy")]
1903    #[fuchsia::test]
1904    async fn test_named_u64_recorder(lazy_record: bool) {
1905        use std::fs;
1906        use tempfile::tempdir;
1907
1908        // Setup isolated persistence environment
1909        let dir = tempdir().unwrap();
1910        let storage_path = dir.path().join("data");
1911        let volatile_path = dir.path().join("tmp");
1912        fs::create_dir(&storage_path).unwrap();
1913        fs::create_dir(&volatile_path).unwrap();
1914
1915        let inspector = Inspector::default();
1916        let manager = StateRecorderManager::new(&inspector);
1917
1918        let mut map = HashMap::new();
1919        map.insert(100, "Hundred".to_string());
1920        map.insert(200, "TwoHundred".to_string());
1921
1922        let persistence_opts = if lazy_record {
1923            Some(
1924                PersistenceOptions::new("my_u64_metrics_p".to_string())
1925                    .storage_dir(storage_path.to_str().unwrap())
1926                    .volatile_dir(volatile_path.to_str().unwrap()),
1927            )
1928        } else {
1929            None
1930        };
1931
1932        // 1. Start Recorder and Record Data
1933        let mut recorder = NamedU64StateRecorder::new(
1934            "my_u64_metrics_p".into(),
1935            c"power_test",
1936            map.clone(),
1937            RecorderOptions {
1938                lazy_record,
1939                capacity: 10,
1940                manager: Some(manager.clone()),
1941                persistence: persistence_opts.clone(),
1942            },
1943        )
1944        .unwrap();
1945
1946        recorder.record(100);
1947        recorder.record(200);
1948        recorder.record(300); // Unknown
1949
1950        // 2. Verify Persistence (Lazy mode only)
1951        if lazy_record {
1952            drop(recorder); // Drop to ensure flush and release name
1953
1954            let curr_csv = storage_path.join("my_u64_metrics_p.csv");
1955            let content = fs::read_to_string(&curr_csv).unwrap();
1956            let lines: Vec<&str> = content.trim().lines().collect();
1957            assert_eq!(lines.len(), 3, "Expected 3 lines of recorded history");
1958
1959            // First record: 100
1960            let parts0: Vec<&str> = lines[0].split(',').collect();
1961            assert_eq!(parts0.len(), 2, "Invalid CSV format in line 1");
1962            assert_eq!(parts0[1], "100", "First record should be 100");
1963
1964            // Second record: 200
1965            let parts1: Vec<&str> = lines[1].split(',').collect();
1966            assert_eq!(parts1.len(), 2, "Invalid CSV format in line 2");
1967            assert_eq!(parts1[1], "200", "Second record should be 200");
1968
1969            // Third record: 300
1970            let parts2: Vec<&str> = lines[2].split(',').collect();
1971            assert_eq!(parts2.len(), 2, "Invalid CSV format in line 3");
1972            assert_eq!(parts2[1], "300", "Third record should be 300");
1973
1974            // 3. Restart Recorder (Simulate Reboot)
1975            let mut _recorder_restarted = NamedU64StateRecorder::new(
1976                "my_u64_metrics_p".into(),
1977                c"power_test",
1978                map,
1979                RecorderOptions {
1980                    lazy_record,
1981                    capacity: 10,
1982                    manager: Some(manager),
1983                    persistence: persistence_opts,
1984                },
1985            )
1986            .unwrap();
1987
1988            assert_data_tree!(inspector, root: {
1989                power_observability_state_recorders: {
1990                    my_u64_metrics_p: {
1991                        metadata: {
1992                            name: "my_u64_metrics_p",
1993                            type: "enum",
1994                            states: {
1995                                "Hundred": 100u64,
1996                                "TwoHundred": 200u64,
1997                            }
1998                        },
1999                        previous_boot_history: {
2000                            "0": {
2001                                "@time": AnyIntProperty,
2002                                "value": "Hundred",
2003                            },
2004                            "1": {
2005                                "@time": AnyIntProperty,
2006                                "value": "TwoHundred",
2007                            },
2008                             "2": {
2009                                "@time": AnyIntProperty,
2010                                "value": "<Unknown>",
2011                            },
2012                        },
2013                        history: {},
2014                        reset_info: {
2015                            count: 0,
2016                            last_reset_ns: AnyIntProperty,
2017                        }
2018                    }
2019                }
2020            });
2021        } else {
2022            // Eager mode
2023            // Recorder IS ALIVE here, so node exists.
2024            assert_data_tree!(inspector, root: {
2025                power_observability_state_recorders: {
2026                    my_u64_metrics_p: {
2027                        metadata: {
2028                            name: "my_u64_metrics_p",
2029                            type: "enum",
2030                            states: {
2031                                "Hundred": 100u64,
2032                                "TwoHundred": 200u64,
2033                            }
2034                        },
2035                        history: {
2036                            "0": {
2037                                "@time": AnyIntProperty,
2038                                "value": "Hundred",
2039                            },
2040                            "1": {
2041                                "@time": AnyIntProperty,
2042                                "value": "TwoHundred",
2043                            },
2044                             "2": {
2045                                "@time": AnyIntProperty,
2046                                "value": "<Unknown>",
2047                            },
2048                        },
2049                        reset_info: {
2050                            count: 0,
2051                            last_reset_ns: AnyIntProperty,
2052                        }
2053                    }
2054                }
2055            });
2056        }
2057    }
2058
2059    #[test_case(true; "lazy")]
2060    #[fuchsia::test]
2061    async fn test_numeric_persistence_reboot(lazy_record: bool) {
2062        use std::fs;
2063        use tempfile::tempdir;
2064
2065        // 1. Setup isolated persistence environment
2066        let dir = tempdir().unwrap();
2067        let storage_path = dir.path().join("data");
2068        let volatile_path = dir.path().join("tmp");
2069        fs::create_dir(&storage_path).unwrap();
2070        fs::create_dir(&volatile_path).unwrap();
2071
2072        let inspector = Inspector::default();
2073        let manager = StateRecorderManager::new(&inspector);
2074
2075        let create_options = |manager_ref| RecorderOptions {
2076            lazy_record,
2077            capacity: 10,
2078            manager: Some(manager_ref),
2079            persistence: Some(
2080                PersistenceOptions::new("num_reboot_test".to_string())
2081                    .storage_dir(storage_path.to_str().unwrap())
2082                    .volatile_dir(volatile_path.to_str().unwrap()),
2083            ),
2084        };
2085
2086        // 2. SIMULATE FRESH REBOOT STATE
2087        // - "Current" file exists (saved from previous run).
2088        // - "Previous" file in volatile is MISSING.
2089        let curr_csv = storage_path.join("num_reboot_test.csv");
2090        // Write raw CSV data: time,value
2091        fs::write(&curr_csv, "1000,42\n2000,100\n").unwrap();
2092
2093        let prev_csv = volatile_path.join("num_reboot_test.csv");
2094        assert!(!prev_csv.exists());
2095
2096        // 3. START RECORDER
2097        let mut _recorder = NumericStateRecorder::new(
2098            "num_reboot_test".into(),
2099            c"power_test",
2100            units!(Number),
2101            Some((0u64, 200u64)),
2102            create_options(manager),
2103        )
2104        .unwrap();
2105
2106        // 4. VERIFY FILESYSTEM (Rotation)
2107        // The file should have been moved from 'data' to 'tmp'.
2108        assert!(prev_csv.exists(), "Library should have rotated curr -> prev");
2109        assert!(curr_csv.exists(), "Library should have create a new current file");
2110
2111        let rotated_content = fs::read_to_string(&prev_csv).unwrap();
2112        assert_eq!(rotated_content, "1000,42\n2000,100\n");
2113
2114        // 5. ASSERTIONS (Inspect)
2115        assert_data_tree!(inspector, root: {
2116            power_observability_state_recorders: {
2117                num_reboot_test: {
2118                    metadata: {
2119                        name: "num_reboot_test",
2120                        type: "numeric",
2121                        units: "#",
2122                        range: {
2123                            min_inc: 0u64,
2124                            max_inc: 200u64,
2125                        }
2126                    },
2127                    previous_boot_history: {
2128                        "0": {
2129                            "@time": 1000i64,
2130                            "value": 42u64,
2131                        },
2132                        "1": {
2133                            "@time": 2000i64,
2134                            "value": 100u64,
2135                        },
2136                    },
2137                    history: {},
2138                    reset_info: {
2139                        count: 0i64,
2140                        last_reset_ns: AnyIntProperty,
2141                    }
2142                }
2143            }
2144        });
2145    }
2146}