1use crate::types::ParameterValue;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ParamStoreError {
18 Backend,
20 Format,
22}
23
24pub trait ParamStore {
28 fn load(&self, apply: &mut dyn FnMut(&str, ParameterValue));
31
32 fn save(
37 &mut self,
38 params: &mut dyn Iterator<Item = (&str, &ParameterValue)>,
39 ) -> Result<(), ParamStoreError>;
40}
41
42#[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 #[derive(Debug, Clone)]
72 pub struct FileParamStore {
73 path: PathBuf,
74 }
75
76 impl FileParamStore {
77 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; };
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, };
138 if name.contains(['\t', '\n']) || rendered.contains(['\t', '\n']) {
139 continue; }
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 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 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 use crate::server::ParameterServer;
247
248 let path = temp_path("e2e");
249
250 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 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 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}