use serde::{Deserialize, Serialize};
pub const BASIS_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfidenceLevel {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Highlight {
pub text: String,
pub score: f64,
pub char_start: usize,
pub char_end: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceCitation {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>,
pub source_hash: String,
pub source_text_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub char_start: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub char_end: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FieldStatus {
Supported,
Unverified,
Unsupported,
NotFound,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BasisWarning {
pub field: String,
pub code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceEvidence {
pub url: String,
pub title: String,
pub position: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub highlights: Vec<Highlight>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Basis {
pub basis_version: u8,
pub field: String,
#[serde(default)]
pub value: Option<serde_json::Value>,
pub status: FieldStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<ConfidenceLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(default)]
pub citations: Vec<EvidenceCitation>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SourcePolicy {
#[serde(default)]
pub include_domains: Vec<String>,
#[serde(default)]
pub exclude_domains: Vec<String>,
#[serde(default)]
pub prefer_domains: Vec<String>,
#[serde(default)]
pub allow_subdomains: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_age_hours: Option<u32>,
#[serde(default)]
pub force_live: bool,
#[serde(default)]
pub source_types: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_evidence_is_camelcase_and_skips_empty() {
let ev = SourceEvidence {
url: "https://example.com".into(),
title: "Example".into(),
position: 1,
highlights: vec![],
};
let j = serde_json::to_value(&ev).unwrap();
assert_eq!(j["url"], "https://example.com");
assert!(
j.get("highlights").is_none(),
"empty highlights must be skipped"
);
let hl = Highlight {
text: "x".into(),
score: 0.5,
char_start: 0,
char_end: 1,
source_hash: None,
};
let jh = serde_json::to_value(&hl).unwrap();
assert!(
jh.get("charStart").is_some(),
"char_start must serialize as charStart"
);
assert!(
jh.get("sourceHash").is_none(),
"None source_hash must be skipped"
);
}
#[test]
fn confidence_level_is_lowercase() {
assert_eq!(
serde_json::to_value(ConfidenceLevel::High).unwrap(),
serde_json::json!("high")
);
}
#[test]
fn basis_null_value_and_empty_citations_are_visible() {
let b = Basis {
basis_version: BASIS_VERSION,
field: "price".into(),
value: None,
status: FieldStatus::NotFound,
confidence: None,
reasoning: None,
citations: vec![],
};
let j = serde_json::to_value(&b).unwrap();
assert_eq!(j["basisVersion"], 1);
assert!(
j.get("value").is_some_and(serde_json::Value::is_null),
"notFound must serialize an explicit null value, got: {j}"
);
assert_eq!(
j["citations"],
serde_json::json!([]),
"empty citations must serialize as [], not be skipped"
);
assert!(j.get("reasoning").is_none(), "None reasoning is skipped");
}
#[test]
fn field_status_is_camelcase_on_the_wire() {
let j = |s: FieldStatus| serde_json::to_value(s).unwrap();
assert_eq!(j(FieldStatus::Supported), serde_json::json!("supported"));
assert_eq!(j(FieldStatus::Unverified), serde_json::json!("unverified"));
assert_eq!(
j(FieldStatus::Unsupported),
serde_json::json!("unsupported")
);
assert_eq!(j(FieldStatus::NotFound), serde_json::json!("notFound"));
}
#[test]
fn evidence_citation_is_camelcase() {
let c = EvidenceCitation {
url: "https://example.com".into(),
title: None,
excerpt: Some("Price: $19.99".into()),
source_hash: "sha256:ab".into(),
source_text_kind: "llmInput".into(),
char_start: None,
char_end: None,
};
let j = serde_json::to_value(&c).unwrap();
assert_eq!(j["sourceHash"], "sha256:ab");
assert_eq!(j["sourceTextKind"], "llmInput");
assert_eq!(j["excerpt"], "Price: $19.99");
assert!(j.get("title").is_none(), "None title is skipped");
assert!(j.get("charStart").is_none(), "v1 emits no offsets");
}
#[test]
fn basis_warning_is_camelcase() {
let w = BasisWarning {
field: "price".into(),
code: "basis_value_mismatch".into(),
};
let j = serde_json::to_value(&w).unwrap();
assert_eq!(j["field"], "price");
assert_eq!(j["code"], "basis_value_mismatch");
}
#[test]
fn source_policy_default_is_unconstrained() {
let j = serde_json::to_value(SourcePolicy::default()).unwrap();
assert!(j.get("publishedAfter").is_none());
assert_eq!(j["allowSubdomains"], false);
assert_eq!(j["includeDomains"], serde_json::json!([]));
}
}