Skip to main content

mentat_script/
values.rs

1//! Backend-independent mino `Value` builders and the `DbRef` shape shared by
2//! both Mentat scripting backends (SQLite `Store` and pg_mentat's engine).
3//!
4//! Everything here is pure value construction/destructuring — no storage. The
5//! db-value map shape, the tx-report projection, and the tagged inst/uuid
6//! builders live here so the two backends stop drifting (§ 1.19).
7
8use mino_rs::collections::map::PMap;
9use mino_rs::error::throw_str;
10use mino_rs::symbol::Symbol;
11use mino_rs::{Gc, Throw, Value};
12
13/// A destructured db value: which conn, its basis tx, and any temporal bound.
14///
15/// `conn` is the opaque handle from `mentat.store/open`. The SQLite backend
16/// keys its store table by it; pg_mentat has exactly one database and uses `1`.
17#[derive(Clone, Copy, Debug)]
18pub struct DbRef {
19    pub conn: i64,
20    pub basis_tx: i64,
21    pub as_of: Option<i64>,
22    pub since: Option<i64>,
23}
24
25/// A backend-independent transaction report: the tx id and tempid resolutions.
26///
27/// Each backend converts its native report (mentat `TxReport`, pg engine JSON)
28/// into this shape; the shared layer projects it to the `:mentat.store/*` map.
29#[derive(Clone, Debug, Default)]
30pub struct TxReport {
31    pub tx_id: i64,
32    pub tempids: Vec<(String, i64)>,
33}
34
35/// A namespaced keyword `Value`, e.g. `:mentat.store/conn`.
36pub fn kw_ns(ns: &str, name: &str) -> Value {
37    Value::Keyword(Symbol::namespaced(ns, name))
38}
39
40/// A string `Value`.
41pub fn str_val(s: &str) -> Value {
42    Value::Str(Gc::new(s.to_string()))
43}
44
45/// Build a db-value mino map:
46/// `{:mentat.store/db true :mentat.store/conn N :mentat.store/basis-tx T
47///   :mentat.store/as-of A :mentat.store/since S}`.
48pub fn db_value(conn: i64, basis_tx: i64, as_of: Option<i64>, since: Option<i64>) -> Value {
49    let opt = |o: Option<i64>| o.map(Value::Int).unwrap_or(Value::Nil);
50    let m = PMap::empty()
51        .assoc(kw_ns("mentat.store", "db"), Value::Bool(true))
52        .assoc(kw_ns("mentat.store", "conn"), Value::Int(conn))
53        .assoc(kw_ns("mentat.store", "basis-tx"), Value::Int(basis_tx))
54        .assoc(kw_ns("mentat.store", "as-of"), opt(as_of))
55        .assoc(kw_ns("mentat.store", "since"), opt(since));
56    Value::Map(Gc::new(m))
57}
58
59/// Destructure a db value, accepting a bare conn `Int` (treated as the current
60/// db: `basis_tx` left 0, as-of/since nil — backends re-read the live basis
61/// when they need it). A db map carries its conn, basis, and temporal bound.
62pub fn destructure_db(prim: &str, arg: Option<&Value>) -> Result<DbRef, Throw> {
63    match arg {
64        Some(Value::Int(n)) => Ok(DbRef {
65            conn: *n,
66            basis_tx: 0,
67            as_of: None,
68            since: None,
69        }),
70        Some(Value::Map(m)) => {
71            let conn = match m.get(&kw_ns("mentat.store", "conn")) {
72                Some(Value::Int(n)) => *n,
73                _ => {
74                    return Err(throw_str(&format!(
75                        "{prim}: db value missing :mentat.store/conn"
76                    )))
77                }
78            };
79            let basis_tx = match m.get(&kw_ns("mentat.store", "basis-tx")) {
80                Some(Value::Int(n)) => *n,
81                _ => 0,
82            };
83            let opt = |k: &str| match m.get(&kw_ns("mentat.store", k)) {
84                Some(Value::Int(n)) => Some(*n),
85                _ => None,
86            };
87            Ok(DbRef {
88                conn,
89                basis_tx,
90                as_of: opt("as-of"),
91                since: opt("since"),
92            })
93        }
94        _ => Err(throw_str(&format!(
95            "{prim}: first arg must be a db value (from mentat.store/db) or a conn handle"
96        ))),
97    }
98}
99
100/// A [`TxReport`] as a mino map
101/// `{:mentat.store/tx-id N :mentat.store/tempids {...}}`, optionally carrying a
102/// `:mentat.store/db-after`.
103pub fn tx_report_value(report: &TxReport, db_after: Option<Value>) -> Value {
104    let mut tempids = PMap::empty();
105    for (k, v) in &report.tempids {
106        tempids = tempids.assoc(str_val(k), Value::Int(*v));
107    }
108    let mut m = PMap::empty()
109        .assoc(kw_ns("mentat.store", "tx-id"), Value::Int(report.tx_id))
110        .assoc(
111            kw_ns("mentat.store", "tempids"),
112            Value::Map(Gc::new(tempids)),
113        );
114    if let Some(db) = db_after {
115        m = m.assoc(kw_ns("mentat.store", "db-after"), db);
116    }
117    Value::Map(Gc::new(m))
118}
119
120/// The instant reader form `(clojure.instant/read-instant-date "RFC3339")` —
121/// the exact value `read_one("#inst \"RFC3339\"")` yields, so it round-trips
122/// through the reader. (mino has no distinct `Value::Instant`; `#inst` expands
123/// to this constructor call at read time — see `crates/mino/src/reader.rs`.)
124pub fn inst_value(rfc3339: &str) -> Value {
125    cons_call(
126        Symbol::namespaced("clojure.instant", "read-instant-date"),
127        str_val(rfc3339),
128    )
129}
130
131/// A real UUID `Value` — the same value `read_one("#uuid \"…\"")` yields since
132/// the mino refresh (Task 8), so it prints back as `#uuid "…"` and round-trips.
133/// An unparseable string falls back to the `parse-uuid` reader form so callers
134/// still get a value that re-reads to the intended UUID.
135pub fn uuid_value(uuid: &str) -> Value {
136    match parse_uuid_bytes(uuid) {
137        Some(bytes) => Value::Uuid(Gc::new(mino_rs::value::UuidVal(bytes))),
138        None => cons_call(Symbol::plain("parse-uuid"), str_val(uuid)),
139    }
140}
141
142/// Parse a canonical `8-4-4-4-12` hyphenated UUID string to its 16 bytes.
143/// Rejects non-canonical forms (the mino `#uuid` reader is equally strict).
144fn parse_uuid_bytes(s: &str) -> Option<[u8; 16]> {
145    if s.len() != 36 || s.as_bytes().iter().filter(|&&b| b == b'-').count() != 4 {
146        return None;
147    }
148    let hex: String = s.chars().filter(|&c| c != '-').collect();
149    if hex.len() != 32 {
150        return None;
151    }
152    let mut bytes = [0u8; 16];
153    for (i, b) in bytes.iter_mut().enumerate() {
154        *b = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
155    }
156    Some(bytes)
157}
158
159/// Build `(sym arg)` as a proper one-arg cons list.
160fn cons_call(sym: Symbol, arg: Value) -> Value {
161    Value::Cons(Gc::new((
162        Value::Sym(sym),
163        Value::Cons(Gc::new((arg, Value::EmptyList))),
164    )))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use mino_rs::printer::print_str;
171    use mino_rs::reader::read_one;
172
173    #[test]
174    fn uuid_builder_round_trips_through_the_reader() {
175        // The builder emits the same value the #uuid literal reads to.
176        let lit = "12345678-1234-5678-1234-567812345678";
177        let built = uuid_value(lit);
178        assert_eq!(print_str(&built), format!("#uuid \"{lit}\""));
179        let (from_lit, _) = read_one(&format!("#uuid \"{lit}\"")).unwrap();
180        assert_eq!(print_str(&from_lit), print_str(&built));
181    }
182
183    #[test]
184    fn inst_builder_matches_the_reader_form() {
185        let built = inst_value("2017-01-01T00:00:00Z");
186        assert_eq!(
187            print_str(&built),
188            "(clojure.instant/read-instant-date \"2017-01-01T00:00:00Z\")"
189        );
190        let (from_lit, _) = read_one(r#"#inst "2017-01-01T00:00:00Z""#).unwrap();
191        assert_eq!(print_str(&from_lit), print_str(&built));
192    }
193
194    #[test]
195    fn db_value_round_trips_through_destructure() {
196        let v = db_value(1, 42, Some(7), None);
197        let d = destructure_db("t", Some(&v)).unwrap();
198        assert_eq!(
199            (d.conn, d.basis_tx, d.as_of, d.since),
200            (1, 42, Some(7), None)
201        );
202        // A bare conn int is the current db.
203        let d2 = destructure_db("t", Some(&Value::Int(3))).unwrap();
204        assert_eq!((d2.conn, d2.as_of, d2.since), (3, None, None));
205    }
206
207    #[test]
208    fn tx_report_projects_tx_id_and_tempids() {
209        let r = TxReport {
210            tx_id: 268435458,
211            tempids: vec![("t".to_string(), 268435460)],
212        };
213        let s = print_str(&tx_report_value(&r, None));
214        assert!(s.contains(":mentat.store/tx-id 268435458"), "{s}");
215        assert!(s.contains("\"t\" 268435460"), "{s}");
216    }
217
218    #[test]
219    fn bad_uuid_falls_back_to_the_reader_call_form() {
220        let s = print_str(&uuid_value("not-a-uuid"));
221        assert_eq!(s, "(parse-uuid \"not-a-uuid\")");
222    }
223}