Skip to main content

proef_core/
world.rs

1//! Typed variable scope and persistent global store (ADR-0005).
2//!
3//! The [`World`] is the interop bus between batches and engines: one typed scenario
4//! scope plus the persistent [`GlobalStore`] (`.proef-state.json`). Values are typed
5//! (mirroring hurl's `Value` subset) because captures cross engine boundaries —
6//! stringly-typed round-trips would lose numbers and booleans.
7//!
8//! Persistence note (core purity): [`GlobalStore::load`] / [`GlobalStore::save`] are
9//! the crate's single sanctioned IO edge, invoked only by the orchestrating CLI —
10//! never from pipeline code. Writes are atomic (sibling temp file + rename) and the
11//! state file is created `0600` (TECH-SPEC §11).
12
13use std::collections::BTreeMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::CoreError;
20
21/// A typed variable value (mirrors the hurl `Value` subset that crosses the seam).
22///
23/// Serialized as the natural JSON scalar. Deserialization is a hand-written
24/// visitor rather than `#[serde(untagged)]`: hurl enables
25/// `serde_json/arbitrary_precision` by default, cargo feature-unifies that into
26/// every workspace build, and under it numbers reach untagged enums as `serde_json`'s
27/// private number token (which no plain `i64`/`f64` variant matches). The visitor
28/// accepts both encodings — pinned by `value_json_forms_round_trip`.
29#[derive(Debug, Clone, PartialEq, Serialize)]
30#[serde(untagged)]
31pub enum Value {
32    /// Absent/null.
33    Null,
34    /// Boolean.
35    Bool(bool),
36    /// Integer number.
37    Int(i64),
38    /// Floating-point number.
39    Float(f64),
40    /// String.
41    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                // Beyond i64 range: keep totality, deliberately degrade to float.
74                #[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            // The `arbitrary_precision` path: numbers arrive as a single-entry
91            // magic map that only `serde_json::Number` knows how to decode.
92            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/// The persistent global variable store (`.proef-state.json`, ADR-0005).
127///
128/// `saveAs: global` promotes captures here; values survive across scenarios
129/// and runs.
130#[derive(Debug, Clone, Default, PartialEq)]
131pub struct GlobalStore {
132    values: BTreeMap<String, Value>,
133}
134
135impl GlobalStore {
136    /// An empty store.
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Read a value.
142    pub fn get(&self, name: &str) -> Option<&Value> {
143        self.values.get(name)
144    }
145
146    /// Insert or replace a value.
147    pub fn insert(&mut self, name: impl Into<String>, value: Value) {
148        self.values.insert(name.into(), value);
149    }
150
151    /// Iterate entries in key order.
152    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
153        self.values.iter().map(|(k, v)| (k.as_str(), v))
154    }
155
156    /// Load the store from `path`. A missing file yields an empty store; any other
157    /// failure is a [`CoreError::System`].
158    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    /// Persist the store to `path` atomically: write a sibling temp file
181    /// (*created* mode `0600` on unix — private from the first byte, no
182    /// world-readable window), then rename over the target.
183    pub fn save(&self, path: &Path) -> Result<(), CoreError> {
184        let json = serde_json::to_string_pretty(&self.values)
185            .map_err(|err| CoreError::system_with("cannot serialize global state", err))?;
186        let tmp = sibling_tmp_path(path);
187        #[cfg(unix)]
188        {
189            use std::io::Write as _;
190            use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
191            let mut file = fs::OpenOptions::new()
192                .write(true)
193                .create(true)
194                .truncate(true)
195                .mode(0o600)
196                .open(&tmp)
197                .map_err(|err| {
198                    CoreError::system_with(
199                        format!("cannot write global state temp file {}", tmp.display()),
200                        err,
201                    )
202                })?;
203            file.write_all(json.as_bytes()).map_err(|err| {
204                CoreError::system_with(
205                    format!("cannot write global state temp file {}", tmp.display()),
206                    err,
207                )
208            })?;
209            // `mode` applies only on create — re-assert for a leftover tmp
210            // file that predates this write.
211            fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).map_err(|err| {
212                CoreError::system_with(format!("cannot set permissions on {}", tmp.display()), err)
213            })?;
214        }
215        #[cfg(not(unix))]
216        fs::write(&tmp, json).map_err(|err| {
217            CoreError::system_with(
218                format!("cannot write global state temp file {}", tmp.display()),
219                err,
220            )
221        })?;
222        fs::rename(&tmp, path).map_err(|err| {
223            CoreError::system_with(
224                format!("cannot move global state into place at {}", path.display()),
225                err,
226            )
227        })
228    }
229}
230
231/// `<path>.tmp` next to the target, so the final rename stays on one filesystem.
232fn sibling_tmp_path(path: &Path) -> PathBuf {
233    let mut os = path.as_os_str().to_owned();
234    // Process-unique: concurrent proef processes (the harness) must not
235    // truncate each other's in-flight temp file.
236    os.push(format!(".{}.tmp", std::process::id()));
237    PathBuf::from(os)
238}
239
240/// One typed variable scope per scenario, layered over the persistent global store.
241///
242/// Reads resolve scenario-first (scenario values shadow globals); `saveAs: global`
243/// writes through to the store.
244#[derive(Debug, Default)]
245pub struct World {
246    scenario: BTreeMap<String, Value>,
247    global: GlobalStore,
248    /// Keys written through [`World::set_global`] during this scenario — the
249    /// *write set* merged back into the shared store. Merging the whole store
250    /// would write the stale snapshot over concurrent scenarios' promotions.
251    promoted: std::collections::BTreeSet<String>,
252}
253
254impl World {
255    /// A world over the given global store, with an empty scenario scope.
256    pub fn new(global: GlobalStore) -> Self {
257        Self {
258            scenario: BTreeMap::new(),
259            global,
260            promoted: std::collections::BTreeSet::new(),
261        }
262    }
263
264    /// Read a variable: the scenario scope shadows the global store.
265    pub fn get(&self, name: &str) -> Option<&Value> {
266        self.scenario.get(name).or_else(|| self.global.get(name))
267    }
268
269    /// Set a variable in the scenario scope.
270    pub fn set(&mut self, name: impl Into<String>, value: Value) {
271        self.scenario.insert(name.into(), value);
272    }
273
274    /// Promote a value into the persistent global store (`saveAs: global`).
275    /// The key joins the scenario's write set (see [`World::promotions`]).
276    pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
277        let name = name.into();
278        self.promoted.insert(name.clone());
279        self.global.insert(name, value);
280    }
281
282    /// The underlying global store.
283    pub fn global(&self) -> &GlobalStore {
284        &self.global
285    }
286
287    /// The scenario's promotions — only the keys actually written via
288    /// [`World::set_global`], with their current values. This is what merges
289    /// back into the shared store; the untouched snapshot remainder must not.
290    pub fn promotions(&self) -> impl Iterator<Item = (&str, &Value)> {
291        self.promoted
292            .iter()
293            .filter_map(|key| self.global.get(key).map(|value| (key.as_str(), value)))
294    }
295
296    /// The merged view (globals overlaid by scenario values), in key order —
297    /// the seed set for an engine's variable bridge.
298    pub fn merged(&self) -> BTreeMap<&str, &Value> {
299        let mut merged: BTreeMap<&str, &Value> = self.global.iter().collect();
300        for (k, v) in &self.scenario {
301            merged.insert(k.as_str(), v);
302        }
303        merged
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    #![allow(clippy::unwrap_used)]
310
311    use super::*;
312
313    #[test]
314    fn scenario_scope_shadows_global() {
315        let mut store = GlobalStore::new();
316        store.insert("token", Value::String("global".into()));
317        let mut world = World::new(store);
318        assert_eq!(world.get("token"), Some(&Value::String("global".into())));
319
320        world.set("token", Value::String("scenario".into()));
321        assert_eq!(world.get("token"), Some(&Value::String("scenario".into())));
322    }
323
324    #[test]
325    fn merged_view_prefers_scenario_values() {
326        let mut store = GlobalStore::new();
327        store.insert("a", Value::Int(1));
328        store.insert("b", Value::Int(2));
329        let mut world = World::new(store);
330        world.set("b", Value::Int(20));
331        let merged = world.merged();
332        assert_eq!(merged["a"], &Value::Int(1));
333        assert_eq!(merged["b"], &Value::Int(20));
334    }
335
336    #[test]
337    fn promotions_are_the_write_set_only() {
338        let mut store = GlobalStore::new();
339        store.insert("seed", Value::Int(1));
340        let mut world = World::new(store);
341        world.set("scenario-only", Value::Bool(true));
342        world.set_global("promoted", Value::Int(2));
343
344        let promotions: Vec<_> = world.promotions().collect();
345        assert_eq!(promotions, vec![("promoted", &Value::Int(2))]);
346    }
347
348    #[test]
349    fn value_json_forms_round_trip() {
350        let cases = [
351            ("null", Value::Null),
352            ("true", Value::Bool(true)),
353            ("3", Value::Int(3)),
354            ("0.5", Value::Float(0.5)),
355            (r#""c-42""#, Value::String("c-42".into())),
356        ];
357        for (json, expected) in cases {
358            let parsed: Value = serde_json::from_str(json)
359                .unwrap_or_else(|err| panic!("cannot parse {json}: {err}"));
360            assert_eq!(parsed, expected, "for literal {json}");
361        }
362    }
363
364    #[test]
365    fn store_save_load_round_trips_atomically() {
366        let dir = tempfile::tempdir().unwrap();
367        let path = dir.path().join(".proef-state.json");
368
369        let mut store = GlobalStore::new();
370        store.insert("clientId", Value::String("c-42".into()));
371        store.insert("count", Value::Int(3));
372        store.insert("ratio", Value::Float(0.5));
373        store.save(&path).unwrap();
374
375        // No temp residue after a successful save.
376        assert!(!sibling_tmp_path(&path).exists());
377
378        let loaded = GlobalStore::load(&path).unwrap();
379        assert_eq!(loaded, store);
380    }
381
382    #[test]
383    fn missing_state_file_loads_as_empty() {
384        let dir = tempfile::tempdir().unwrap();
385        let loaded = GlobalStore::load(&dir.path().join("absent.json")).unwrap();
386        assert_eq!(loaded, GlobalStore::new());
387    }
388
389    #[cfg(unix)]
390    #[test]
391    fn state_file_is_created_private() {
392        use std::os::unix::fs::PermissionsExt;
393        let dir = tempfile::tempdir().unwrap();
394        let path = dir.path().join(".proef-state.json");
395        GlobalStore::new().save(&path).unwrap();
396        let mode = fs::metadata(&path).unwrap().permissions().mode();
397        assert_eq!(mode & 0o777, 0o600);
398    }
399
400    #[test]
401    fn corrupt_state_file_is_a_system_fault() {
402        let dir = tempfile::tempdir().unwrap();
403        let path = dir.path().join(".proef-state.json");
404        fs::write(&path, "not json").unwrap();
405        let err = GlobalStore::load(&path).unwrap_err();
406        assert_eq!(err.exit_code().code(), 3);
407    }
408}