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