#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::locale::{GeneralTerm, TermForm};
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct BibliographyConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub article_journal: Option<ArticleJournalBibliographyConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subsequent_author_substitute: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hanging_indent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_suffix: Option<String>,
#[serde(
default = "default_separator",
skip_serializing_if = "is_default_separator"
)]
pub separator: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub suppress_period_after_url: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compound_numeric: Option<CompoundNumericConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_partitioning: Option<BibliographySortPartitioning>,
#[serde(
flatten,
default,
skip_serializing_if = "std::collections::BTreeMap::is_empty"
)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct ArticleJournalBibliographyConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub no_page_fallback: Option<ArticleJournalNoPageFallback>,
#[serde(
flatten,
default,
skip_serializing_if = "std::collections::BTreeMap::is_empty"
)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ArticleJournalNoPageFallback {
Doi,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct BibliographySortPartitioning {
pub by: BibliographyPartitionKind,
#[serde(default)]
pub mode: BibliographyPartitionMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub order: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headings: HashMap<String, BibliographyPartitionHeading>,
#[serde(
flatten,
default,
skip_serializing_if = "std::collections::BTreeMap::is_empty"
)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case", untagged)]
pub enum BibliographyPartitionHeading {
Literal {
literal: String,
},
Term {
term: GeneralTerm,
#[serde(skip_serializing_if = "Option::is_none")]
form: Option<TermForm>,
},
Localized {
localized: HashMap<String, String>,
},
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum BibliographyPartitionKind {
Script,
Language,
}
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum BibliographyPartitionMode {
#[default]
SortOnly,
Sections,
SortAndSections,
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SubsequentAuthorSubstituteRule {
#[default]
CompleteAll,
CompleteEach,
PartialEach,
PartialFirst,
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SubLabelStyle {
#[default]
Alphabetic,
Numeric,
}
pub(crate) fn default_separator() -> Option<String> {
Some(". ".to_string())
}
pub(crate) fn is_default_separator(v: &Option<String>) -> bool {
v.as_deref() == Some(". ")
}
fn default_sub_label_suffix() -> String {
")".to_string()
}
fn default_sub_delimiter() -> String {
", ".to_string()
}
fn default_subentry() -> bool {
true
}
fn default_collapse_subentries() -> bool {
false
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct CompoundNumericConfig {
#[serde(default = "default_subentry")]
pub subentry: bool,
#[serde(default = "default_collapse_subentries")]
pub collapse_subentries: bool,
#[serde(default)]
pub sub_label: SubLabelStyle,
#[serde(default = "default_sub_label_suffix")]
pub sub_label_suffix: String,
#[serde(default = "default_sub_delimiter")]
pub sub_delimiter: String,
#[serde(
flatten,
default,
skip_serializing_if = "std::collections::BTreeMap::is_empty"
)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
}
impl Default for BibliographyConfig {
fn default() -> Self {
Self {
article_journal: None,
subsequent_author_substitute: None,
subsequent_author_substitute_rule: None,
hanging_indent: None,
entry_suffix: None,
separator: default_separator(),
suppress_period_after_url: false,
custom: None,
compound_numeric: None,
sort_partitioning: None,
unknown_fields: std::collections::BTreeMap::new(),
}
}
}
impl Default for CompoundNumericConfig {
fn default() -> Self {
Self {
subentry: default_subentry(),
collapse_subentries: default_collapse_subentries(),
sub_label: SubLabelStyle::default(),
sub_label_suffix: default_sub_label_suffix(),
sub_delimiter: default_sub_delimiter(),
unknown_fields: std::collections::BTreeMap::new(),
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
#[test]
fn test_compound_numeric_config_defaults() {
let config: CompoundNumericConfig = serde_json::from_str("{}").unwrap();
assert!(config.subentry);
assert!(!config.collapse_subentries);
assert_eq!(config.sub_label, SubLabelStyle::Alphabetic);
assert_eq!(config.sub_label_suffix, ")");
assert_eq!(config.sub_delimiter, ", ");
}
#[test]
fn test_compound_numeric_config_custom() {
let json = r#"{"subentry": false, "collapse-subentries": true, "sub-label": "numeric", "sub-label-suffix": ".", "sub-delimiter": "; "}"#;
let config: CompoundNumericConfig = serde_json::from_str(json).unwrap();
assert!(!config.subentry);
assert!(config.collapse_subentries);
assert_eq!(config.sub_label, SubLabelStyle::Numeric);
assert_eq!(config.sub_label_suffix, ".");
assert_eq!(config.sub_delimiter, "; ");
}
#[test]
fn test_compound_numeric_roundtrip() {
let config = CompoundNumericConfig::default();
let json = serde_json::to_string(&config).unwrap();
let deserialized: CompoundNumericConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_bibliography_config_with_compound() {
let json = r#"{"compound-numeric": {"sub-label": "alphabetic"}}"#;
let config: BibliographyConfig = serde_json::from_str(json).unwrap();
assert!(config.compound_numeric.is_some());
}
#[test]
fn test_article_journal_no_page_fallback_deserializes() {
let json = r#"{"article-journal":{"no-page-fallback":"doi"}}"#;
let config: BibliographyConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.article_journal.and_then(|cfg| cfg.no_page_fallback),
Some(ArticleJournalNoPageFallback::Doi)
);
}
#[test]
fn test_article_journal_no_page_fallback_roundtrip() {
let config = BibliographyConfig {
article_journal: Some(ArticleJournalBibliographyConfig {
no_page_fallback: Some(ArticleJournalNoPageFallback::Doi),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: BibliographyConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_sort_partitioning_deserializes_script_sort_only() {
let json = r#"{
"sort-partitioning": {
"by": "script",
"mode": "sort-only",
"order": ["Cyrl", "Latn"],
"headings": {
"Cyrl": {"literal": "Cyrillic"}
}
}
}"#;
let config: BibliographyConfig = serde_json::from_str(json).unwrap();
let partitioning = config
.sort_partitioning
.expect("partitioning should deserialize");
assert_eq!(partitioning.by, BibliographyPartitionKind::Script);
assert_eq!(partitioning.mode, BibliographyPartitionMode::SortOnly);
assert_eq!(
partitioning.order,
vec!["Cyrl".to_string(), "Latn".to_string()]
);
assert_eq!(
partitioning.headings.get("Cyrl"),
Some(&BibliographyPartitionHeading::Literal {
literal: "Cyrillic".to_string()
})
);
}
#[test]
fn test_sort_partitioning_defaults_to_sort_only() {
let json = r#"{"sort-partitioning": {"by": "language"}}"#;
let config: BibliographyConfig = serde_json::from_str(json).unwrap();
let partitioning = config
.sort_partitioning
.expect("partitioning should deserialize");
assert_eq!(partitioning.by, BibliographyPartitionKind::Language);
assert_eq!(partitioning.mode, BibliographyPartitionMode::SortOnly);
assert!(partitioning.order.is_empty());
assert!(partitioning.headings.is_empty());
}
#[test]
fn test_sort_partitioning_roundtrip() {
let mut headings = HashMap::new();
headings.insert(
"Latn".to_string(),
BibliographyPartitionHeading::Literal {
literal: "Latin".to_string(),
},
);
let config = BibliographyConfig {
sort_partitioning: Some(BibliographySortPartitioning {
by: BibliographyPartitionKind::Script,
mode: BibliographyPartitionMode::SortAndSections,
order: vec!["Latn".to_string()],
headings,
unknown_fields: std::collections::BTreeMap::new(),
}),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: BibliographyConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_sort_partitioning_captures_unknown_fields_for_forward_compat() {
let json = r#"{"sort-partitioning": {"by": "script", "future-key": true}}"#;
let config: BibliographyConfig = serde_json::from_str(json)
.expect("unknown partitioning fields should be captured, not rejected");
let partitioning = config
.sort_partitioning
.expect("partitioning should deserialize");
assert!(partitioning.unknown_fields.contains_key("future-key"));
assert_eq!(partitioning.by, BibliographyPartitionKind::Script);
}
#[test]
fn test_bibliography_config_captures_unknown_fields_for_forward_compat() {
let json = r#"{"future-key": true}"#;
let config: BibliographyConfig = serde_json::from_str(json).unwrap();
assert!(config.unknown_fields.contains_key("future-key"));
}
#[test]
fn test_compound_numeric_captures_unknown_fields_for_forward_compat() {
let json = r#"{"sub-label": "alphabetic", "future-key": true}"#;
let config: CompoundNumericConfig = serde_json::from_str(json).unwrap();
assert!(config.unknown_fields.contains_key("future-key"));
assert_eq!(config.sub_label, SubLabelStyle::Alphabetic);
}
#[test]
fn test_article_journal_captures_unknown_fields_for_forward_compat() {
let json = r#"{"future-key": true}"#;
let config: ArticleJournalBibliographyConfig = serde_json::from_str(json).unwrap();
assert!(config.unknown_fields.contains_key("future-key"));
}
}