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 (mode
181    /// `0600` on unix), then rename over the target.
182    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
208/// `<path>.tmp` next to the target, so the final rename stays on one filesystem.
209fn sibling_tmp_path(path: &Path) -> PathBuf {
210    let mut os = path.as_os_str().to_owned();
211    // Process-unique: concurrent proef processes (the harness) must not
212    // truncate each other's in-flight temp file.
213    os.push(format!(".{}.tmp", std::process::id()));
214    PathBuf::from(os)
215}
216
217/// One typed variable scope per scenario, layered over the persistent global store.
218///
219/// Reads resolve scenario-first (scenario values shadow globals); `saveAs: global`
220/// writes through to the store.
221#[derive(Debug, Default)]
222pub struct World {
223    scenario: BTreeMap<String, Value>,
224    global: GlobalStore,
225    /// Keys written through [`World::set_global`] during this scenario — the
226    /// *write set* merged back into the shared store. Merging the whole store
227    /// would write the stale snapshot over concurrent scenarios' promotions.
228    promoted: std::collections::BTreeSet<String>,
229}
230
231impl World {
232    /// A world over the given global store, with an empty scenario scope.
233    pub fn new(global: GlobalStore) -> Self {
234        Self {
235            scenario: BTreeMap::new(),
236            global,
237            promoted: std::collections::BTreeSet::new(),
238        }
239    }
240
241    /// Read a variable: the scenario scope shadows the global store.
242    pub fn get(&self, name: &str) -> Option<&Value> {
243        self.scenario.get(name).or_else(|| self.global.get(name))
244    }
245
246    /// Set a variable in the scenario scope.
247    pub fn set(&mut self, name: impl Into<String>, value: Value) {
248        self.scenario.insert(name.into(), value);
249    }
250
251    /// Promote a value into the persistent global store (`saveAs: global`).
252    /// The key joins the scenario's write set (see [`World::promotions`]).
253    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    /// The underlying global store.
260    pub fn global(&self) -> &GlobalStore {
261        &self.global
262    }
263
264    /// The scenario's promotions — only the keys actually written via
265    /// [`World::set_global`], with their current values. This is what merges
266    /// back into the shared store; the untouched snapshot remainder must not.
267    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    /// The merged view (globals overlaid by scenario values), in key order —
274    /// the seed set for an engine's variable bridge.
275    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        // No temp residue after a successful save.
353        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}