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}