Skip to main content

algocline_engine/
state.rs

1//! Persistent key-value state backed by JSON files.
2//!
3//! ## Architecture
4//!
5//! All state operations go through the [`StateStore`] trait, which
6//! abstracts the storage backend.  The default implementation,
7//! [`JsonFileStore`], persists each namespace as a JSON file under a
8//! caller-provided root directory with atomic writes (tmp + rename).
9//!
10//! ## Tier 1 — Current API
11//!
12//! | Operation | Description |
13//! |-----------|-------------|
14//! | `get` | Read a value (returns `None` if absent) |
15//! | `set` | Write a value (upsert) |
16//! | `delete` | Remove a key (returns whether it existed) |
17//! | `keys` | List all keys in a namespace |
18//! | `has` | Check existence (cost is backend-dependent) |
19//! | `set_nx` | Set-if-not-exists (returns `false` if key already present) |
20//! | `incr` | Counter increment — single-process atomic (read-modify-write) |
21//!
22//! ## Tier 2 — Future Extensions (design notes, not yet implemented)
23//!
24//! The following operations are planned but **not yet implemented**.
25//! The trait is designed to accommodate them without breaking changes.
26//! Review this list when adding a new backend.
27//!
28//! - **TTL**: `set(key, value, opts)` with `opts.ttl_secs`, plus
29//!   `ttl(key) -> Option<Duration>` to query remaining time.  Useful
30//!   for caching patterns (e.g. Hub index cache, LLM response cache).
31//! - **Batch**: `mget(keys) -> Vec<Option<Value>>` and
32//!   `mset(pairs) -> Result<()>`.  Reduces I/O round-trips for
33//!   file/network backends.
34//! - **clear**: Flush all keys in a namespace.  OpenResty's
35//!   `flush_all` equivalent.
36//!
37//! ## Backend Swappability
38//!
39//! Because the engine interacts with state only through the
40//! [`StateStore`] trait, backends can be swapped without changing Lua
41//! code.  Planned backends:
42//!
43//! - `JsonFileStore` (current, default)
44//! - In-memory `HashMap` (for tests and short-lived sessions)
45//! - SQLite (for larger datasets with indexed queries)
46//! - Redis (for distributed / multi-process scenarios)
47
48use std::collections::HashMap;
49use std::fs;
50use std::path::{Path, PathBuf};
51
52use serde_json::Value;
53
54// ═══════════════════════════════════════════════════════════════
55// Trait
56// ═══════════════════════════════════════════════════════════════
57
58/// Backend-agnostic key-value state store.
59///
60/// All operations are namespace-scoped.  Implementations must be
61/// `Send + Sync` so they can be shared across Lua VMs (e.g. fork).
62pub trait StateStore: Send + Sync {
63    /// Read a value.  Returns `None` if the key does not exist.
64    fn get(&self, ns: &str, key: &str) -> Result<Option<Value>, String>;
65
66    /// Write a value (upsert).
67    fn set(&self, ns: &str, key: &str, value: Value) -> Result<(), String>;
68
69    /// Remove a key.  Returns `true` if it existed.
70    fn delete(&self, ns: &str, key: &str) -> Result<bool, String>;
71
72    /// List all keys in a namespace.
73    fn keys(&self, ns: &str) -> Result<Vec<String>, String>;
74
75    /// Check whether a key exists.
76    ///
77    /// Whether this is cheaper than `get` + nil check depends on the
78    /// backend.  `JsonFileStore` still loads the whole namespace; backends
79    /// like Redis or SQLite can answer with an `EXISTS` command.
80    fn has(&self, ns: &str, key: &str) -> Result<bool, String>;
81
82    /// Set a value only if the key does **not** already exist.
83    /// Returns `true` if the value was written, `false` if the key
84    /// was already present.
85    ///
86    /// **Note:** `JsonFileStore` performs a non-locking load-check-save
87    /// cycle.  This is safe within a single process but **not** across
88    /// concurrent processes.  Backends with native CAS (Redis `SETNX`,
89    /// SQLite transactions) will provide true atomicity.
90    fn set_nx(&self, ns: &str, key: &str, value: Value) -> Result<bool, String>;
91
92    /// Counter increment (single-process atomic).
93    ///
94    /// Adds `delta` to the current numeric value at `key`.  If the key
95    /// is missing, initialises it to `default` before adding.  Returns
96    /// the new value.
97    ///
98    /// **Note:** `JsonFileStore` performs a non-locking
99    /// read-modify-write.  Safe within one process; use a backend with
100    /// native `INCR` (Redis) or transactions (SQLite) for multi-process
101    /// safety.
102    ///
103    /// Uses `f64` internally.  Integer-valued deltas are exact; fractional
104    /// deltas may accumulate floating-point rounding errors over many calls.
105    ///
106    /// Errors if the existing value is not a JSON number.
107    fn incr(&self, ns: &str, key: &str, delta: f64, default: f64) -> Result<f64, String>;
108}
109
110// ═══════════════════════════════════════════════════════════════
111// JsonFileStore — default backend
112// ═══════════════════════════════════════════════════════════════
113
114/// JSON-file-backed state store.
115///
116/// Each namespace is a single JSON file at
117/// `{root}/{namespace}.json`.  Writes are atomic: the new state is
118/// written to a `.tmp` sibling and then renamed.
119///
120/// The root directory is provided at construction time; callers are
121/// expected to resolve it from the service-layer `AppDir` abstraction
122/// (typically `~/.algocline/state/`).
123pub struct JsonFileStore {
124    root: PathBuf,
125}
126
127impl JsonFileStore {
128    /// Construct a store rooted at an explicit path.
129    ///
130    /// The directory is **not** created eagerly; it is created lazily
131    /// on the first `set` / `set_nx` / `incr` call via [`Self::state_path`].
132    pub fn new(root: PathBuf) -> Self {
133        Self { root }
134    }
135
136    /// Return the root directory this store writes under.
137    pub fn root(&self) -> &Path {
138        &self.root
139    }
140
141    /// Ensure the root directory exists, returning it.
142    fn ensure_root(&self) -> Result<&Path, String> {
143        if !self.root.exists() {
144            fs::create_dir_all(&self.root)
145                .map_err(|e| format!("Failed to create state dir: {e}"))?;
146        }
147        Ok(&self.root)
148    }
149
150    /// Resolve the JSON file path for a namespace, validating the name
151    /// and creating the root directory on demand.
152    pub fn state_path(&self, ns: &str) -> Result<PathBuf, String> {
153        if ns.contains('/')
154            || ns.contains('\\')
155            || ns.contains("..")
156            || ns.contains('\0')
157            || ns.is_empty()
158        {
159            return Err(format!("Invalid namespace: '{ns}'"));
160        }
161        let dir = self.ensure_root()?;
162        Ok(dir.join(format!("{ns}.json")))
163    }
164
165    fn load(&self, ns: &str) -> Result<HashMap<String, Value>, String> {
166        let path = self.state_path(ns)?;
167        if !path.exists() {
168            return Ok(HashMap::new());
169        }
170        let content =
171            fs::read_to_string(&path).map_err(|e| format!("Failed to read state '{ns}': {e}"))?;
172        serde_json::from_str(&content).map_err(|e| format!("Failed to parse state '{ns}': {e}"))
173    }
174
175    fn save(&self, ns: &str, data: &HashMap<String, Value>) -> Result<(), String> {
176        let path = self.state_path(ns)?;
177        let tmp = path.with_extension("json.tmp");
178        let content = serde_json::to_string_pretty(data)
179            .map_err(|e| format!("Failed to serialize state: {e}"))?;
180        fs::write(&tmp, &content).map_err(|e| format!("Failed to write state tmp: {e}"))?;
181        fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename state file: {e}"))?;
182        Ok(())
183    }
184}
185
186impl StateStore for JsonFileStore {
187    fn get(&self, ns: &str, key: &str) -> Result<Option<Value>, String> {
188        let state = self.load(ns)?;
189        Ok(state.get(key).cloned())
190    }
191
192    fn set(&self, ns: &str, key: &str, value: Value) -> Result<(), String> {
193        let mut state = self.load(ns)?;
194        state.insert(key.to_string(), value);
195        self.save(ns, &state)
196    }
197
198    fn delete(&self, ns: &str, key: &str) -> Result<bool, String> {
199        let mut state = self.load(ns)?;
200        let existed = state.remove(key).is_some();
201        if existed {
202            self.save(ns, &state)?;
203        }
204        Ok(existed)
205    }
206
207    fn keys(&self, ns: &str) -> Result<Vec<String>, String> {
208        let state = self.load(ns)?;
209        Ok(state.keys().cloned().collect())
210    }
211
212    fn has(&self, ns: &str, key: &str) -> Result<bool, String> {
213        let state = self.load(ns)?;
214        Ok(state.contains_key(key))
215    }
216
217    fn set_nx(&self, ns: &str, key: &str, value: Value) -> Result<bool, String> {
218        let mut state = self.load(ns)?;
219        if state.contains_key(key) {
220            return Ok(false);
221        }
222        state.insert(key.to_string(), value);
223        self.save(ns, &state)?;
224        Ok(true)
225    }
226
227    fn incr(&self, ns: &str, key: &str, delta: f64, default: f64) -> Result<f64, String> {
228        let mut state = self.load(ns)?;
229        let current = match state.get(key) {
230            Some(v) => v
231                .as_f64()
232                .ok_or_else(|| format!("incr: value at '{key}' is not a number"))?,
233            None => default,
234        };
235        let new_val = current + delta;
236        state.insert(key.to_string(), serde_json::json!(new_val));
237        self.save(ns, &state)?;
238        Ok(new_val)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use tempfile::TempDir;
246
247    /// Create a JsonFileStore rooted in a fresh tempdir, returning both
248    /// so the TempDir guard lives for the test duration.
249    fn new_store() -> (JsonFileStore, TempDir) {
250        let tmp = tempfile::tempdir().unwrap();
251        let store = JsonFileStore::new(tmp.path().to_path_buf());
252        (store, tmp)
253    }
254
255    #[test]
256    fn roundtrip() {
257        let (store, _tmp) = new_store();
258        let ns = "rt";
259
260        store.set(ns, "count", serde_json::json!(42)).unwrap();
261        store
262            .set(ns, "name", serde_json::json!("algocline"))
263            .unwrap();
264
265        assert_eq!(store.get(ns, "count").unwrap(), Some(serde_json::json!(42)));
266        assert_eq!(
267            store.get(ns, "name").unwrap(),
268            Some(serde_json::json!("algocline"))
269        );
270        assert_eq!(store.get(ns, "missing").unwrap(), None);
271
272        let k = store.keys(ns).unwrap();
273        assert!(k.contains(&"count".to_string()));
274        assert!(k.contains(&"name".to_string()));
275
276        assert!(store.delete(ns, "count").unwrap());
277        assert!(!store.delete(ns, "count").unwrap());
278        assert_eq!(store.get(ns, "count").unwrap(), None);
279    }
280
281    #[test]
282    fn invalid_namespace() {
283        let (store, _tmp) = new_store();
284        assert!(store.state_path("../evil").is_err());
285        assert!(store.state_path("foo/bar").is_err());
286        assert!(store.state_path("foo\\bar").is_err());
287        assert!(store.state_path("").is_err());
288        assert!(store.state_path("foo\0bar").is_err());
289    }
290
291    #[test]
292    fn get_nonexistent_namespace_returns_empty() {
293        let (store, _tmp) = new_store();
294        let result = store.get("ghost_ns", "any_key").unwrap();
295        assert_eq!(result, None);
296    }
297
298    #[test]
299    fn keys_nonexistent_namespace_returns_empty() {
300        let (store, _tmp) = new_store();
301        let result = store.keys("ghost_ns").unwrap();
302        assert!(result.is_empty());
303    }
304
305    #[test]
306    fn delete_nonexistent_key_returns_false() {
307        let (store, _tmp) = new_store();
308        assert!(!store.delete("delns", "nope").unwrap());
309    }
310
311    #[test]
312    fn set_overwrites_existing_value() {
313        let (store, _tmp) = new_store();
314        let ns = "ow";
315
316        store.set(ns, "k", serde_json::json!(1)).unwrap();
317        store.set(ns, "k", serde_json::json!(2)).unwrap();
318        assert_eq!(store.get(ns, "k").unwrap(), Some(serde_json::json!(2)));
319    }
320
321    #[test]
322    fn state_path_valid_namespaces() {
323        let (store, _tmp) = new_store();
324        assert!(store.state_path("default").is_ok());
325        assert!(store.state_path("my-app").is_ok());
326        assert!(store.state_path("test_123").is_ok());
327    }
328
329    // ─── Tier 1: has / set_nx / incr ──────────────────────────
330
331    #[test]
332    fn has_returns_existence() {
333        let (store, _tmp) = new_store();
334        let ns = "hasns";
335
336        assert!(!store.has(ns, "x").unwrap());
337        store.set(ns, "x", serde_json::json!(1)).unwrap();
338        assert!(store.has(ns, "x").unwrap());
339    }
340
341    #[test]
342    fn set_nx_only_sets_if_absent() {
343        let (store, _tmp) = new_store();
344        let ns = "snx";
345
346        assert!(store.set_nx(ns, "k", serde_json::json!("first")).unwrap());
347        assert!(!store.set_nx(ns, "k", serde_json::json!("second")).unwrap());
348        assert_eq!(
349            store.get(ns, "k").unwrap(),
350            Some(serde_json::json!("first")),
351            "set_nx should not overwrite"
352        );
353    }
354
355    #[test]
356    fn incr_initialises_and_increments() {
357        let (store, _tmp) = new_store();
358        let ns = "inc";
359
360        // Missing key: initialise from default (0) + delta (1) = 1
361        let v = store.incr(ns, "counter", 1.0, 0.0).unwrap();
362        assert!((v - 1.0).abs() < f64::EPSILON);
363
364        // Increment existing
365        let v = store.incr(ns, "counter", 5.0, 0.0).unwrap();
366        assert!((v - 6.0).abs() < f64::EPSILON);
367
368        // Negative delta
369        let v = store.incr(ns, "counter", -2.0, 0.0).unwrap();
370        assert!((v - 4.0).abs() < f64::EPSILON);
371    }
372
373    #[test]
374    fn incr_rejects_non_numeric() {
375        let (store, _tmp) = new_store();
376        let ns = "incerr";
377
378        store.set(ns, "s", serde_json::json!("hello")).unwrap();
379        let err = store.incr(ns, "s", 1.0, 0.0).unwrap_err();
380        assert!(err.contains("not a number"), "got: {err}");
381    }
382
383    #[test]
384    fn incr_custom_default() {
385        let (store, _tmp) = new_store();
386        let ns = "incdef";
387
388        let v = store.incr(ns, "score", 10.0, 100.0).unwrap();
389        assert!((v - 110.0).abs() < f64::EPSILON, "100 + 10 = 110");
390    }
391}
392
393#[cfg(test)]
394mod proptests {
395    use super::*;
396    use proptest::prelude::*;
397
398    fn new_store() -> (JsonFileStore, tempfile::TempDir) {
399        let tmp = tempfile::tempdir().unwrap();
400        let store = JsonFileStore::new(tmp.path().to_path_buf());
401        (store, tmp)
402    }
403
404    proptest! {
405        /// Any valid namespace (alphanumeric + hyphen/underscore) round-trips through set/get.
406        #[test]
407        fn roundtrip_arbitrary_values(
408            key in "[a-z]{1,20}",
409            val in any::<i64>(),
410        ) {
411            let (store, _tmp) = new_store();
412            let ns = "rt";
413            let json_val = serde_json::json!(val);
414            store.set(ns, &key, json_val.clone()).unwrap();
415            let got = store.get(ns, &key).unwrap();
416            prop_assert_eq!(got, Some(json_val));
417            let _ = store.delete(ns, &key);
418        }
419
420        /// Path traversal patterns are always rejected.
421        #[test]
422        fn traversal_always_rejected(
423            prefix in "[a-z]{0,5}",
424            suffix in "[a-z]{0,5}",
425        ) {
426            let (store, _tmp) = new_store();
427            let evil = format!("{prefix}/../{suffix}");
428            prop_assert!(store.state_path(&evil).is_err());
429        }
430
431        /// state_path rejects NUL bytes anywhere in the namespace.
432        #[test]
433        fn nul_byte_always_rejected(
434            prefix in "[a-z]{0,10}",
435            suffix in "[a-z]{0,10}",
436        ) {
437            let (store, _tmp) = new_store();
438            let evil = format!("{prefix}\0{suffix}");
439            prop_assert!(store.state_path(&evil).is_err());
440        }
441    }
442}