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;