1use crate::locale::{GeneralTerm, GrammaticalGender, TermForm};
37use indexmap::IndexMap;
38#[cfg(feature = "schema")]
39use schemars::JsonSchema;
40use serde::{Deserialize, Deserializer, Serialize, Serializer};
41use std::borrow::Cow;
42use std::collections::{BTreeMap, HashMap};
43use std::hash::{Hash, Hasher};
44
45mod reference;
46pub(crate) mod resolution;
47
48pub(crate) use reference::locale_matches;
49pub use reference::{LocalizedTemplateSpec, TemplatePreset, TemplateReference};
50pub(crate) use resolution::{inherited_variant_context, resolve_style_template_variants};
51
52pub fn resolve_local_template_variants(
66 style: &mut crate::Style,
67) -> Result<(), crate::ResolutionError> {
68 resolution::resolve_style_template_variants(style, None)
69}
70
71pub type Template = Vec<TemplateComponent>;
73
74pub type TemplateVariants = IndexMap<TypeSelector, TemplateVariant>;
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(JsonSchema))]
80#[serde(rename_all = "kebab-case")]
81pub enum VerticalAlign {
82 Baseline,
84 Superscript,
86 Subscript,
88}
89
90#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
100#[cfg_attr(feature = "schema", derive(JsonSchema))]
101#[serde(rename_all = "kebab-case", default)]
102pub struct Rendering {
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub text_case: Option<crate::options::titles::TextCase>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub emph: Option<bool>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub quote: Option<bool>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub strong: Option<bool>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub small_caps: Option<bool>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub vertical_align: Option<VerticalAlign>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub prefix: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub suffix: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub wrap: Option<WrapConfig>,
130 #[serde(skip_serializing_if = "Option::is_none")]
133 pub suppress: Option<bool>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub initialize_with: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
139 pub name_form: Option<crate::options::contributors::NameForm>,
140 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
142 pub strip_periods: Option<bool>,
143}
144
145impl Rendering {
146 pub fn merge(&mut self, other: &Rendering) {
150 crate::merge_options!(
151 self,
152 other,
153 text_case,
154 emph,
155 quote,
156 strong,
157 small_caps,
158 vertical_align,
159 prefix,
160 suffix,
161 wrap,
162 suppress,
163 initialize_with,
164 name_form,
165 strip_periods,
166 );
167 }
168}
169
170#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
172#[cfg_attr(feature = "schema", derive(JsonSchema))]
173#[serde(rename_all = "kebab-case")]
174pub enum WrapPunctuation {
175 #[default]
176 Parentheses,
177 Brackets,
178 Quotes,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize)]
186#[cfg_attr(feature = "schema", derive(JsonSchema))]
187#[serde(rename_all = "kebab-case")]
188pub struct WrapConfig {
189 pub punctuation: WrapPunctuation,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub inner_prefix: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub inner_suffix: Option<String>,
197}
198
199impl<'de> serde::Deserialize<'de> for WrapConfig {
200 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
201 struct WrapConfigVisitor;
202
203 impl<'de> serde::de::Visitor<'de> for WrapConfigVisitor {
204 type Value = WrapConfig;
205
206 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
207 write!(
208 f,
209 "a wrap punctuation string or a mapping with a 'punctuation' key"
210 )
211 }
212
213 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WrapConfig, E> {
214 let punctuation = match v {
215 "parentheses" => WrapPunctuation::Parentheses,
216 "brackets" => WrapPunctuation::Brackets,
217 "quotes" => WrapPunctuation::Quotes,
218 other => {
219 return Err(E::unknown_variant(
220 other,
221 &["parentheses", "brackets", "quotes"],
222 ));
223 }
224 };
225 Ok(WrapConfig {
226 punctuation,
227 inner_prefix: None,
228 inner_suffix: None,
229 })
230 }
231
232 fn visit_map<A: serde::de::MapAccess<'de>>(
233 self,
234 mut map: A,
235 ) -> Result<WrapConfig, A::Error> {
236 let mut punctuation: Option<WrapPunctuation> = None;
237 let mut inner_prefix: Option<String> = None;
238 let mut inner_suffix: Option<String> = None;
239
240 while let Some(key) = map.next_key::<String>()? {
241 match key.as_str() {
242 "punctuation" => {
243 punctuation = Some(map.next_value()?);
244 }
245 "inner-prefix" => {
246 inner_prefix = Some(map.next_value()?);
247 }
248 "inner-suffix" => {
249 inner_suffix = Some(map.next_value()?);
250 }
251 other => {
252 return Err(serde::de::Error::unknown_field(
253 other,
254 &["punctuation", "inner-prefix", "inner-suffix"],
255 ));
256 }
257 }
258 }
259
260 let punctuation =
261 punctuation.ok_or_else(|| serde::de::Error::missing_field("punctuation"))?;
262 Ok(WrapConfig {
263 punctuation,
264 inner_prefix,
265 inner_suffix,
266 })
267 }
268 }
269
270 deserializer.deserialize_any(WrapConfigVisitor)
271 }
272}
273
274impl From<WrapPunctuation> for WrapConfig {
275 fn from(punctuation: WrapPunctuation) -> Self {
276 WrapConfig {
277 punctuation,
278 inner_prefix: None,
279 inner_suffix: None,
280 }
281 }
282}
283
284pub const VALID_TYPE_NAMES: &[&str] = &[
288 "book",
289 "manual",
290 "report",
291 "thesis",
292 "webpage",
293 "map",
294 "post",
295 "interview",
296 "manuscript",
297 "personal-communication",
298 "document",
299 "chapter",
300 "entry-dictionary",
301 "paper-conference",
302 "article-journal",
303 "article-magazine",
304 "article-newspaper",
305 "broadcast",
306 "motion-picture",
307 "collection",
308 "legal-case",
309 "statute",
310 "treaty",
311 "hearing",
312 "regulation",
313 "brief",
314 "classic",
315 "patent",
316 "dataset",
317 "standard",
318 "software",
319 "all",
321 "default",
322];
323
324pub fn validate_type_name(s: &str) -> bool {
330 let normalized = s.replace('_', "-");
331 VALID_TYPE_NAMES.iter().any(|&known| known == normalized)
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Hash)]
337#[cfg_attr(feature = "schema", derive(JsonSchema))]
338pub enum TypeSelector {
339 Single(String),
340 Multiple(Vec<String>),
341}
342
343impl Serialize for TypeSelector {
344 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
345 where
346 S: serde::Serializer,
347 {
348 serializer.serialize_str(&self.to_string())
349 }
350}
351
352impl<'de> Deserialize<'de> for TypeSelector {
353 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354 where
355 D: serde::Deserializer<'de>,
356 {
357 struct Visitor;
358 impl<'de> serde::de::Visitor<'de> for Visitor {
359 type Value = TypeSelector;
360
361 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
362 formatter.write_str("a string or a sequence of strings")
363 }
364
365 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
366 where
367 E: serde::de::Error,
368 {
369 v.parse().map_err(E::custom)
370 }
371
372 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
373 where
374 A: serde::de::SeqAccess<'de>,
375 {
376 let mut types = Vec::new();
377 while let Some(t) = seq.next_element::<String>()? {
378 types.push(t);
379 }
380 if types.len() == 1 {
381 Ok(TypeSelector::Single(types.remove(0)))
382 } else {
383 Ok(TypeSelector::Multiple(types))
384 }
385 }
386 }
387 deserializer.deserialize_any(Visitor)
388 }
389}
390
391impl std::fmt::Display for TypeSelector {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 match self {
394 TypeSelector::Single(s) => write!(f, "{s}"),
395 TypeSelector::Multiple(types) => write!(f, "{}", types.join(",")),
396 }
397 }
398}
399
400impl std::str::FromStr for TypeSelector {
401 type Err = std::convert::Infallible;
402
403 fn from_str(s: &str) -> Result<Self, Self::Err> {
404 if s.contains(',') {
405 Ok(TypeSelector::Multiple(
406 s.split(',').map(|t| t.trim().to_string()).collect(),
407 ))
408 } else {
409 Ok(TypeSelector::Single(s.to_string()))
410 }
411 }
412}
413
414impl TypeSelector {
415 pub fn matches(&self, ref_type: &str) -> bool {
423 let normalized_ref = ref_type.replace('_', "-");
424 let base_ref = normalized_ref
425 .split_once('+')
426 .map(|(base, _)| base)
427 .unwrap_or(&normalized_ref);
428 let eq = |s: &str| -> bool {
429 s == ref_type
430 || s.replace('_', "-") == normalized_ref
431 || s.replace('_', "-") == base_ref
432 || s == "all"
433 || (s == "default" && ref_type == "default")
434 };
435 match self {
436 TypeSelector::Single(s) => eq(s),
437 TypeSelector::Multiple(types) => types.iter().any(|t| eq(t)),
438 }
439 }
440
441 pub fn unknown_type_names(&self) -> Vec<&str> {
446 match self {
447 TypeSelector::Single(s) => {
448 if validate_type_name(s) {
449 vec![]
450 } else {
451 vec![s.as_str()]
452 }
453 }
454 TypeSelector::Multiple(types) => types
455 .iter()
456 .filter(|s| !validate_type_name(s))
457 .map(|s| s.as_str())
458 .collect(),
459 }
460 }
461}
462
463#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
467#[cfg_attr(feature = "schema", derive(JsonSchema))]
468#[serde(untagged)]
469#[non_exhaustive]
470pub enum TemplateComponent {
471 Contributor(TemplateContributor),
472 Date(TemplateDate),
473 Title(TemplateTitle),
474 Number(TemplateNumber),
475 Variable(TemplateVariable),
476 Group(TemplateGroup),
477 Term(TemplateTerm),
478}
479
480impl Default for TemplateComponent {
481 fn default() -> Self {
482 TemplateComponent::Variable(TemplateVariable::default())
483 }
484}
485
486impl TemplateComponent {
487 pub fn rendering(&self) -> &Rendering {
491 crate::dispatch_component!(self, |inner| &inner.rendering)
492 }
493
494 pub fn rendering_mut(&mut self) -> &mut Rendering {
499 crate::dispatch_component!(self, |inner| &mut inner.rendering)
500 }
501}
502
503#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
505#[cfg_attr(feature = "schema", derive(JsonSchema))]
506#[serde(untagged)]
507pub enum TemplateVariant {
508 Full(Vec<TemplateComponent>),
510 Diff(TemplateVariantDiff),
512}
513
514impl TemplateVariant {
515 #[must_use]
517 pub fn as_template(&self) -> Option<&[TemplateComponent]> {
518 match self {
519 Self::Full(template) => Some(template.as_slice()),
520 Self::Diff(_) => None,
521 }
522 }
523
524 pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
526 match self {
527 Self::Full(template) => Some(template),
528 Self::Diff(_) => None,
529 }
530 }
531
532 #[must_use]
534 pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
535 match self {
536 Self::Full(template) => Some(template),
537 Self::Diff(_) => None,
538 }
539 }
540}
541
542impl From<Vec<TemplateComponent>> for TemplateVariant {
543 fn from(template: Vec<TemplateComponent>) -> Self {
544 Self::Full(template)
545 }
546}
547
548#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
550#[cfg_attr(feature = "schema", derive(JsonSchema))]
551#[serde(rename_all = "kebab-case", deny_unknown_fields)]
552pub struct TemplateVariantDiff {
553 #[serde(skip_serializing_if = "Option::is_none")]
555 pub extends: Option<TypeSelector>,
556 #[serde(skip_serializing_if = "Vec::is_empty", default)]
558 pub modify: Vec<TemplateModifyOperation>,
559 #[serde(skip_serializing_if = "Vec::is_empty", default)]
561 pub remove: Vec<TemplateRemoveOperation>,
562 #[serde(skip_serializing_if = "Vec::is_empty", default)]
564 pub add: Vec<TemplateAddOperation>,
565}
566
567#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
569#[cfg_attr(feature = "schema", derive(JsonSchema))]
570#[serde(transparent)]
571pub struct TemplateComponentSelector {
572 pub fields: BTreeMap<String, serde_json::Value>,
574}
575
576impl TemplateComponentSelector {
577 #[must_use]
579 pub fn is_empty(&self) -> bool {
580 self.fields.is_empty()
581 }
582
583 #[must_use]
585 pub fn matches(&self, component: &TemplateComponent) -> bool {
586 let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
587 else {
588 return false;
589 };
590
591 self.fields.iter().all(|(key, expected)| {
592 component_fields
593 .get(key)
594 .is_some_and(|actual| selector_value_matches(expected, actual))
595 })
596 }
597}
598
599fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
600 match (expected, actual) {
601 (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
602 expected_fields.iter().all(|(key, expected_value)| {
603 actual_fields.get(key).is_some_and(|actual_value| {
604 selector_value_matches(expected_value, actual_value)
605 })
606 })
607 }
608 (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
609 expected_items.len() == actual_items.len()
610 && expected_items.iter().zip(actual_items.iter()).all(
611 |(expected_item, actual_item)| {
612 selector_value_matches(expected_item, actual_item)
613 },
614 )
615 }
616 _ => expected == actual,
617 }
618}
619
620#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
622#[cfg_attr(feature = "schema", derive(JsonSchema))]
623#[serde(rename_all = "kebab-case", deny_unknown_fields)]
624pub struct TemplateModifyOperation {
625 #[serde(rename = "match")]
627 pub match_selector: TemplateComponentSelector,
628 #[serde(skip_serializing_if = "Option::is_none")]
630 pub label_form: Option<LabelForm>,
631 #[serde(flatten, default)]
633 pub rendering: Rendering,
634}
635
636#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
638#[cfg_attr(feature = "schema", derive(JsonSchema))]
639#[serde(rename_all = "kebab-case", deny_unknown_fields)]
640pub struct TemplateRemoveOperation {
641 #[serde(rename = "match")]
643 pub match_selector: TemplateComponentSelector,
644}
645
646#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
648#[cfg_attr(feature = "schema", derive(JsonSchema))]
649#[serde(rename_all = "kebab-case", deny_unknown_fields)]
650pub struct TemplateAddOperation {
651 #[serde(skip_serializing_if = "Option::is_none")]
653 pub before: Option<TemplateComponentSelector>,
654 #[serde(skip_serializing_if = "Option::is_none")]
656 pub after: Option<TemplateComponentSelector>,
657 pub component: TemplateComponent,
659}
660
661#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
663#[cfg_attr(feature = "schema", derive(JsonSchema))]
664#[serde(rename_all = "kebab-case")]
665pub struct RoleLabel {
666 pub term: String,
668 #[serde(default)]
670 pub form: RoleLabelForm,
671 #[serde(default)]
673 pub placement: LabelPlacement,
674}
675
676#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
678#[cfg_attr(feature = "schema", derive(JsonSchema))]
679#[serde(rename_all = "kebab-case")]
680pub enum RoleLabelForm {
681 #[default]
682 Short,
683 Long,
684}
685
686#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
688#[cfg_attr(feature = "schema", derive(JsonSchema))]
689#[serde(rename_all = "kebab-case")]
690pub enum LabelPlacement {
691 Prefix,
692 #[default]
693 Suffix,
694}
695
696#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
698#[cfg_attr(feature = "schema", derive(JsonSchema))]
699#[serde(rename_all = "kebab-case", deny_unknown_fields)]
700pub struct TemplateContributor {
701 pub contributor: ContributorRole,
703 pub form: ContributorForm,
705 #[serde(skip_serializing_if = "Option::is_none")]
707 pub label: Option<RoleLabel>,
708 #[serde(skip_serializing_if = "Option::is_none")]
711 pub name_order: Option<NameOrder>,
712 #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
714 pub name_form: Option<crate::options::contributors::NameForm>,
715 #[serde(skip_serializing_if = "Option::is_none")]
717 pub delimiter: Option<String>,
718 #[serde(skip_serializing_if = "Option::is_none")]
720 pub sort_separator: Option<String>,
721 #[serde(skip_serializing_if = "Option::is_none")]
723 pub shorten: Option<crate::options::ShortenListOptions>,
724 #[serde(skip_serializing_if = "Option::is_none")]
727 pub and: Option<crate::options::AndOptions>,
728 #[serde(flatten, default)]
729 pub rendering: Rendering,
730 #[serde(skip_serializing_if = "Option::is_none")]
732 pub links: Option<crate::options::LinksConfig>,
733 #[serde(skip_serializing_if = "Option::is_none")]
735 pub gender: Option<GrammaticalGender>,
736
737 #[serde(skip_serializing_if = "Option::is_none")]
739 pub custom: Option<HashMap<String, serde_json::Value>>,
740}
741
742#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
744#[cfg_attr(feature = "schema", derive(JsonSchema))]
745#[serde(rename_all = "kebab-case")]
746pub enum NameOrder {
747 GivenFirst,
749 #[default]
751 FamilyFirst,
752 FamilyFirstOnly,
754}
755
756#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
758#[cfg_attr(feature = "schema", derive(JsonSchema))]
759#[serde(rename_all = "kebab-case")]
760pub enum ContributorForm {
761 #[default]
762 Long,
763 Short,
764 FamilyOnly,
765 Verb,
766 VerbShort,
767}
768
769crate::str_enum! {
770 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
772 pub enum ContributorRole {
773 #[default] Author = "author",
774 Chair = "chair",
775 Editor = "editor",
776 Translator = "translator",
777 Director = "director",
778 Publisher = "publisher",
779 Recipient = "recipient",
780 Interviewer = "interviewer",
781 Interviewee = "interviewee",
782 Guest = "guest",
783 Inventor = "inventor",
784 Counsel = "counsel",
785 Composer = "composer",
786 CollectionEditor = "collection-editor",
787 ContainerAuthor = "container-author",
788 EditorialDirector = "editorial-director",
789 TextualEditor = "textual-editor",
790 Illustrator = "illustrator",
791 OriginalAuthor = "original-author",
792 ReviewedAuthor = "reviewed-author"
793 }
794}
795
796#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
798#[cfg_attr(feature = "schema", derive(JsonSchema))]
799#[serde(rename_all = "kebab-case", deny_unknown_fields)]
800pub struct TemplateDate {
801 pub date: DateVariable,
802 pub form: DateForm,
803 #[serde(skip_serializing_if = "Option::is_none")]
805 pub fallback: Option<Vec<TemplateComponent>>,
806 #[serde(flatten, default)]
807 pub rendering: Rendering,
808 #[serde(skip_serializing_if = "Option::is_none")]
810 pub links: Option<crate::options::LinksConfig>,
811
812 #[serde(skip_serializing_if = "Option::is_none")]
814 pub custom: Option<HashMap<String, serde_json::Value>>,
815}
816
817#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
819#[cfg_attr(feature = "schema", derive(JsonSchema))]
820#[serde(rename_all = "kebab-case")]
821pub enum DateVariable {
822 #[default]
823 Issued,
824 Accessed,
825 OriginalPublished,
826 Submitted,
827 EventDate,
828}
829
830crate::str_enum! {
831 #[derive(Debug, Default, Clone, PartialEq)]
833 pub enum DateForm {
834 #[default]
835 Year = "year",
836 YearMonth = "year-month",
837 Full = "full",
838 MonthDay = "month-day",
839 YearMonthDay = "year-month-day",
840 DayMonthAbbrYear = "day-month-abbr-year",
841 MonthAbbrDayYear = "month-abbr-day-year"
843 }
844}
845
846#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
848#[cfg_attr(feature = "schema", derive(JsonSchema))]
849#[serde(rename_all = "kebab-case", deny_unknown_fields)]
850pub struct TemplateTitle {
851 pub title: TitleType,
852 #[serde(skip_serializing_if = "Option::is_none")]
853 pub form: Option<TitleForm>,
854 #[serde(skip_serializing_if = "Option::is_none")]
859 pub disambiguate_only: Option<bool>,
860 #[serde(flatten, default)]
861 pub rendering: Rendering,
862 #[serde(skip_serializing_if = "Option::is_none")]
864 pub links: Option<crate::options::LinksConfig>,
865
866 #[serde(skip_serializing_if = "Option::is_none")]
868 pub custom: Option<HashMap<String, serde_json::Value>>,
869}
870
871#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
873#[cfg_attr(feature = "schema", derive(JsonSchema))]
874#[serde(rename_all = "kebab-case")]
875#[non_exhaustive]
876pub enum TitleType {
877 #[default]
879 Primary,
880 ContainerTitle,
882 ParentMonograph,
884 ParentSerial,
886 CollectionTitle,
888}
889
890#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
892#[cfg_attr(feature = "schema", derive(JsonSchema))]
893#[serde(rename_all = "kebab-case")]
894pub enum TitleForm {
895 Short,
896 #[default]
897 Long,
898}
899
900#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
902#[cfg_attr(feature = "schema", derive(JsonSchema))]
903#[serde(rename_all = "kebab-case", deny_unknown_fields)]
904pub struct TemplateNumber {
905 pub number: NumberVariable,
906 #[serde(skip_serializing_if = "Option::is_none")]
907 pub form: Option<NumberForm>,
908 #[serde(skip_serializing_if = "Option::is_none")]
909 pub label_form: Option<LabelForm>,
910 #[serde(skip_serializing_if = "Option::is_none")]
913 pub show_with_locator: Option<bool>,
914 #[serde(flatten)]
915 pub rendering: Rendering,
916 #[serde(skip_serializing_if = "Option::is_none")]
918 pub links: Option<crate::options::LinksConfig>,
919 #[serde(skip_serializing_if = "Option::is_none")]
921 pub gender: Option<GrammaticalGender>,
922
923 #[serde(skip_serializing_if = "Option::is_none")]
925 pub custom: Option<HashMap<String, serde_json::Value>>,
926}
927
928#[derive(Debug, Default, Clone)]
935#[non_exhaustive]
936pub enum NumberVariable {
937 #[default]
938 Volume,
939 Issue,
940 Pages,
941 Edition,
942 ChapterNumber,
943 CollectionNumber,
944 NumberOfPages,
945 NumberOfVolumes,
946 CitationNumber,
947 FirstReferenceNoteNumber,
951 CitationLabel,
952 Number,
953 DocketNumber,
954 PatentNumber,
955 StandardNumber,
956 ReportNumber,
957 PartNumber,
958 SupplementNumber,
959 PrintingNumber,
960 Custom(String),
962}
963
964impl NumberVariable {
965 #[must_use]
967 pub fn as_key(&self) -> Cow<'_, str> {
968 match self {
969 Self::Volume => Cow::Borrowed("volume"),
970 Self::Issue => Cow::Borrowed("issue"),
971 Self::Pages => Cow::Borrowed("pages"),
972 Self::Edition => Cow::Borrowed("edition"),
973 Self::ChapterNumber => Cow::Borrowed("chapter-number"),
974 Self::CollectionNumber => Cow::Borrowed("collection-number"),
975 Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
976 Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
977 Self::CitationNumber => Cow::Borrowed("citation-number"),
978 Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
979 Self::CitationLabel => Cow::Borrowed("citation-label"),
980 Self::Number => Cow::Borrowed("number"),
981 Self::DocketNumber => Cow::Borrowed("docket-number"),
982 Self::PatentNumber => Cow::Borrowed("patent-number"),
983 Self::StandardNumber => Cow::Borrowed("standard-number"),
984 Self::ReportNumber => Cow::Borrowed("report-number"),
985 Self::PartNumber => Cow::Borrowed("part-number"),
986 Self::SupplementNumber => Cow::Borrowed("supplement-number"),
987 Self::PrintingNumber => Cow::Borrowed("printing-number"),
988 Self::Custom(value) => normalize_kind_key(value)
989 .map(Cow::Owned)
990 .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
991 }
992 }
993
994 fn from_key(value: &str) -> Result<Self, String> {
995 let canonical = normalize_kind_key(value)
996 .ok_or_else(|| "number variable must not be empty".to_string())?;
997 Ok(match canonical.as_str() {
998 "volume" => Self::Volume,
999 "issue" => Self::Issue,
1000 "pages" => Self::Pages,
1001 "edition" => Self::Edition,
1002 "chapter-number" => Self::ChapterNumber,
1003 "collection-number" => Self::CollectionNumber,
1004 "number-of-pages" => Self::NumberOfPages,
1005 "number-of-volumes" => Self::NumberOfVolumes,
1006 "citation-number" => Self::CitationNumber,
1007 "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1008 "citation-label" => Self::CitationLabel,
1009 "number" => Self::Number,
1010 "docket-number" => Self::DocketNumber,
1011 "patent-number" => Self::PatentNumber,
1012 "standard-number" => Self::StandardNumber,
1013 "report-number" => Self::ReportNumber,
1014 "part-number" => Self::PartNumber,
1015 "supplement-number" => Self::SupplementNumber,
1016 "printing-number" => Self::PrintingNumber,
1017 _ => Self::Custom(canonical),
1018 })
1019 }
1020}
1021
1022impl PartialEq for NumberVariable {
1023 fn eq(&self, other: &Self) -> bool {
1024 self.as_key().as_ref() == other.as_key().as_ref()
1025 }
1026}
1027
1028impl Eq for NumberVariable {}
1029
1030impl Hash for NumberVariable {
1031 fn hash<H: Hasher>(&self, state: &mut H) {
1032 self.as_key().as_ref().hash(state);
1033 }
1034}
1035
1036impl Serialize for NumberVariable {
1037 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1038 where
1039 S: Serializer,
1040 {
1041 serializer.serialize_str(self.as_key().as_ref())
1042 }
1043}
1044
1045impl<'de> Deserialize<'de> for NumberVariable {
1046 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1047 where
1048 D: Deserializer<'de>,
1049 {
1050 let value = String::deserialize(deserializer)?;
1051 Self::from_key(&value).map_err(serde::de::Error::custom)
1052 }
1053}
1054
1055#[cfg(feature = "schema")]
1056impl JsonSchema for NumberVariable {
1057 fn schema_name() -> std::borrow::Cow<'static, str> {
1058 "NumberVariable".into()
1059 }
1060
1061 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1062 schemars::json_schema!({
1063 "type": "string",
1064 "description": "Known number variable keyword or custom kebab-case identifier."
1065 })
1066 }
1067}
1068
1069#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1071#[cfg_attr(feature = "schema", derive(JsonSchema))]
1072#[serde(rename_all = "lowercase")]
1073pub enum NumberForm {
1074 #[default]
1075 Numeric,
1076 Ordinal,
1077 Roman,
1078}
1079
1080fn normalize_kind_key(value: &str) -> Option<String> {
1081 let mut normalized = String::new();
1082 let mut pending_dash = false;
1083
1084 for ch in value.trim().chars() {
1085 if ch.is_ascii_alphanumeric() {
1086 if pending_dash && !normalized.is_empty() {
1087 normalized.push('-');
1088 }
1089 normalized.push(ch.to_ascii_lowercase());
1090 pending_dash = false;
1091 } else if !normalized.is_empty() {
1092 pending_dash = true;
1093 }
1094 }
1095
1096 if normalized.is_empty() {
1097 None
1098 } else {
1099 Some(normalized)
1100 }
1101}
1102
1103#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1105#[cfg_attr(feature = "schema", derive(JsonSchema))]
1106#[serde(rename_all = "kebab-case")]
1107pub enum LabelForm {
1108 Long,
1109 #[default]
1110 Short,
1111 Symbol,
1112}
1113
1114#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1116#[cfg_attr(feature = "schema", derive(JsonSchema))]
1117#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1118pub struct TemplateVariable {
1119 pub variable: SimpleVariable,
1120 #[serde(flatten)]
1121 pub rendering: Rendering,
1122 #[serde(skip_serializing_if = "Option::is_none")]
1124 pub links: Option<crate::options::LinksConfig>,
1125
1126 #[serde(skip_serializing_if = "Option::is_none")]
1128 pub custom: Option<HashMap<String, serde_json::Value>>,
1129}
1130
1131#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1138#[cfg_attr(feature = "schema", derive(JsonSchema))]
1139#[serde(rename_all = "kebab-case")]
1140#[non_exhaustive]
1141pub enum SimpleVariable {
1142 #[default]
1143 Doi,
1144 Isbn,
1145 Issn,
1146 Url,
1147 Pmid,
1148 Pmcid,
1149 Abstract,
1150 Note,
1151 Annote,
1152 Keyword,
1153 Genre,
1154 Medium,
1155 Source,
1156 Status,
1157 Archive,
1158 ArchiveLocation,
1159 ArchiveName,
1160 ArchivePlace,
1161 ArchiveCollection,
1162 ArchiveCollectionId,
1163 ArchiveSeries,
1164 ArchiveBox,
1165 ArchiveFolder,
1166 ArchiveItem,
1167 ArchiveUrl,
1168 EprintId,
1169 EprintServer,
1170 EprintClass,
1171 Publisher,
1172 PublisherPlace,
1173 OriginalPublisher,
1174 OriginalPublisherPlace,
1175 EventPlace,
1176 Dimensions,
1177 Scale,
1178 Version,
1179 Locator,
1180 ContainerTitleShort,
1181 Authority,
1182 Code,
1183 Reporter,
1184 Page,
1185 Section,
1186 Volume,
1187 Number,
1188 DocketNumber,
1189 PatentNumber,
1190 StandardNumber,
1191 ReportNumber,
1192 AdsBibcode,
1193}
1194
1195#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1197#[cfg_attr(feature = "schema", derive(JsonSchema))]
1198#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1199pub struct TemplateTerm {
1200 pub term: GeneralTerm,
1202 #[serde(skip_serializing_if = "Option::is_none")]
1204 pub form: Option<TermForm>,
1205 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub gender: Option<GrammaticalGender>,
1208 #[serde(flatten, default)]
1209 pub rendering: Rendering,
1210
1211 #[serde(skip_serializing_if = "Option::is_none")]
1213 pub custom: Option<HashMap<String, serde_json::Value>>,
1214}
1215
1216#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1219#[cfg_attr(feature = "schema", derive(JsonSchema))]
1220#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1221pub struct TemplateGroup {
1222 pub group: Vec<TemplateComponent>,
1223 #[serde(skip_serializing_if = "Option::is_none")]
1224 pub delimiter: Option<DelimiterPunctuation>,
1225 #[serde(flatten, default)]
1226 pub rendering: Rendering,
1227
1228 #[serde(skip_serializing_if = "Option::is_none")]
1230 pub custom: Option<HashMap<String, serde_json::Value>>,
1231}
1232
1233#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1235#[serde(rename_all = "kebab-case")]
1236pub enum DelimiterPunctuation {
1237 #[default]
1238 Comma,
1239 Semicolon,
1240 Period,
1241 Colon,
1242 Ampersand,
1243 VerticalLine,
1244 Slash,
1245 Hyphen,
1246 Space,
1247 None,
1248 #[serde(untagged)]
1250 Custom(String),
1251}
1252
1253#[cfg(feature = "schema")]
1254impl JsonSchema for DelimiterPunctuation {
1255 fn schema_name() -> std::borrow::Cow<'static, str> {
1256 "DelimiterPunctuation".into()
1257 }
1258
1259 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1260 schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1261 }
1262}
1263
1264impl DelimiterPunctuation {
1265 pub fn to_string_with_space(&self) -> String {
1269 match self {
1270 Self::Comma => ", ".to_string(),
1271 Self::Semicolon => "; ".to_string(),
1272 Self::Period => ". ".to_string(),
1273 Self::Colon => ": ".to_string(),
1274 Self::Ampersand => " & ".to_string(),
1275 Self::VerticalLine => " | ".to_string(),
1276 Self::Slash => "/".to_string(),
1277 Self::Hyphen => "-".to_string(),
1278 Self::Space => " ".to_string(),
1279 Self::None => "".to_string(),
1280 Self::Custom(s) => s.clone(),
1281 }
1282 }
1283
1284 pub fn from_csl_string(s: &str) -> Self {
1289 if s == " " {
1290 return Self::Space;
1291 }
1292
1293 let trimmed = s.trim();
1294 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1295 return Self::None;
1296 }
1297
1298 match trimmed {
1299 "," => Self::Comma,
1300 ";" => Self::Semicolon,
1301 "." => Self::Period,
1302 ":" => Self::Colon,
1303 "&" => Self::Ampersand,
1304 "|" => Self::VerticalLine,
1305 "/" => Self::Slash,
1306 "-" => Self::Hyphen,
1307 _ => Self::Custom(s.to_string()),
1308 }
1309 }
1310}
1311
1312#[cfg(test)]
1313#[allow(
1314 clippy::unwrap_used,
1315 clippy::expect_used,
1316 clippy::panic,
1317 clippy::indexing_slicing,
1318 clippy::todo,
1319 clippy::unimplemented,
1320 clippy::unreachable,
1321 clippy::get_unwrap,
1322 reason = "Panicking is acceptable and often desired in tests."
1323)]
1324mod tests {
1325 use super::*;
1326
1327 #[test]
1328 fn test_contributor_deserialization() {
1329 let yaml = r#"
1330contributor: author
1331form: long
1332"#;
1333 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1334 assert_eq!(comp.contributor, ContributorRole::Author);
1335 assert_eq!(comp.form, ContributorForm::Long);
1336 }
1337
1338 #[test]
1339 fn test_template_component_untagged() {
1340 let yaml = r#"
1341- contributor: author
1342 form: short
1343- date: issued
1344 form: year
1345- title: primary
1346"#;
1347 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1348 assert_eq!(components.len(), 3);
1349
1350 match &components[0] {
1351 TemplateComponent::Contributor(c) => {
1352 assert_eq!(c.contributor, ContributorRole::Author);
1353 }
1354 _ => panic!("Expected Contributor"),
1355 }
1356
1357 match &components[1] {
1358 TemplateComponent::Date(d) => {
1359 assert_eq!(d.date, DateVariable::Issued);
1360 }
1361 _ => panic!("Expected Date"),
1362 }
1363 }
1364
1365 #[test]
1366 fn test_flattened_rendering() {
1367 let yaml = r#"
1369- title: parent-monograph
1370 prefix: "In "
1371 emph: true
1372- date: issued
1373 form: year
1374 wrap: parentheses
1375"#;
1376 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1377 assert_eq!(components.len(), 2);
1378
1379 match &components[0] {
1380 TemplateComponent::Title(t) => {
1381 assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1382 assert_eq!(t.rendering.emph, Some(true));
1383 }
1384 _ => panic!("Expected Title"),
1385 }
1386
1387 match &components[1] {
1388 TemplateComponent::Date(d) => {
1389 assert_eq!(
1390 d.rendering.wrap,
1391 Some(WrapConfig {
1392 punctuation: WrapPunctuation::Parentheses,
1393 inner_prefix: None,
1394 inner_suffix: None,
1395 })
1396 );
1397 }
1398 _ => panic!("Expected Date"),
1399 }
1400 }
1401
1402 #[test]
1403 fn test_number_variable_custom_normalizes_manual_construction() {
1404 let number = NumberVariable::Custom("Reel Label".to_string());
1405
1406 assert_eq!(number.as_key(), "reel-label");
1407 assert_eq!(
1408 number,
1409 serde_yaml::from_str::<NumberVariable>("reel-label")
1410 .expect("custom number variable should parse")
1411 );
1412 assert_eq!(
1413 serde_json::to_string(&number).expect("custom number variable should serialize"),
1414 "\"reel-label\""
1415 );
1416 }
1417
1418 #[test]
1419 fn test_contributor_with_wrap() {
1420 let yaml = r#"
1421contributor: publisher
1422form: short
1423wrap: parentheses
1424"#;
1425 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1426 assert_eq!(comp.contributor, ContributorRole::Publisher);
1427 assert_eq!(
1428 comp.rendering.wrap,
1429 Some(WrapConfig {
1430 punctuation: WrapPunctuation::Parentheses,
1431 inner_prefix: None,
1432 inner_suffix: None,
1433 })
1434 );
1435 }
1436
1437 #[test]
1438 fn test_variable_deserialization() {
1439 let yaml = "variable: publisher\n";
1441 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1442 match comp {
1443 TemplateComponent::Variable(v) => {
1444 assert_eq!(v.variable, SimpleVariable::Publisher);
1445 }
1446 _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1447 }
1448 }
1449
1450 #[test]
1451 fn test_variable_array_parsing() {
1452 let yaml = r#"
1453- variable: doi
1454 prefix: "https://doi.org/"
1455- variable: publisher
1456"#;
1457 let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1458 assert_eq!(comps.len(), 2);
1459 match &comps[0] {
1460 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1461 _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1462 }
1463 match &comps[1] {
1464 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1465 _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1466 }
1467 }
1468
1469 #[test]
1470 fn test_type_selector_default_only_matches_default_context() {
1471 let selector = TypeSelector::Single("default".to_string());
1472 assert!(selector.matches("default"));
1473 assert!(!selector.matches("article-journal"));
1474
1475 let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1476 assert!(mixed.matches("default"));
1477 assert!(mixed.matches("chapter"));
1478 assert!(!mixed.matches("book"));
1479 }
1480
1481 #[test]
1482 fn test_template_component_selector_matches_nested_partial_group() {
1483 let component: TemplateComponent = serde_yaml::from_str(
1484 r#"
1485delimiter: ""
1486group:
1487- number: citation-number
1488 wrap:
1489 punctuation: brackets
1490- contributor: author
1491 form: long
1492"#,
1493 )
1494 .unwrap();
1495 let selector = TemplateComponentSelector {
1496 fields: BTreeMap::from([(
1497 "group".to_string(),
1498 serde_json::json!([
1499 { "number": "citation-number" },
1500 { "contributor": "author" }
1501 ]),
1502 )]),
1503 };
1504
1505 assert!(selector.matches(&component));
1506 }
1507
1508 #[test]
1509 fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1510 assert_eq!(
1511 DelimiterPunctuation::from_csl_string("none"),
1512 DelimiterPunctuation::None
1513 );
1514 assert_eq!(
1515 DelimiterPunctuation::from_csl_string(" none "),
1516 DelimiterPunctuation::None
1517 );
1518 assert_eq!(
1519 DelimiterPunctuation::from_csl_string(" "),
1520 DelimiterPunctuation::Space
1521 );
1522 assert_eq!(
1523 DelimiterPunctuation::from_csl_string(" : "),
1524 DelimiterPunctuation::Colon
1525 );
1526 }
1527}