use serde::{Deserialize, Serialize};
use crate::{Axis, Cluster, ClusterId};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct Envelope {
pub result: ResultBlock,
#[serde(default)]
pub meta: serde_json::Map<String, serde_json::Value>,
pub clusters: Vec<Cluster>,
pub page: Page,
#[serde(default, skip_serializing_if = "EnvelopeCache::is_empty")]
pub cache: EnvelopeCache,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct EnvelopeCache {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<serde_json::Value>,
}
impl EnvelopeCache {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct ResultBlock {
pub input_total: u64,
pub skipped: u64,
pub axes: Vec<Axis>,
pub detection: DetectionReport,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct DetectionReport {
pub format: InputFormat,
pub items_path: String,
pub score_path: Option<String>,
pub preset: Option<String>,
pub fallback_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum InputFormat {
#[default]
Json,
Jsonl,
Csv,
Tsv,
#[serde(rename = "face-envelope")]
FaceEnvelope,
}
impl Envelope {
pub fn looks_like_envelope(prefix: &[u8]) -> bool {
scan_top_level_keys(prefix)
}
}
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,
None => return saw_result && saw_clusters,
}
}
Some(_) => return false,
}
}
}
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
}
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}'),
b'u' => {
for _ in 0..4 {
j += 1;
bytes.get(j + 1)?;
}
out.push('?');
}
_ => return None,
}
j += 2;
}
_ => {
out.push(b as char);
j += 1;
}
}
}
None
}
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),
_ => {
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
}
}
}
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'"' => {
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
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[non_exhaustive]
pub struct Page {
pub cluster_id: Option<ClusterId>,
pub page: u32,
pub per_page: u32,
pub total_items: u64,
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));
}
}