Skip to main content

nros_params/
persist.rs

1//! Phase 172.H — runtime parameter-override persistence.
2//!
3//! A [`ParamStore`] persists parameter values set at runtime (via the
4//! `set_parameters` service) so they survive a restart. At boot the generated
5//! runtime declares the plan's compile-time defaults, then [`ParamStore::load`]
6//! overlays any persisted overrides; after a successful runtime set the
7//! executor flushes the full parameter set back via [`ParamStore::save`].
8//!
9//! [`NullParamStore`] is the no-op default (no persistence). [`FileParamStore`]
10//! (`std` only) persists scalars to a text file — the hosted backend. Flash /
11//! NVS backends for embedded targets are future work.
12
13use crate::types::ParameterValue;
14
15/// Error from a [`ParamStore`] backend.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ParamStoreError {
18    /// The backend (filesystem, flash, …) reported a failure.
19    Backend,
20    /// Persisted data was malformed.
21    Format,
22}
23
24/// Backend that persists runtime parameter overrides across restarts (172.H).
25///
26/// Object-safe so the executor can hold a `Box<dyn ParamStore>`.
27pub trait ParamStore {
28    /// Apply each persisted `(name, value)` via `apply`. Called once at boot
29    /// after defaults are declared, so persisted values override them.
30    fn load(&self, apply: &mut dyn FnMut(&str, ParameterValue));
31
32    /// Persist the full current parameter set. `params` yields `(name, value)`
33    /// for every declared parameter; called after a runtime set changes a
34    /// value. Non-scalar values (arrays, `NotSet`) are backend-defined and may
35    /// be skipped.
36    fn save(
37        &mut self,
38        params: &mut dyn Iterator<Item = (&str, &ParameterValue)>,
39    ) -> Result<(), ParamStoreError>;
40}
41
42/// No-op store: the default when persistence is disabled.
43#[derive(Debug, Default, Clone, Copy)]
44pub struct NullParamStore;
45
46impl ParamStore for NullParamStore {
47    fn load(&self, _apply: &mut dyn FnMut(&str, ParameterValue)) {}
48
49    fn save(
50        &mut self,
51        _params: &mut dyn Iterator<Item = (&str, &ParameterValue)>,
52    ) -> Result<(), ParamStoreError> {
53        Ok(())
54    }
55}
56
57#[cfg(feature = "std")]
58mod file {
59    extern crate std;
60
61    use std::{format, fs, io::Write, path::PathBuf, string::String as StdString};
62
63    use super::{ParamStore, ParamStoreError};
64    use crate::types::ParameterValue;
65
66    /// Hosted [`ParamStore`] persisting scalar parameter overrides to a text
67    /// file: one `name<TAB>kind<TAB>value` line per scalar parameter (kind ∈
68    /// `b`/`i`/`d`/`s`). Arrays and `NotSet` are not persisted; names or values
69    /// containing a tab or newline are skipped (they would corrupt the line
70    /// format). Writes are atomic (temp file + rename).
71    #[derive(Debug, Clone)]
72    pub struct FileParamStore {
73        path: PathBuf,
74    }
75
76    impl FileParamStore {
77        /// Persist to / restore from `path`.
78        pub fn new(path: impl Into<PathBuf>) -> Self {
79            Self { path: path.into() }
80        }
81    }
82
83    impl ParamStore for FileParamStore {
84        fn load(&self, apply: &mut dyn FnMut(&str, ParameterValue)) {
85            let Ok(raw) = fs::read_to_string(&self.path) else {
86                return; // absent / unreadable ⇒ no overrides
87            };
88            for line in raw.lines() {
89                let line = line.trim_end_matches(['\r', '\n']);
90                if line.is_empty() {
91                    continue;
92                }
93                let mut parts = line.splitn(3, '\t');
94                let (Some(name), Some(kind), Some(val)) =
95                    (parts.next(), parts.next(), parts.next())
96                else {
97                    continue;
98                };
99                let value = match kind {
100                    "b" => match val {
101                        "true" => ParameterValue::Bool(true),
102                        "false" => ParameterValue::Bool(false),
103                        _ => continue,
104                    },
105                    "i" => match val.parse::<i64>() {
106                        Ok(v) => ParameterValue::Integer(v),
107                        Err(_) => continue,
108                    },
109                    "d" => match val.parse::<f64>() {
110                        Ok(v) => ParameterValue::Double(v),
111                        Err(_) => continue,
112                    },
113                    "s" => match ParameterValue::from_string(val) {
114                        Some(v) => v,
115                        None => continue,
116                    },
117                    _ => continue,
118                };
119                apply(name, value);
120            }
121        }
122
123        fn save(
124            &mut self,
125            params: &mut dyn Iterator<Item = (&str, &ParameterValue)>,
126        ) -> Result<(), ParamStoreError> {
127            let mut out = StdString::new();
128            for (name, value) in params {
129                let (kind, rendered): (&str, StdString) = match value {
130                    ParameterValue::Bool(b) => {
131                        ("b", if *b { "true".into() } else { "false".into() })
132                    }
133                    ParameterValue::Integer(i) => ("i", format!("{i}")),
134                    ParameterValue::Double(d) => ("d", format!("{d}")),
135                    ParameterValue::String(s) => ("s", s.as_str().into()),
136                    _ => continue, // arrays / NotSet not persisted in v1
137                };
138                if name.contains(['\t', '\n']) || rendered.contains(['\t', '\n']) {
139                    continue; // delimiters in data would corrupt the line format
140                }
141                out.push_str(name);
142                out.push('\t');
143                out.push_str(kind);
144                out.push('\t');
145                out.push_str(&rendered);
146                out.push('\n');
147            }
148
149            // Atomic write: temp file + rename, so a crash mid-write never
150            // leaves a half-written store on disk.
151            let tmp = self.path.with_extension("tmp");
152            let mut file = fs::File::create(&tmp).map_err(|_| ParamStoreError::Backend)?;
153            file.write_all(out.as_bytes())
154                .map_err(|_| ParamStoreError::Backend)?;
155            file.sync_all().ok();
156            fs::rename(&tmp, &self.path).map_err(|_| ParamStoreError::Backend)?;
157            Ok(())
158        }
159    }
160}
161
162#[cfg(feature = "std")]
163pub use file::FileParamStore;
164
165#[cfg(all(test, feature = "std"))]
166mod tests {
167    extern crate std;
168
169    use std::{fs, path::PathBuf, vec::Vec};
170
171    use super::*;
172    use crate::types::ParameterValue;
173
174    fn temp_path(tag: &str) -> PathBuf {
175        let unique = std::time::SystemTime::now()
176            .duration_since(std::time::UNIX_EPOCH)
177            .unwrap()
178            .as_nanos();
179        std::env::temp_dir().join(std::format!("nros_param_{tag}_{unique}.store"))
180    }
181
182    #[test]
183    fn null_store_is_noop() {
184        let mut store = NullParamStore;
185        let bool_v = ParameterValue::Bool(true);
186        let mut iter = [("a", &bool_v)].into_iter();
187        assert_eq!(store.save(&mut iter), Ok(()));
188        let mut applied = 0;
189        store.load(&mut |_, _| applied += 1);
190        assert_eq!(applied, 0);
191    }
192
193    #[test]
194    #[cfg_attr(
195        miri,
196        ignore = "real filesystem I/O + clock; not meaningful under miri isolation"
197    )]
198    fn file_store_round_trips_scalars() {
199        let path = temp_path("roundtrip");
200        let mut store = FileParamStore::new(&path);
201
202        let b = ParameterValue::Bool(true);
203        let i = ParameterValue::Integer(-42);
204        let d = ParameterValue::Double(3.5);
205        let s = ParameterValue::from_string("hello world").unwrap();
206        let mut iter = [("flag", &b), ("count", &i), ("gain", &d), ("label", &s)].into_iter();
207        store.save(&mut iter).unwrap();
208
209        // A fresh store reading the same file recovers every scalar.
210        let reader = FileParamStore::new(&path);
211        let mut got: Vec<(std::string::String, ParameterValue)> = Vec::new();
212        reader.load(&mut |name, value| got.push((name.into(), value)));
213
214        assert_eq!(got.len(), 4);
215        let find = |n: &str| got.iter().find(|(k, _)| k == n).map(|(_, v)| v).unwrap();
216        assert_eq!(find("flag").as_bool(), Some(true));
217        assert_eq!(find("count").as_integer(), Some(-42));
218        assert_eq!(find("gain").as_double(), Some(3.5));
219        assert_eq!(find("label").as_string(), Some("hello world"));
220
221        fs::remove_file(&path).ok();
222    }
223
224    #[test]
225    #[cfg_attr(
226        miri,
227        ignore = "real filesystem I/O + clock; not meaningful under miri isolation"
228    )]
229    fn file_store_load_absent_is_empty() {
230        let reader = FileParamStore::new(temp_path("absent"));
231        let mut applied = 0;
232        reader.load(&mut |_, _| applied += 1);
233        assert_eq!(applied, 0);
234    }
235
236    #[test]
237    #[cfg_attr(
238        miri,
239        ignore = "real filesystem I/O + clock; not meaningful under miri isolation"
240    )]
241    fn boot_overlay_then_flush_restores_runtime_override() {
242        // The full 172.H loop the executor orchestrates, exercised on real
243        // types: boot 1 declares defaults, a runtime set + flush persists the
244        // override; boot 2 re-declares defaults then loads — the override wins,
245        // untouched defaults stay.
246        use crate::server::ParameterServer;
247
248        let path = temp_path("e2e");
249
250        // Boot 1: defaults → runtime override → flush (mirrors flush_param_store).
251        let mut server = ParameterServer::new();
252        server.declare("gain", ParameterValue::Double(1.0));
253        server.declare("mode", ParameterValue::Integer(0));
254        assert!(!server.take_dirty(), "declares are not runtime changes");
255        server.set("gain", ParameterValue::Double(2.5));
256        assert!(server.take_dirty());
257        let mut store = FileParamStore::new(&path);
258        store
259            .save(&mut server.iter().map(|p| (p.name(), &p.value)))
260            .unwrap();
261
262        // Boot 2: fresh server, defaults, then overlay persisted overrides
263        // (mirrors enable_parameter_persistence).
264        let mut booted = ParameterServer::new();
265        booted.declare("gain", ParameterValue::Double(1.0));
266        booted.declare("mode", ParameterValue::Integer(0));
267        FileParamStore::new(&path).load(&mut |name, value| {
268            let _ = booted.set(name, value);
269        });
270
271        assert_eq!(booted.get_double("gain"), Some(2.5), "override restored");
272        assert_eq!(booted.get_integer("mode"), Some(0), "default preserved");
273
274        fs::remove_file(&path).ok();
275    }
276
277    #[test]
278    #[cfg_attr(
279        miri,
280        ignore = "real filesystem I/O + clock; not meaningful under miri isolation"
281    )]
282    fn file_store_skips_non_scalar_and_delimiter_corruption() {
283        let path = temp_path("skip");
284        let mut store = FileParamStore::new(&path);
285
286        let arr = ParameterValue::IntegerArray(Default::default());
287        let unset = ParameterValue::NotSet;
288        let tabbed = ParameterValue::from_string("a\tb").unwrap();
289        let ok = ParameterValue::Integer(7);
290        let mut iter = [
291            ("arr", &arr),
292            ("none", &unset),
293            ("bad", &tabbed),
294            ("good", &ok),
295        ]
296        .into_iter();
297        store.save(&mut iter).unwrap();
298
299        let mut got = Vec::new();
300        FileParamStore::new(&path)
301            .load(&mut |name, value| got.push((std::string::String::from(name), value)));
302        // Only the clean scalar survives.
303        assert_eq!(got.len(), 1);
304        assert_eq!(got[0].0, "good");
305        assert_eq!(got[0].1.as_integer(), Some(7));
306
307        fs::remove_file(&path).ok();
308    }
309}