Skip to main content

sva_core/
json.rs

1// Concern: the JSON scalar, array and envelope-meta spellings every emitter agrees on | Non-concern: what any one object holds (output.rs, sva-analysis's json.rs) | IO: (a value) -> a fragment
2
3use std::sync::OnceLock;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6#[cfg(not(target_arch = "wasm32"))]
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// One spelling, every emitter, wherever a declared key holds nothing.
10pub const NONE: &str = "null";
11
12pub fn escape(s: &str) -> String {
13    let mut out = String::with_capacity(s.len());
14    for c in s.chars() {
15        match c {
16            '"' => out.push_str("\\\""),
17            '\\' => out.push_str("\\\\"),
18            '\n' => out.push_str("\\n"),
19            '\r' => out.push_str("\\r"),
20            '\t' => out.push_str("\\t"),
21            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
22            c => out.push(c),
23        }
24    }
25    out
26}
27
28/// JSON has no NaN or infinity, so a measurement at one reads as `null`.
29pub fn num(v: f64) -> String {
30    if v.is_finite() {
31        format!("{v}")
32    } else {
33        NONE.to_string()
34    }
35}
36
37/// The `cli` standard's collection: `count` is the whole of it, `has_more` that `items` is
38/// not, and `next_cursor` the interval start the caller reads the rest from.
39pub fn collection(
40    items: impl IntoIterator<Item = String>,
41    held: usize,
42    next: Option<String>,
43) -> String {
44    let written: Vec<String> = items.into_iter().collect();
45    let cursor = next
46        .as_ref()
47        .map_or_else(|| NONE.to_string(), |at| format!("\"{}\"", escape(at)));
48    format!(
49        "{{ \"items\": [{}], \"pagination\": {{ \"count\": {held}, \"has_more\": {}, \"next_cursor\": {cursor} }} }}",
50        written.join(", "),
51        next.is_some()
52    )
53}
54
55pub fn list<T>(items: &[T], f: impl Fn(&T) -> String) -> String {
56    collection(items.iter().map(f), items.len(), None)
57}
58
59/// `items` holds the first `shown`; `resumes_at` names the second the next one begins at.
60pub fn capped<T>(
61    items: &[T],
62    shown: usize,
63    resumes_at: impl Fn(usize) -> f64,
64    f: impl Fn(&T) -> String,
65) -> String {
66    let next = (shown < items.len()).then(|| format!("{}s", resumes_at(shown)));
67    collection(items[..shown].iter().map(f), items.len(), next)
68}
69
70pub fn pair_list(items: &[(&str, &str)], key: &str, value: &str) -> String {
71    list(items, |(a, b)| {
72        format!(
73            "{{ \"{key}\": \"{}\", \"{value}\": \"{}\" }}",
74            escape(a),
75            escape(b)
76        )
77    })
78}
79
80pub fn strings(items: &[impl AsRef<str>]) -> String {
81    list(items, |s| format!("\"{}\"", escape(s.as_ref())))
82}
83
84#[cfg(not(target_arch = "wasm32"))]
85fn timestamp() -> u64 {
86    SystemTime::now()
87        .duration_since(UNIX_EPOCH)
88        .map(|d| d.as_secs())
89        .unwrap_or(0)
90}
91
92#[cfg(target_arch = "wasm32")]
93fn timestamp() -> u64 {
94    0
95}
96
97/// Nanoseconds and the process id part two runs inside one second; a wasm module has
98/// neither, and answers one page's sequence off the counter alone.
99fn origin() -> u64 {
100    #[cfg(not(target_arch = "wasm32"))]
101    {
102        let nanos = SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .map(|d| d.as_nanos() as u64)
105            .unwrap_or(0);
106        nanos ^ ((std::process::id() as u64) << 40)
107    }
108    #[cfg(target_arch = "wasm32")]
109    {
110        0
111    }
112}
113
114/// So an agent can quote the response it is asking about.
115fn request_id() -> String {
116    static ORIGIN: OnceLock<u64> = OnceLock::new();
117    static ANSWERED: AtomicU64 = AtomicU64::new(0);
118    let n = ANSWERED.fetch_add(1, Ordering::Relaxed);
119    format!("req_{:016x}{n:06x}", ORIGIN.get_or_init(origin))
120}
121
122pub fn meta() -> String {
123    format!(
124        "{{ \"request_id\": \"{}\", \"timestamp\": {} }}",
125        request_id(),
126        timestamp()
127    )
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn a_non_finite_measurement_reads_as_null_in_every_emitter() {
136        assert_eq!(num(0.5), "0.5");
137        assert_eq!(num(f64::NAN), "null");
138        assert_eq!(num(f64::INFINITY), "null");
139        assert_eq!(num(f64::NEG_INFINITY), "null");
140    }
141
142    #[test]
143    fn a_control_character_is_escaped_rather_than_emitted_raw() {
144        assert_eq!(escape("a\"b\\c\nd\u{1}"), "a\\\"b\\\\c\\nd\\u0001");
145    }
146
147    #[test]
148    fn every_response_carries_its_own_request_id_beside_the_timestamp() {
149        let (first, second) = (meta(), meta());
150        assert!(first.contains("\"request_id\": \"req_"), "{first}");
151        assert!(first.contains("\"timestamp\": "), "{first}");
152        assert_ne!(first, second, "two responses are never the same request");
153    }
154
155    #[test]
156    fn the_origin_carries_more_than_the_whole_second_two_runs_would_share() {
157        let (a, b) = (origin(), origin());
158        assert_ne!(a, b, "the origin carries more than whole seconds");
159    }
160
161    #[test]
162    fn an_empty_collection_is_an_empty_items_list_with_a_zero_count() {
163        assert!(list(&[] as &[f64], |v| num(*v)).contains("\"items\": []"));
164        assert!(strings(&[] as &[&str]).contains("\"count\": 0"));
165        assert!(strings(&["a", "b\"c"]).contains("[\"a\", \"b\\\"c\"]"));
166        let cut = capped(&[1.0, 2.0, 3.0], 2, |n| n as f64 / 4.0, |v| num(*v));
167        assert!(
168            cut.contains("\"count\": 3") && cut.contains("\"has_more\": true"),
169            "{cut}"
170        );
171        assert!(
172            cut.contains("\"items\": [1, 2]"),
173            "the page, not the whole: {cut}"
174        );
175        assert!(
176            cut.contains("\"next_cursor\": \"0.5s\""),
177            "an interval start: {cut}"
178        );
179    }
180}