#![allow(
clippy::too_many_lines,
reason = "scaffolding will be split when expanded to all 18 classes"
)]
use std::collections::BTreeSet;
use std::fmt;
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map as JsonMap, Value as JsonValue};
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use super::EdtfString;
use super::contributor::ContributorList;
use super::types::common::{RefID, Title};
#[derive(Debug, Clone, PartialEq)]
pub struct InputReference {
pub id: RefID,
pub title: Option<Title>,
pub contributors: Option<ContributorList>,
pub issued: Option<EdtfString>,
pub created: Option<EdtfString>,
pub note: Option<String>,
pub(crate) extension: ClassExtension,
}
impl InputReference {
#[must_use]
pub fn class(&self) -> ReferenceClass {
match &self.extension {
ClassExtension::Monograph(_) => ReferenceClass::Monograph,
ClassExtension::LegalCase(_) => ReferenceClass::LegalCase,
ClassExtension::AudioVisual(_) => ReferenceClass::AudioVisual,
ClassExtension::Unknown(u) => ReferenceClass::Unknown(u.class.clone()),
}
}
#[must_use]
pub fn extension(&self) -> &ClassExtension {
&self.extension
}
#[must_use]
pub fn as_monograph(&self) -> Option<&MonographFields> {
match &self.extension {
ClassExtension::Monograph(m) => Some(m),
_ => None,
}
}
#[must_use]
pub fn as_legal_case(&self) -> Option<&LegalCaseFields> {
match &self.extension {
ClassExtension::LegalCase(c) => Some(c),
_ => None,
}
}
#[must_use]
pub fn as_audio_visual(&self) -> Option<&AudioVisualFields> {
match &self.extension {
ClassExtension::AudioVisual(a) => Some(a),
_ => None,
}
}
#[must_use]
pub fn unknown_class(&self) -> Option<&UnknownClassData> {
match &self.extension {
ClassExtension::Unknown(u) => Some(u),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ReferenceClass {
Monograph,
LegalCase,
AudioVisual,
#[serde(skip)]
Unknown(String),
}
impl ReferenceClass {
pub const KNOWN: &'static [&'static str] = &["monograph", "legal-case", "audio-visual"];
}
#[derive(Debug, Clone, PartialEq)]
pub enum ClassExtension {
Monograph(MonographFields),
LegalCase(LegalCaseFields),
AudioVisual(AudioVisualFields),
Unknown(UnknownClassData),
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct MonographFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monograph_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volume: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isbn: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct LegalCaseFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub court: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docket_number: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reporter: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct AudioVisualFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_minutes: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub medium: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnknownClassData {
pub class: String,
pub fields: JsonMap<String, JsonValue>,
}
const SHARED_KEYS: &[&str] = &["id", "title", "contributors", "issued", "created", "note"];
const MONOGRAPH_KEYS: &[&str] = &["monograph-type", "edition", "volume", "isbn"];
const LEGAL_CASE_KEYS: &[&str] = &["court", "docket-number", "reporter"];
const AUDIO_VISUAL_KEYS: &[&str] = &["runtime-minutes", "medium"];
fn class_keys(class: &str) -> Option<&'static [&'static str]> {
match class {
"monograph" => Some(MONOGRAPH_KEYS),
"legal-case" => Some(LEGAL_CASE_KEYS),
"audio-visual" => Some(AUDIO_VISUAL_KEYS),
_ => None,
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct SharedFields {
id: RefID,
#[serde(default)]
title: Option<Title>,
#[serde(default)]
contributors: Option<ContributorList>,
#[serde(default)]
issued: Option<EdtfString>,
#[serde(default)]
created: Option<EdtfString>,
#[serde(default)]
note: Option<String>,
}
impl<'de> Deserialize<'de> for InputReference {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct RefVisitor;
impl<'de> Visitor<'de> for RefVisitor {
type Value = InputReference;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a reference object with a `class` discriminator")
}
fn visit_map<M>(self, mut map: M) -> Result<InputReference, M::Error>
where
M: MapAccess<'de>,
{
let mut entries: Vec<(String, JsonValue)> = Vec::new();
let mut class: Option<String> = None;
let mut seen: BTreeSet<String> = BTreeSet::new();
while let Some(key) = map.next_key::<String>()? {
if !seen.insert(key.clone()) {
return Err(de::Error::custom(format!("duplicate field `{key}`")));
}
if key == "class" {
class = Some(map.next_value()?);
} else {
let v: JsonValue = map.next_value()?;
entries.push((key, v));
}
}
let class = class.ok_or_else(|| de::Error::missing_field("class"))?;
let known = class_keys(&class);
let mut shared_map: JsonMap<String, JsonValue> = JsonMap::new();
let mut class_map: JsonMap<String, JsonValue> = JsonMap::new();
for (k, v) in entries {
if SHARED_KEYS.contains(&k.as_str()) {
shared_map.insert(k, v);
} else if let Some(keys) = known {
if keys.contains(&k.as_str()) {
class_map.insert(k, v);
} else {
return Err(de::Error::custom(format!(
"unknown field `{k}` for class `{class}`; \
known shared fields: {SHARED_KEYS:?}, \
known fields for this class: {keys:?}"
)));
}
} else {
class_map.insert(k, v);
}
}
let shared: SharedFields = serde_json::from_value(JsonValue::Object(shared_map))
.map_err(de::Error::custom)?;
let extension = match class.as_str() {
"monograph" => {
let f: MonographFields =
serde_json::from_value(JsonValue::Object(class_map)).map_err(|e| {
de::Error::custom(format!("class `monograph`: {e}"))
})?;
ClassExtension::Monograph(f)
}
"legal-case" => {
let f: LegalCaseFields =
serde_json::from_value(JsonValue::Object(class_map)).map_err(|e| {
de::Error::custom(format!("class `legal-case`: {e}"))
})?;
ClassExtension::LegalCase(f)
}
"audio-visual" => {
let f: AudioVisualFields =
serde_json::from_value(JsonValue::Object(class_map)).map_err(|e| {
de::Error::custom(format!("class `audio-visual`: {e}"))
})?;
ClassExtension::AudioVisual(f)
}
other => ClassExtension::Unknown(UnknownClassData {
class: other.to_string(),
fields: class_map,
}),
};
Ok(InputReference {
id: shared.id,
title: shared.title,
contributors: shared.contributors,
issued: shared.issued,
created: shared.created,
note: shared.note,
extension,
})
}
}
deserializer.deserialize_map(RefVisitor)
}
}
impl Serialize for InputReference {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let class_str = match &self.extension {
ClassExtension::Monograph(_) => "monograph",
ClassExtension::LegalCase(_) => "legal-case",
ClassExtension::AudioVisual(_) => "audio-visual",
ClassExtension::Unknown(u) => &u.class,
};
let class_value = match &self.extension {
ClassExtension::Monograph(f) => serde_json::to_value(f),
ClassExtension::LegalCase(f) => serde_json::to_value(f),
ClassExtension::AudioVisual(f) => serde_json::to_value(f),
ClassExtension::Unknown(u) => Ok(JsonValue::Object(u.fields.clone())),
}
.map_err(serde::ser::Error::custom)?;
let class_map = match class_value {
JsonValue::Object(m) => m,
JsonValue::Null => JsonMap::new(),
_ => {
return Err(serde::ser::Error::custom(
"class-specific fields did not serialize as an object",
));
}
};
let mut total = 2 + class_map.len();
if self.title.is_some() {
total += 1;
}
if self.contributors.is_some() {
total += 1;
}
if self.issued.is_some() {
total += 1;
}
if self.created.is_some() {
total += 1;
}
if self.note.is_some() {
total += 1;
}
let mut out = serializer.serialize_map(Some(total))?;
out.serialize_entry("id", &self.id)?;
out.serialize_entry("class", class_str)?;
if let Some(t) = &self.title {
out.serialize_entry("title", t)?;
}
if let Some(c) = &self.contributors {
out.serialize_entry("contributors", c)?;
}
if let Some(i) = &self.issued {
out.serialize_entry("issued", i)?;
}
if let Some(c) = &self.created {
out.serialize_entry("created", c)?;
}
if let Some(n) = &self.note {
out.serialize_entry("note", n)?;
}
for (k, v) in &class_map {
out.serialize_entry(k, v)?;
}
out.end()
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "Panicking is acceptable in tests."
)]
mod tests {
use super::*;
fn parse(yaml: &str) -> Result<InputReference, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
#[test]
fn valid_monograph_parses() {
let yaml = r#"
id: smith2026
class: monograph
title: A Book
issued: "2026"
monograph-type: book
volume: "2"
"#;
let r = parse(yaml).unwrap();
assert_eq!(r.id.as_str(), "smith2026");
assert!(matches!(r.class(), ReferenceClass::Monograph));
let m = r.as_monograph().unwrap();
assert_eq!(m.monograph_type.as_deref(), Some("book"));
assert_eq!(m.volume.as_deref(), Some("2"));
}
#[test]
fn known_class_with_no_class_specific_fields() {
let yaml = r#"
id: smith2026
class: legal-case
title: Smith v. Jones
"#;
let r = parse(yaml).unwrap();
assert!(matches!(r.class(), ReferenceClass::LegalCase));
let c = r.as_legal_case().unwrap();
assert!(c.court.is_none());
assert!(c.docket_number.is_none());
}
#[test]
fn typo_in_shared_field_rejected() {
let yaml = r#"
id: smith2026
class: monograph
titel: A Book
"#;
let err = parse(yaml).unwrap_err().to_string();
assert!(
err.contains("titel"),
"expected unknown-field error mentioning `titel`, got: {err}"
);
}
#[test]
fn typo_in_class_specific_field_rejected() {
let yaml = r#"
id: smith2026
class: monograph
title: A Book
monogarph-type: book
"#;
let err = parse(yaml).unwrap_err().to_string();
assert!(
err.contains("monogarph") && err.contains("monograph"),
"expected scope-correct unknown-field error, got: {err}"
);
}
#[test]
fn class_specific_field_under_wrong_class_rejected() {
let yaml = r#"
id: smith2026
class: legal-case
title: Smith v. Jones
monograph-type: book
"#;
let err = parse(yaml).unwrap_err().to_string();
assert!(
err.contains("monograph-type") && err.contains("legal-case"),
"expected wrong-class error, got: {err}"
);
}
#[test]
fn unknown_class_round_trips() {
let yaml = r#"
id: perf2026
class: dance-performance
title: Pina
issued: "2011"
venue: Berlin
duration-minutes: 103
"#;
let r = parse(yaml).unwrap();
let u = r.unknown_class().unwrap();
assert_eq!(u.class, "dance-performance");
assert_eq!(u.fields.len(), 2);
assert!(u.fields.contains_key("venue"));
let reserialized = serde_yaml::to_string(&r).unwrap();
let r2 = parse(&reserialized).unwrap();
assert_eq!(r, r2);
}
#[test]
fn typo_in_class_value_captured_as_unknown() {
let yaml = r#"
id: smith2026
class: monogarph
title: A Book
monograph-type: book
"#;
let r = parse(yaml).unwrap();
let u = r.unknown_class().unwrap();
assert_eq!(u.class, "monogarph");
}
#[test]
fn known_class_round_trip() {
let original = InputReference {
id: RefID("av2026".to_string()),
title: Some(Title::Single("Pina".to_string())),
contributors: None,
issued: Some(EdtfString("2011".to_string())),
created: None,
note: None,
extension: ClassExtension::AudioVisual(AudioVisualFields {
runtime_minutes: Some(103),
medium: Some("film".into()),
}),
};
let yaml = serde_yaml::to_string(&original).unwrap();
assert!(
!yaml.contains("class_data"),
"wire shape must be flat: {yaml}"
);
assert!(
!yaml.contains("extension:"),
"wire shape must be flat: {yaml}"
);
let round = parse(&yaml).unwrap();
assert_eq!(original, round);
}
}