Skip to main content

face_core/
envelope.rs

1//! Top-level JSON envelope for `--format=json` output (§7).
2//!
3//! The envelope is a stable shape for any combination of axes,
4//! strategies, and depths. [`Envelope`] is the outer object;
5//! [`ResultBlock`] / [`DetectionReport`] / [`Page`] / [`InputFormat`]
6//! are its components.
7
8use serde::{Deserialize, Serialize};
9
10use crate::{Axis, Cluster, ClusterId};
11
12/// Top-level envelope (§7).
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14#[non_exhaustive]
15pub struct Envelope {
16    /// Result-level metadata: totals, axes, detection report.
17    pub result: ResultBlock,
18    /// Free-form metadata block. Defaults to empty.
19    #[serde(default)]
20    pub meta: serde_json::Map<String, serde_json::Value>,
21    /// Top-level clusters (root children).
22    pub clusters: Vec<Cluster>,
23    /// Page block — populated when `--cluster` is set, empty otherwise.
24    pub page: Page,
25    /// Optional source records cache used when re-processing a saved
26    /// envelope. Empty for hand-authored/minimal envelopes.
27    #[serde(default, skip_serializing_if = "EnvelopeCache::is_empty")]
28    pub cache: EnvelopeCache,
29}
30
31/// Optional source records stored in a JSON envelope so `face` can
32/// re-drill and re-cluster without re-running the upstream command.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
34#[non_exhaustive]
35pub struct EnvelopeCache {
36    /// Original input records, in input order.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub items: Vec<serde_json::Value>,
39}
40
41impl EnvelopeCache {
42    /// Whether the cache carries no source records.
43    pub fn is_empty(&self) -> bool {
44        self.items.is_empty()
45    }
46}
47
48/// Result-level metadata block within the envelope.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
50#[non_exhaustive]
51pub struct ResultBlock {
52    /// Total number of input items observed (before skips).
53    pub input_total: u64,
54    /// Number of records skipped (§11.1).
55    pub skipped: u64,
56    /// Grouping axes applied, outermost first.
57    pub axes: Vec<Axis>,
58    /// What auto-detection picked, for provenance (§4.6).
59    pub detection: DetectionReport,
60}
61
62/// Provenance for auto-detection decisions (§4.6).
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
64#[non_exhaustive]
65pub struct DetectionReport {
66    /// Detected (or supplied) input format.
67    pub format: InputFormat,
68    /// Items path used to extract records from the input.
69    pub items_path: String,
70    /// Score path, when one was detected or supplied.
71    pub score_path: Option<String>,
72    /// Preset name, when an explicit `--preset` was applied.
73    pub preset: Option<String>,
74    /// Reason auto-detection fell back to a default, when applicable.
75    pub fallback_reason: Option<String>,
76}
77
78/// Detected or supplied input format.
79///
80/// Wire form is lowercase except for `face-envelope`, which keeps the
81/// hyphen so it stays distinguishable in provenance output.
82#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
83#[serde(rename_all = "lowercase")]
84#[non_exhaustive]
85pub enum InputFormat {
86    /// Single JSON document.
87    #[default]
88    Json,
89    /// JSON-lines (one JSON value per line).
90    Jsonl,
91    /// Comma-separated values.
92    Csv,
93    /// Tab-separated values.
94    Tsv,
95    /// Set when stdin was a previously-emitted face envelope (§9).
96    /// Wire form: `"face-envelope"`.
97    #[serde(rename = "face-envelope")]
98    FaceEnvelope,
99}
100
101impl Envelope {
102    /// §9: detect whether a byte prefix starts with a face envelope.
103    ///
104    /// The check walks the prefix as a structural scan: skip BOM and
105    /// leading whitespace, require an opening `{`, then look at the
106    /// top-level keys (depth-1 strings). If both `result` and
107    /// `clusters` appear before the prefix runs out, return `true`.
108    ///
109    /// This is intentionally a structural scan, not a full
110    /// `serde_json::from_slice`, so the §9 self-consume detection
111    /// works on envelopes larger than the sniff prefix (the prefix is
112    /// only ~4 KiB; an envelope's `meta` block alone may exceed that).
113    /// A truncated prefix that contains both target keys at depth 1 is
114    /// enough to classify the input.
115    ///
116    /// Returns `false` for any input that does not start with a JSON
117    /// object (JSONL streams, JSON arrays, CSV/TSV, scalars).
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use face_core::Envelope;
123    ///
124    /// let env = br#"{"result":{"input_total":0},"clusters":[]}"#;
125    /// assert!(Envelope::looks_like_envelope(env));
126    ///
127    /// let arr = b"[1, 2, 3]";
128    /// assert!(!Envelope::looks_like_envelope(arr));
129    /// ```
130    pub fn looks_like_envelope(prefix: &[u8]) -> bool {
131        scan_top_level_keys(prefix)
132    }
133}
134
135/// Scan a JSON-prefix for the §9 envelope signature.
136///
137/// Walk the prefix as bytes:
138/// 1. Skip an optional UTF-8 BOM and any leading whitespace.
139/// 2. Require an opening `{` — anything else is not an envelope.
140/// 3. Iterate top-level (depth-1) members: read the key string,
141///    expect a `:`, then skip the value (handling nested objects /
142///    arrays / strings) without parsing it. Track which target keys
143///    have been seen.
144/// 4. As soon as both `result` and `clusters` are seen, return `true`.
145/// 5. If the prefix runs out before both are seen, return `false`.
146///
147/// The scan tolerates malformed JSON beyond the keys we care about —
148/// truncation of a value mid-stream is normal at the prefix boundary,
149/// and the goal is "did we see the signature", not "is this valid
150/// JSON".
151fn scan_top_level_keys(prefix: &[u8]) -> bool {
152    const BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
153
154    let mut bytes = prefix;
155    if bytes.starts_with(&BOM) {
156        bytes = &bytes[BOM.len()..];
157    }
158
159    let mut i = skip_whitespace(bytes, 0);
160    if bytes.get(i).copied() != Some(b'{') {
161        return false;
162    }
163    i += 1;
164
165    let mut saw_result = false;
166    let mut saw_clusters = false;
167
168    loop {
169        i = skip_whitespace(bytes, i);
170        match bytes.get(i).copied() {
171            None => return false,
172            Some(b'}') => return false,
173            Some(b',') => {
174                i += 1;
175                continue;
176            }
177            Some(b'"') => {
178                let (key, after_key) = match read_string(bytes, i) {
179                    Some(pair) => pair,
180                    None => return false,
181                };
182                i = after_key;
183
184                if key == "result" {
185                    saw_result = true;
186                } else if key == "clusters" {
187                    saw_clusters = true;
188                }
189                if saw_result && saw_clusters {
190                    return true;
191                }
192
193                i = skip_whitespace(bytes, i);
194                if bytes.get(i).copied() != Some(b':') {
195                    return false;
196                }
197                i += 1;
198                i = skip_whitespace(bytes, i);
199
200                match skip_value(bytes, i) {
201                    Some(after_value) => i = after_value,
202                    // Value truncated by the prefix boundary — we cannot
203                    // continue reliably. Bail with whatever we have.
204                    None => return saw_result && saw_clusters,
205                }
206            }
207            // Unexpected byte at depth 1 — give up; not envelope-shaped.
208            Some(_) => return false,
209        }
210    }
211}
212
213/// Advance `i` past ASCII whitespace bytes.
214fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize {
215    while let Some(&b) = bytes.get(i) {
216        if b.is_ascii_whitespace() {
217            i += 1;
218        } else {
219            break;
220        }
221    }
222    i
223}
224
225/// Read a JSON string at `bytes[i] == b'"'`. Returns the unescaped
226/// content and the index after the closing quote, or `None` if the
227/// string runs past the prefix or hits an invalid escape.
228fn read_string(bytes: &[u8], i: usize) -> Option<(String, usize)> {
229    if bytes.get(i).copied() != Some(b'"') {
230        return None;
231    }
232    let mut j = i + 1;
233    let mut out = String::new();
234    while let Some(&b) = bytes.get(j) {
235        match b {
236            b'"' => return Some((out, j + 1)),
237            b'\\' => {
238                let esc = bytes.get(j + 1).copied()?;
239                match esc {
240                    b'"' => out.push('"'),
241                    b'\\' => out.push('\\'),
242                    b'/' => out.push('/'),
243                    b'n' => out.push('\n'),
244                    b'r' => out.push('\r'),
245                    b't' => out.push('\t'),
246                    b'b' => out.push('\u{08}'),
247                    b'f' => out.push('\u{0C}'),
248                    // `\uXXXX` escapes — we don't decode them in the
249                    // signature scan; the target keys (`result`,
250                    // `clusters`) are pure ASCII, so any `\u` escape
251                    // means this isn't one of those keys. Emit a
252                    // placeholder character and keep advancing.
253                    b'u' => {
254                        // Skip the four hex digits if present.
255                        for _ in 0..4 {
256                            j += 1;
257                            bytes.get(j + 1)?;
258                        }
259                        out.push('?');
260                    }
261                    _ => return None,
262                }
263                j += 2;
264            }
265            _ => {
266                out.push(b as char);
267                j += 1;
268            }
269        }
270    }
271    None
272}
273
274/// Skip past one JSON value starting at `i`, returning the index just
275/// after it. Returns `None` if the value is truncated by the prefix
276/// boundary.
277fn skip_value(bytes: &[u8], i: usize) -> Option<usize> {
278    let i = skip_whitespace(bytes, i);
279    match bytes.get(i).copied()? {
280        b'{' => skip_balanced(bytes, i, b'{', b'}'),
281        b'[' => skip_balanced(bytes, i, b'[', b']'),
282        b'"' => read_string(bytes, i).map(|(_, j)| j),
283        // Numbers, `true`, `false`, `null`: scan until a structural
284        // delimiter — `,`, `}`, `]`, or whitespace.
285        _ => {
286            let mut j = i;
287            while let Some(&b) = bytes.get(j) {
288                if b == b',' || b == b'}' || b == b']' || b.is_ascii_whitespace() {
289                    return Some(j);
290                }
291                j += 1;
292            }
293            None
294        }
295    }
296}
297
298/// Skip a balanced object or array starting at `bytes[i] == open`.
299/// Counts nested `open`/`close` while respecting string literals.
300fn skip_balanced(bytes: &[u8], i: usize, open: u8, close: u8) -> Option<usize> {
301    if bytes.get(i).copied() != Some(open) {
302        return None;
303    }
304    let mut depth: usize = 1;
305    let mut j = i + 1;
306    while let Some(&b) = bytes.get(j) {
307        match b {
308            b'"' => {
309                // Skip the entire string literal — including escaped
310                // quotes — so braces/brackets inside don't count.
311                let (_, after) = read_string(bytes, j)?;
312                j = after;
313            }
314            x if x == open => {
315                depth += 1;
316                j += 1;
317            }
318            x if x == close => {
319                depth -= 1;
320                j += 1;
321                if depth == 0 {
322                    return Some(j);
323                }
324            }
325            _ => j += 1,
326        }
327    }
328    None
329}
330
331/// Page block — populated when `--cluster` is set (§7.2).
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
333#[non_exhaustive]
334pub struct Page {
335    /// Cluster id this page is drawn from, or `None` for the
336    /// top-level summary view.
337    pub cluster_id: Option<ClusterId>,
338    /// One-based page number.
339    pub page: u32,
340    /// Items per page.
341    pub per_page: u32,
342    /// Total items inside the focused cluster.
343    pub total_items: u64,
344    /// The page's items, in input order.
345    pub items: Vec<serde_json::Value>,
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn default_envelope_round_trips() {
354        let env = Envelope::default();
355        let s = serde_json::to_string(&env).unwrap();
356        let back: Envelope = serde_json::from_str(&s).unwrap();
357        assert_eq!(env, back);
358    }
359
360    #[test]
361    fn face_envelope_format_uses_hyphen_form() {
362        let s = serde_json::to_string(&InputFormat::FaceEnvelope).unwrap();
363        assert_eq!(s, "\"face-envelope\"");
364        let back: InputFormat = serde_json::from_str(&s).unwrap();
365        assert_eq!(back, InputFormat::FaceEnvelope);
366    }
367
368    #[test]
369    fn looks_like_envelope_accepts_minimal_signature() {
370        let bytes = br#"{"result":{},"clusters":[]}"#;
371        assert!(Envelope::looks_like_envelope(bytes));
372    }
373
374    #[test]
375    fn looks_like_envelope_rejects_partial_signature() {
376        let only_result = br#"{"result":{}}"#;
377        let only_clusters = br#"{"clusters":[]}"#;
378        let array = b"[1, 2, 3]";
379        let jsonl = b"{\"a\":1}\n{\"a\":2}\n";
380        assert!(!Envelope::looks_like_envelope(only_result));
381        assert!(!Envelope::looks_like_envelope(only_clusters));
382        assert!(!Envelope::looks_like_envelope(array));
383        assert!(!Envelope::looks_like_envelope(jsonl));
384    }
385}