Skip to main content

git_policy_cli/
json_record.rs

1//! What:
2//!  Reading and writing the compact JSON records both wrappers share,
3//!  with the
4//!       incumbent's `JSON.parse` and `JSON.stringify` semantics.
5//! Why:
6//!  Lock,
7//!  owner,
8//!  journal and capture records written by either wrapper are read by the
9//!      other.
10//!  A record must be read the way the incumbent reads it (numbers are doubles,
11//!  a
12//!      whole number in safe-integer range is an integer,
13//!  the last duplicate key wins) and
14//!      written with the same field order and escaping.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! const value: unknown = JSON.parse(text); const bytes = `${JSON.stringify(record)}\n`;
19//! ```
20
21/// What:
22///  `Map` is a JSON object's key/value table and `Value` any JSON value.
23/// Why:
24///   Records are untrusted until each field is checked,
25///  so they are parsed into the
26///       general form first.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// type Value = unknown;
31/// ```
32use serde_json::{Map, Value};
33
34/// The largest whole number a double holds exactly,
35///  JavaScript's `Number.MAX_SAFE_INTEGER`.
36pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
37
38/// What:
39///  Parse record text into a JSON object,
40///  or nothing for any other document.
41///       `Option<Map<String, Value>>` is "an object,
42///  or nothing".
43/// Why:
44///   Every record is an object;
45///  an array,
46///  a scalar or text that is not JSON is malformed.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// function parseObject(text: string): Record<string, unknown> | undefined;
51/// ```
52pub fn parse_object(text: &str) -> Option<Map<String, Value>> {
53    // `match` unpacks the parse result; any parse failure is "no object".
54    match serde_json::from_str::<Value>(text) {
55        Ok(Value::Object(map)) => return Some(map),
56        Ok(_) | Err(_) => return None,
57    }
58}
59
60/// What:
61///  Decode record bytes the way a `fatal` `TextDecoder` does:
62///  valid UTF-8 only,
63///  with one
64///       leading byte-order mark removed.
65/// Why:
66///   The incumbent decodes owner and journal records with `new TextDecoder('utf-8',
67///       { fatal: true })`,
68///  which rejects invalid bytes and drops a leading mark.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// new TextDecoder('utf-8', { fatal: true }).decode(bytes)
73/// ```
74pub fn decode_fatal(bytes: &[u8]) -> Option<&str> {
75    let text: &str = std::str::from_utf8(bytes).ok()?;
76    // `strip_prefix` returns the rest after the mark, when the text starts with it.
77    return Some(text.strip_prefix('\u{feff}').unwrap_or(text));
78}
79
80/// What:
81///  Read a field as text,
82///  or nothing when it is absent or not a string.
83/// Why:
84///   `typeof value.key === 'string'` is the incumbent's check.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// typeof value[key] === 'string' ? value[key] : undefined
89/// ```
90pub fn string_field<'a>(map: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
91    return map.get(key)?.as_str();
92}
93
94/// What:
95///  Read a field as a JavaScript safe integer,
96///  or nothing.
97///       `i64` is a signed 64-bit integer.
98/// Why:
99///   `typeof value === 'number' && Number.isSafeInteger(value)` accepts `1`,
100///  `1.0` and
101///       `1e0` alike,
102///  because JavaScript has only doubles;
103///  anything with a fraction or beyond
104///       2^53 - 1 is refused.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// typeof value[key] === 'number' && Number.isSafeInteger(value[key]) ? value[key] : undefined
109/// ```
110pub fn safe_integer_field(map: &Map<String, Value>, key: &str) -> Option<i64> {
111    return safe_integer(map.get(key)?);
112}
113
114/// What:
115///  The safe-integer reading of one JSON value.
116/// Why:
117///   Shared by object fields and array items.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// function safeInteger(value: unknown): number | undefined;
122/// ```
123pub fn safe_integer(value: &Value) -> Option<i64> {
124    // `let Value::Number(number) = value else { ... }` keeps only numbers.
125    let Value::Number(number) = value else {
126        return None;
127    };
128    if let Some(whole) = number.as_i64() {
129        return in_safe_range(whole);
130    }
131    if number.as_u64().is_some() {
132        // Any `u64` that does not fit `i64` is far beyond the safe range.
133        return None;
134    }
135    let double: f64 = number.as_f64()?;
136    // A double is a safe integer when it has no fraction and lies within ±(2^53 - 1).
137    if double.fract() != 0.0 || double.abs() > MAX_SAFE_INTEGER as f64 {
138        return None;
139    }
140    // The cast is exact: the value is whole and within range.
141    return Some(double as i64);
142}
143
144/// What:
145///  Keep a whole number only when it lies within the safe range.
146/// Why:
147///   `Number.isSafeInteger` rejects magnitudes beyond 2^53 - 1.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// Math.abs(whole) <= Number.MAX_SAFE_INTEGER ? whole : undefined
152/// ```
153fn in_safe_range(whole: i64) -> Option<i64> {
154    if !(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&whole) {
155        return None;
156    }
157    return Some(whole);
158}
159
160/// What:
161///  Whether a field holds exactly the JSON number `expected`.
162/// Why:
163///   `value.schemaVersion !== 1` compares doubles,
164///  so `1.0` matches `1`.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// value[key] === expected
169/// ```
170pub fn number_equals(map: &Map<String, Value>, key: &str, expected: i64) -> bool {
171    return safe_integer_field(map, key) == Some(expected);
172}
173
174/// What:
175///  Builder of one compact JSON object in insertion order,
176///  as `JSON.stringify` writes it.
177///       A `struct` groups named fields;
178///  this one holds the text written so far.
179/// Why:
180///   Records must list their fields in the incumbent's order so the bytes match for the
181///       same values;
182///  escaping comes from `serde_json`,
183///  which escapes the same characters
184///       `JSON.stringify` does.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// JSON.stringify({ a: 1, b: 'x' })
189/// ```
190#[derive(Debug, Default)]
191pub struct ObjectWriter {
192    /// The object text without its closing brace.
193    text: String,
194}
195
196/// Methods that append one field each and finish the object.
197impl ObjectWriter {
198    /// What:
199    ///  Start an empty object.
200    /// Why:
201    ///   Every record starts the same way.
202    ///
203    /// In TS you'd write (pseudocode):
204    /// ```ts
205    /// const record = {};
206    /// ```
207    pub fn new() -> Self {
208        return Self {
209            text: String::from("{"),
210        };
211    }
212
213    /// What:
214    ///  Append the separator and the quoted key of the next field.
215    /// Why:
216    ///   Every field kind starts the same way.
217    ///
218    /// In TS you'd write (pseudocode):
219    /// ```ts
220    /// text += (first ? '' : ',') + JSON.stringify(key) + ':';
221    /// ```
222    fn key(&mut self, key: &str) {
223        if self.text.len() > 1 {
224            self.text.push(',');
225        }
226        self.text.push_str(quote(key).as_str());
227        self.text.push(':');
228    }
229
230    /// What:
231    ///  Append a text field.
232    /// Why:
233    ///   Most record fields are text.
234    ///
235    /// In TS you'd write (pseudocode):
236    /// ```ts
237    /// record[key] = value;
238    /// ```
239    pub fn string(&mut self, key: &str, value: &str) -> &mut Self {
240        self.key(key);
241        self.text.push_str(quote(value).as_str());
242        return self;
243    }
244
245    /// What:
246    ///  Append a whole-number field.
247    /// Why:
248    ///   Schema versions,
249    ///  process IDs and attempt numbers.
250    ///
251    /// In TS you'd write (pseudocode):
252    /// ```ts
253    /// record[key] = value;
254    /// ```
255    pub fn integer(&mut self, key: &str, value: i64) -> &mut Self {
256        self.key(key);
257        self.text.push_str(value.to_string().as_str());
258        return self;
259    }
260
261    /// What:
262    ///  Append a boolean field.
263    /// Why:
264    ///   Flags such as whether a prepared commit is signed.
265    ///
266    /// In TS you'd write (pseudocode):
267    /// ```ts
268    /// record[key] = value;
269    /// ```
270    pub fn boolean(&mut self, key: &str, value: bool) -> &mut Self {
271        self.key(key);
272        self.text.push_str(if value { "true" } else { "false" });
273        return self;
274    }
275
276    /// What:
277    ///  Append a field whose value is already complete JSON text.
278    /// Why:
279    ///   Nested objects and arrays are built by their own writers.
280    ///
281    /// In TS you'd write (pseudocode):
282    /// ```ts
283    /// record[key] = nested;
284    /// ```
285    pub fn raw(&mut self, key: &str, json: &str) -> &mut Self {
286        self.key(key);
287        self.text.push_str(json);
288        return self;
289    }
290
291    /// What:
292    ///  The finished object text.
293    /// Why:
294    ///   Callers add the record's line terminator themselves.
295    ///
296    /// In TS you'd write (pseudocode):
297    /// ```ts
298    /// JSON.stringify(record)
299    /// ```
300    pub fn finish(&self) -> String {
301        return format!("{}}}", self.text);
302    }
303}
304
305/// What:
306///  Quote text as a JSON string literal.
307/// Why:
308///   `serde_json` escapes `"`,
309///  `\` and control characters as `JSON.stringify` does;
310///       serializing a `&str` cannot fail.
311///
312/// In TS you'd write (pseudocode):
313/// ```ts
314/// JSON.stringify(text)
315/// ```
316pub fn quote(text: &str) -> String {
317    // `unwrap_or_default` is never reached: a `&str` always serializes.
318    return serde_json::to_string(text).unwrap_or_default();
319}
320
321/// What:
322///  A JSON array of text items.
323/// Why:
324///   Pathspec and path lists in journal records.
325///
326/// In TS you'd write (pseudocode):
327/// ```ts
328/// JSON.stringify(items)
329/// ```
330pub fn string_array(items: &[String]) -> String {
331    // `mut` allows appending one item at a time.
332    let mut text: String = String::from("[");
333    for (index, item) in items.iter().enumerate() {
334        if index > 0 {
335            text.push(',');
336        }
337        text.push_str(quote(item.as_str()).as_str());
338    }
339    text.push(']');
340    return text;
341}
342
343/// Parsing and writing controls stay out of the release executable.
344#[cfg(test)]
345#[path = "json_record_tests.rs"]
346mod tests;