face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Top-level JSON envelope for `--format=json` output (§7).
//!
//! The envelope is a stable shape for any combination of axes,
//! strategies, and depths. [`Envelope`] is the outer object;
//! [`ResultBlock`] / [`DetectionReport`] / [`Page`] / [`InputFormat`]
//! are its components.

use serde::{Deserialize, Serialize};

use crate::{Axis, Cluster, ClusterId};

/// Top-level envelope (§7).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct Envelope {
    /// Result-level metadata: totals, axes, detection report.
    pub result: ResultBlock,
    /// Free-form metadata block. Defaults to empty.
    #[serde(default)]
    pub meta: serde_json::Map<String, serde_json::Value>,
    /// Top-level clusters (root children).
    pub clusters: Vec<Cluster>,
    /// Page block — populated when `--cluster` is set, empty otherwise.
    pub page: Page,
    /// Optional source records cache used when re-processing a saved
    /// envelope. Empty for hand-authored/minimal envelopes.
    #[serde(default, skip_serializing_if = "EnvelopeCache::is_empty")]
    pub cache: EnvelopeCache,
}

/// Optional source records stored in a JSON envelope so `face` can
/// re-drill and re-cluster without re-running the upstream command.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct EnvelopeCache {
    /// Original input records, in input order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub items: Vec<serde_json::Value>,
}

impl EnvelopeCache {
    /// Whether the cache carries no source records.
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }
}

/// Result-level metadata block within the envelope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct ResultBlock {
    /// Total number of input items observed (before skips).
    pub input_total: u64,
    /// Number of records skipped (§11.1).
    pub skipped: u64,
    /// Grouping axes applied, outermost first.
    pub axes: Vec<Axis>,
    /// What auto-detection picked, for provenance (§4.6).
    pub detection: DetectionReport,
}

/// Provenance for auto-detection decisions (§4.6).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct DetectionReport {
    /// Detected (or supplied) input format.
    pub format: InputFormat,
    /// Items path used to extract records from the input.
    pub items_path: String,
    /// Score path, when one was detected or supplied.
    pub score_path: Option<String>,
    /// Preset name, when an explicit `--preset` was applied.
    pub preset: Option<String>,
    /// Reason auto-detection fell back to a default, when applicable.
    pub fallback_reason: Option<String>,
}

/// Detected or supplied input format.
///
/// Wire form is lowercase except for `face-envelope`, which keeps the
/// hyphen so it stays distinguishable in provenance output.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum InputFormat {
    /// Single JSON document.
    #[default]
    Json,
    /// JSON-lines (one JSON value per line).
    Jsonl,
    /// Comma-separated values.
    Csv,
    /// Tab-separated values.
    Tsv,
    /// Set when stdin was a previously-emitted face envelope (§9).
    /// Wire form: `"face-envelope"`.
    #[serde(rename = "face-envelope")]
    FaceEnvelope,
}

impl Envelope {
    /// §9: detect whether a byte prefix starts with a face envelope.
    ///
    /// The check walks the prefix as a structural scan: skip BOM and
    /// leading whitespace, require an opening `{`, then look at the
    /// top-level keys (depth-1 strings). If both `result` and
    /// `clusters` appear before the prefix runs out, return `true`.
    ///
    /// This is intentionally a structural scan, not a full
    /// `serde_json::from_slice`, so the §9 self-consume detection
    /// works on envelopes larger than the sniff prefix (the prefix is
    /// only ~4 KiB; an envelope's `meta` block alone may exceed that).
    /// A truncated prefix that contains both target keys at depth 1 is
    /// enough to classify the input.
    ///
    /// Returns `false` for any input that does not start with a JSON
    /// object (JSONL streams, JSON arrays, CSV/TSV, scalars).
    ///
    /// # Examples
    ///
    /// ```
    /// use face_core::Envelope;
    ///
    /// let env = br#"{"result":{"input_total":0},"clusters":[]}"#;
    /// assert!(Envelope::looks_like_envelope(env));
    ///
    /// let arr = b"[1, 2, 3]";
    /// assert!(!Envelope::looks_like_envelope(arr));
    /// ```
    pub fn looks_like_envelope(prefix: &[u8]) -> bool {
        scan_top_level_keys(prefix)
    }
}

/// Scan a JSON-prefix for the §9 envelope signature.
///
/// Walk the prefix as bytes:
/// 1. Skip an optional UTF-8 BOM and any leading whitespace.
/// 2. Require an opening `{` — anything else is not an envelope.
/// 3. Iterate top-level (depth-1) members: read the key string,
///    expect a `:`, then skip the value (handling nested objects /
///    arrays / strings) without parsing it. Track which target keys
///    have been seen.
/// 4. As soon as both `result` and `clusters` are seen, return `true`.
/// 5. If the prefix runs out before both are seen, return `false`.
///
/// The scan tolerates malformed JSON beyond the keys we care about —
/// truncation of a value mid-stream is normal at the prefix boundary,
/// and the goal is "did we see the signature", not "is this valid
/// JSON".
fn scan_top_level_keys(prefix: &[u8]) -> bool {
    const BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];

    let mut bytes = prefix;
    if bytes.starts_with(&BOM) {
        bytes = &bytes[BOM.len()..];
    }

    let mut i = skip_whitespace(bytes, 0);
    if bytes.get(i).copied() != Some(b'{') {
        return false;
    }
    i += 1;

    let mut saw_result = false;
    let mut saw_clusters = false;

    loop {
        i = skip_whitespace(bytes, i);
        match bytes.get(i).copied() {
            None => return false,
            Some(b'}') => return false,
            Some(b',') => {
                i += 1;
                continue;
            }
            Some(b'"') => {
                let (key, after_key) = match read_string(bytes, i) {
                    Some(pair) => pair,
                    None => return false,
                };
                i = after_key;

                if key == "result" {
                    saw_result = true;
                } else if key == "clusters" {
                    saw_clusters = true;
                }
                if saw_result && saw_clusters {
                    return true;
                }

                i = skip_whitespace(bytes, i);
                if bytes.get(i).copied() != Some(b':') {
                    return false;
                }
                i += 1;
                i = skip_whitespace(bytes, i);

                match skip_value(bytes, i) {
                    Some(after_value) => i = after_value,
                    // Value truncated by the prefix boundary — we cannot
                    // continue reliably. Bail with whatever we have.
                    None => return saw_result && saw_clusters,
                }
            }
            // Unexpected byte at depth 1 — give up; not envelope-shaped.
            Some(_) => return false,
        }
    }
}

/// Advance `i` past ASCII whitespace bytes.
fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize {
    while let Some(&b) = bytes.get(i) {
        if b.is_ascii_whitespace() {
            i += 1;
        } else {
            break;
        }
    }
    i
}

/// Read a JSON string at `bytes[i] == b'"'`. Returns the unescaped
/// content and the index after the closing quote, or `None` if the
/// string runs past the prefix or hits an invalid escape.
fn read_string(bytes: &[u8], i: usize) -> Option<(String, usize)> {
    if bytes.get(i).copied() != Some(b'"') {
        return None;
    }
    let mut j = i + 1;
    let mut out = String::new();
    while let Some(&b) = bytes.get(j) {
        match b {
            b'"' => return Some((out, j + 1)),
            b'\\' => {
                let esc = bytes.get(j + 1).copied()?;
                match esc {
                    b'"' => out.push('"'),
                    b'\\' => out.push('\\'),
                    b'/' => out.push('/'),
                    b'n' => out.push('\n'),
                    b'r' => out.push('\r'),
                    b't' => out.push('\t'),
                    b'b' => out.push('\u{08}'),
                    b'f' => out.push('\u{0C}'),
                    // `\uXXXX` escapes — we don't decode them in the
                    // signature scan; the target keys (`result`,
                    // `clusters`) are pure ASCII, so any `\u` escape
                    // means this isn't one of those keys. Emit a
                    // placeholder character and keep advancing.
                    b'u' => {
                        // Skip the four hex digits if present.
                        for _ in 0..4 {
                            j += 1;
                            bytes.get(j + 1)?;
                        }
                        out.push('?');
                    }
                    _ => return None,
                }
                j += 2;
            }
            _ => {
                out.push(b as char);
                j += 1;
            }
        }
    }
    None
}

/// Skip past one JSON value starting at `i`, returning the index just
/// after it. Returns `None` if the value is truncated by the prefix
/// boundary.
fn skip_value(bytes: &[u8], i: usize) -> Option<usize> {
    let i = skip_whitespace(bytes, i);
    match bytes.get(i).copied()? {
        b'{' => skip_balanced(bytes, i, b'{', b'}'),
        b'[' => skip_balanced(bytes, i, b'[', b']'),
        b'"' => read_string(bytes, i).map(|(_, j)| j),
        // Numbers, `true`, `false`, `null`: scan until a structural
        // delimiter — `,`, `}`, `]`, or whitespace.
        _ => {
            let mut j = i;
            while let Some(&b) = bytes.get(j) {
                if b == b',' || b == b'}' || b == b']' || b.is_ascii_whitespace() {
                    return Some(j);
                }
                j += 1;
            }
            None
        }
    }
}

/// Skip a balanced object or array starting at `bytes[i] == open`.
/// Counts nested `open`/`close` while respecting string literals.
fn skip_balanced(bytes: &[u8], i: usize, open: u8, close: u8) -> Option<usize> {
    if bytes.get(i).copied() != Some(open) {
        return None;
    }
    let mut depth: usize = 1;
    let mut j = i + 1;
    while let Some(&b) = bytes.get(j) {
        match b {
            b'"' => {
                // Skip the entire string literal — including escaped
                // quotes — so braces/brackets inside don't count.
                let (_, after) = read_string(bytes, j)?;
                j = after;
            }
            x if x == open => {
                depth += 1;
                j += 1;
            }
            x if x == close => {
                depth -= 1;
                j += 1;
                if depth == 0 {
                    return Some(j);
                }
            }
            _ => j += 1,
        }
    }
    None
}

/// Page block — populated when `--cluster` is set (§7.2).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct Page {
    /// Cluster id this page is drawn from, or `None` for the
    /// top-level summary view.
    pub cluster_id: Option<ClusterId>,
    /// One-based page number.
    pub page: u32,
    /// Items per page.
    pub per_page: u32,
    /// Total items inside the focused cluster.
    pub total_items: u64,
    /// The page's items, in input order.
    pub items: Vec<serde_json::Value>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_envelope_round_trips() {
        let env = Envelope::default();
        let s = serde_json::to_string(&env).unwrap();
        let back: Envelope = serde_json::from_str(&s).unwrap();
        assert_eq!(env, back);
    }

    #[test]
    fn face_envelope_format_uses_hyphen_form() {
        let s = serde_json::to_string(&InputFormat::FaceEnvelope).unwrap();
        assert_eq!(s, "\"face-envelope\"");
        let back: InputFormat = serde_json::from_str(&s).unwrap();
        assert_eq!(back, InputFormat::FaceEnvelope);
    }

    #[test]
    fn looks_like_envelope_accepts_minimal_signature() {
        let bytes = br#"{"result":{},"clusters":[]}"#;
        assert!(Envelope::looks_like_envelope(bytes));
    }

    #[test]
    fn looks_like_envelope_rejects_partial_signature() {
        let only_result = br#"{"result":{}}"#;
        let only_clusters = br#"{"clusters":[]}"#;
        let array = b"[1, 2, 3]";
        let jsonl = b"{\"a\":1}\n{\"a\":2}\n";
        assert!(!Envelope::looks_like_envelope(only_result));
        assert!(!Envelope::looks_like_envelope(only_clusters));
        assert!(!Envelope::looks_like_envelope(array));
        assert!(!Envelope::looks_like_envelope(jsonl));
    }
}