1use std::collections::BTreeMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::CoreError;
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
30#[serde(untagged)]
31pub enum Value {
32 Null,
34 Bool(bool),
36 Int(i64),
38 Float(f64),
40 String(String),
42}
43
44impl<'de> Deserialize<'de> for Value {
45 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
46 where
47 D: serde::Deserializer<'de>,
48 {
49 use serde::de::{self, MapAccess, Visitor};
50
51 struct ValueVisitor;
52
53 impl<'de> Visitor<'de> for ValueVisitor {
54 type Value = Value;
55
56 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str("a scalar (null, bool, number, or string)")
58 }
59
60 fn visit_unit<E>(self) -> Result<Value, E> {
61 Ok(Value::Null)
62 }
63
64 fn visit_bool<E>(self, b: bool) -> Result<Value, E> {
65 Ok(Value::Bool(b))
66 }
67
68 fn visit_i64<E>(self, i: i64) -> Result<Value, E> {
69 Ok(Value::Int(i))
70 }
71
72 fn visit_u64<E>(self, u: u64) -> Result<Value, E> {
73 #[allow(clippy::cast_precision_loss)]
75 Ok(i64::try_from(u).map_or(Value::Float(u as f64), Value::Int))
76 }
77
78 fn visit_f64<E>(self, f: f64) -> Result<Value, E> {
79 Ok(Value::Float(f))
80 }
81
82 fn visit_str<E>(self, s: &str) -> Result<Value, E> {
83 Ok(Value::String(s.to_owned()))
84 }
85
86 fn visit_string<E>(self, s: String) -> Result<Value, E> {
87 Ok(Value::String(s))
88 }
89
90 fn visit_map<A>(self, map: A) -> Result<Value, A::Error>
93 where
94 A: MapAccess<'de>,
95 {
96 let number =
97 serde_json::Number::deserialize(de::value::MapAccessDeserializer::new(map))?;
98 if let Some(i) = number.as_i64() {
99 Ok(Value::Int(i))
100 } else if let Some(f) = number.as_f64() {
101 Ok(Value::Float(f))
102 } else {
103 Err(de::Error::custom(format!(
104 "unrepresentable number {number}"
105 )))
106 }
107 }
108 }
109
110 deserializer.deserialize_any(ValueVisitor)
111 }
112}
113
114impl std::fmt::Display for Value {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Self::Null => f.write_str(""),
118 Self::Bool(b) => write!(f, "{b}"),
119 Self::Int(i) => write!(f, "{i}"),
120 Self::Float(x) => write!(f, "{x}"),
121 Self::String(s) => f.write_str(s),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default, PartialEq)]
131pub struct GlobalStore {
132 values: BTreeMap<String, Value>,
133}
134
135impl GlobalStore {
136 pub fn new() -> Self {
138 Self::default()
139 }
140
141 pub fn get(&self, name: &str) -> Option<&Value> {
143 self.values.get(name)
144 }
145
146 pub fn insert(&mut self, name: impl Into<String>, value: Value) {
148 self.values.insert(name.into(), value);
149 }
150
151 pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
153 self.values.iter().map(|(k, v)| (k.as_str(), v))
154 }
155
156 pub fn load(path: &Path) -> Result<Self, CoreError> {
159 let text = match fs::read_to_string(path) {
160 Ok(text) => text,
161 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
162 return Ok(Self::new());
163 }
164 Err(err) => {
165 return Err(CoreError::system_with(
166 format!("cannot read global state file {}", path.display()),
167 err,
168 ));
169 }
170 };
171 let values: BTreeMap<String, Value> = serde_json::from_str(&text).map_err(|err| {
172 CoreError::system_with(
173 format!("global state file {} is not valid JSON", path.display()),
174 err,
175 )
176 })?;
177 Ok(Self { values })
178 }
179
180 pub fn save(&self, path: &Path) -> Result<(), CoreError> {
183 let json = serde_json::to_string_pretty(&self.values)
184 .map_err(|err| CoreError::system_with("cannot serialize global state", err))?;
185 let tmp = sibling_tmp_path(path);
186 fs::write(&tmp, json).map_err(|err| {
187 CoreError::system_with(
188 format!("cannot write global state temp file {}", tmp.display()),
189 err,
190 )
191 })?;
192 #[cfg(unix)]
193 {
194 use std::os::unix::fs::PermissionsExt;
195 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).map_err(|err| {
196 CoreError::system_with(format!("cannot set permissions on {}", tmp.display()), err)
197 })?;
198 }
199 fs::rename(&tmp, path).map_err(|err| {
200 CoreError::system_with(
201 format!("cannot move global state into place at {}", path.display()),
202 err,
203 )
204 })
205 }
206}
207
208fn sibling_tmp_path(path: &Path) -> PathBuf {
210 let mut os = path.as_os_str().to_owned();
211 os.push(format!(".{}.tmp", std::process::id()));
214 PathBuf::from(os)
215}
216
217#[derive(Debug, Default)]
222pub struct World {
223 scenario: BTreeMap<String, Value>,
224 global: GlobalStore,
225 promoted: std::collections::BTreeSet<String>,
229}
230
231impl World {
232 pub fn new(global: GlobalStore) -> Self {
234 Self {
235 scenario: BTreeMap::new(),
236 global,
237 promoted: std::collections::BTreeSet::new(),
238 }
239 }
240
241 pub fn get(&self, name: &str) -> Option<&Value> {
243 self.scenario.get(name).or_else(|| self.global.get(name))
244 }
245
246 pub fn set(&mut self, name: impl Into<String>, value: Value) {
248 self.scenario.insert(name.into(), value);
249 }
250
251 pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
254 let name = name.into();
255 self.promoted.insert(name.clone());
256 self.global.insert(name, value);
257 }
258
259 pub fn global(&self) -> &GlobalStore {
261 &self.global
262 }
263
264 pub fn promotions(&self) -> impl Iterator<Item = (&str, &Value)> {
268 self.promoted
269 .iter()
270 .filter_map(|key| self.global.get(key).map(|value| (key.as_str(), value)))
271 }
272
273 pub fn merged(&self) -> BTreeMap<&str, &Value> {
276 let mut merged: BTreeMap<&str, &Value> = self.global.iter().collect();
277 for (k, v) in &self.scenario {
278 merged.insert(k.as_str(), v);
279 }
280 merged
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 #![allow(clippy::unwrap_used)]
287
288 use super::*;
289
290 #[test]
291 fn scenario_scope_shadows_global() {
292 let mut store = GlobalStore::new();
293 store.insert("token", Value::String("global".into()));
294 let mut world = World::new(store);
295 assert_eq!(world.get("token"), Some(&Value::String("global".into())));
296
297 world.set("token", Value::String("scenario".into()));
298 assert_eq!(world.get("token"), Some(&Value::String("scenario".into())));
299 }
300
301 #[test]
302 fn merged_view_prefers_scenario_values() {
303 let mut store = GlobalStore::new();
304 store.insert("a", Value::Int(1));
305 store.insert("b", Value::Int(2));
306 let mut world = World::new(store);
307 world.set("b", Value::Int(20));
308 let merged = world.merged();
309 assert_eq!(merged["a"], &Value::Int(1));
310 assert_eq!(merged["b"], &Value::Int(20));
311 }
312
313 #[test]
314 fn promotions_are_the_write_set_only() {
315 let mut store = GlobalStore::new();
316 store.insert("seed", Value::Int(1));
317 let mut world = World::new(store);
318 world.set("scenario-only", Value::Bool(true));
319 world.set_global("promoted", Value::Int(2));
320
321 let promotions: Vec<_> = world.promotions().collect();
322 assert_eq!(promotions, vec![("promoted", &Value::Int(2))]);
323 }
324
325 #[test]
326 fn value_json_forms_round_trip() {
327 let cases = [
328 ("null", Value::Null),
329 ("true", Value::Bool(true)),
330 ("3", Value::Int(3)),
331 ("0.5", Value::Float(0.5)),
332 (r#""c-42""#, Value::String("c-42".into())),
333 ];
334 for (json, expected) in cases {
335 let parsed: Value = serde_json::from_str(json)
336 .unwrap_or_else(|err| panic!("cannot parse {json}: {err}"));
337 assert_eq!(parsed, expected, "for literal {json}");
338 }
339 }
340
341 #[test]
342 fn store_save_load_round_trips_atomically() {
343 let dir = tempfile::tempdir().unwrap();
344 let path = dir.path().join(".proef-state.json");
345
346 let mut store = GlobalStore::new();
347 store.insert("clientId", Value::String("c-42".into()));
348 store.insert("count", Value::Int(3));
349 store.insert("ratio", Value::Float(0.5));
350 store.save(&path).unwrap();
351
352 assert!(!sibling_tmp_path(&path).exists());
354
355 let loaded = GlobalStore::load(&path).unwrap();
356 assert_eq!(loaded, store);
357 }
358
359 #[test]
360 fn missing_state_file_loads_as_empty() {
361 let dir = tempfile::tempdir().unwrap();
362 let loaded = GlobalStore::load(&dir.path().join("absent.json")).unwrap();
363 assert_eq!(loaded, GlobalStore::new());
364 }
365
366 #[cfg(unix)]
367 #[test]
368 fn state_file_is_created_private() {
369 use std::os::unix::fs::PermissionsExt;
370 let dir = tempfile::tempdir().unwrap();
371 let path = dir.path().join(".proef-state.json");
372 GlobalStore::new().save(&path).unwrap();
373 let mode = fs::metadata(&path).unwrap().permissions().mode();
374 assert_eq!(mode & 0o777, 0o600);
375 }
376
377 #[test]
378 fn corrupt_state_file_is_a_system_fault() {
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join(".proef-state.json");
381 fs::write(&path, "not json").unwrap();
382 let err = GlobalStore::load(&path).unwrap_err();
383 assert_eq!(err.exit_code().code(), 3);
384 }
385}