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 "post",
294 "interview",
295 "manuscript",
296 "personal-communication",
297 "document",
298 "chapter",
299 "paper-conference",
300 "article-journal",
301 "article-magazine",
302 "article-newspaper",
303 "broadcast",
304 "motion-picture",
305 "collection",
306 "legal-case",
307 "statute",
308 "treaty",
309 "hearing",
310 "regulation",
311 "brief",
312 "classic",
313 "patent",
314 "dataset",
315 "standard",
316 "software",
317 "all",
319 "default",
320];
321
322pub fn validate_type_name(s: &str) -> bool {
328 let normalized = s.replace('_', "-");
329 VALID_TYPE_NAMES.iter().any(|&known| known == normalized)
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Hash)]
335#[cfg_attr(feature = "schema", derive(JsonSchema))]
336pub enum TypeSelector {
337 Single(String),
338 Multiple(Vec<String>),
339}
340
341impl Serialize for TypeSelector {
342 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
343 where
344 S: serde::Serializer,
345 {
346 serializer.serialize_str(&self.to_string())
347 }
348}
349
350impl<'de> Deserialize<'de> for TypeSelector {
351 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352 where
353 D: serde::Deserializer<'de>,
354 {
355 struct Visitor;
356 impl<'de> serde::de::Visitor<'de> for Visitor {
357 type Value = TypeSelector;
358
359 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
360 formatter.write_str("a string or a sequence of strings")
361 }
362
363 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
364 where
365 E: serde::de::Error,
366 {
367 v.parse().map_err(E::custom)
368 }
369
370 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
371 where
372 A: serde::de::SeqAccess<'de>,
373 {
374 let mut types = Vec::new();
375 while let Some(t) = seq.next_element::<String>()? {
376 types.push(t);
377 }
378 if types.len() == 1 {
379 Ok(TypeSelector::Single(types.remove(0)))
380 } else {
381 Ok(TypeSelector::Multiple(types))
382 }
383 }
384 }
385 deserializer.deserialize_any(Visitor)
386 }
387}
388
389impl std::fmt::Display for TypeSelector {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 TypeSelector::Single(s) => write!(f, "{s}"),
393 TypeSelector::Multiple(types) => write!(f, "{}", types.join(",")),
394 }
395 }
396}
397
398impl std::str::FromStr for TypeSelector {
399 type Err = std::convert::Infallible;
400
401 fn from_str(s: &str) -> Result<Self, Self::Err> {
402 if s.contains(',') {
403 Ok(TypeSelector::Multiple(
404 s.split(',').map(|t| t.trim().to_string()).collect(),
405 ))
406 } else {
407 Ok(TypeSelector::Single(s.to_string()))
408 }
409 }
410}
411
412impl TypeSelector {
413 pub fn matches(&self, ref_type: &str) -> bool {
421 let normalized_ref = ref_type.replace('_', "-");
422 let base_ref = normalized_ref
423 .split_once('+')
424 .map(|(base, _)| base)
425 .unwrap_or(&normalized_ref);
426 let eq = |s: &str| -> bool {
427 s == ref_type
428 || s.replace('_', "-") == normalized_ref
429 || s.replace('_', "-") == base_ref
430 || s == "all"
431 || (s == "default" && ref_type == "default")
432 };
433 match self {
434 TypeSelector::Single(s) => eq(s),
435 TypeSelector::Multiple(types) => types.iter().any(|t| eq(t)),
436 }
437 }
438
439 pub fn unknown_type_names(&self) -> Vec<&str> {
444 match self {
445 TypeSelector::Single(s) => {
446 if validate_type_name(s) {
447 vec![]
448 } else {
449 vec![s.as_str()]
450 }
451 }
452 TypeSelector::Multiple(types) => types
453 .iter()
454 .filter(|s| !validate_type_name(s))
455 .map(|s| s.as_str())
456 .collect(),
457 }
458 }
459}
460
461#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
465#[cfg_attr(feature = "schema", derive(JsonSchema))]
466#[serde(untagged)]
467#[non_exhaustive]
468pub enum TemplateComponent {
469 Contributor(TemplateContributor),
470 Date(TemplateDate),
471 Title(TemplateTitle),
472 Number(TemplateNumber),
473 Variable(TemplateVariable),
474 Group(TemplateGroup),
475 Term(TemplateTerm),
476}
477
478impl Default for TemplateComponent {
479 fn default() -> Self {
480 TemplateComponent::Variable(TemplateVariable::default())
481 }
482}
483
484impl TemplateComponent {
485 pub fn rendering(&self) -> &Rendering {
489 crate::dispatch_component!(self, |inner| &inner.rendering)
490 }
491
492 pub fn rendering_mut(&mut self) -> &mut Rendering {
497 crate::dispatch_component!(self, |inner| &mut inner.rendering)
498 }
499}
500
501#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
503#[cfg_attr(feature = "schema", derive(JsonSchema))]
504#[serde(untagged)]
505pub enum TemplateVariant {
506 Full(Vec<TemplateComponent>),
508 Diff(TemplateVariantDiff),
510}
511
512impl TemplateVariant {
513 #[must_use]
515 pub fn as_template(&self) -> Option<&[TemplateComponent]> {
516 match self {
517 Self::Full(template) => Some(template.as_slice()),
518 Self::Diff(_) => None,
519 }
520 }
521
522 pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
524 match self {
525 Self::Full(template) => Some(template),
526 Self::Diff(_) => None,
527 }
528 }
529
530 #[must_use]
532 pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
533 match self {
534 Self::Full(template) => Some(template),
535 Self::Diff(_) => None,
536 }
537 }
538}
539
540impl From<Vec<TemplateComponent>> for TemplateVariant {
541 fn from(template: Vec<TemplateComponent>) -> Self {
542 Self::Full(template)
543 }
544}
545
546#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
548#[cfg_attr(feature = "schema", derive(JsonSchema))]
549#[serde(rename_all = "kebab-case", deny_unknown_fields)]
550pub struct TemplateVariantDiff {
551 #[serde(skip_serializing_if = "Option::is_none")]
553 pub extends: Option<TypeSelector>,
554 #[serde(skip_serializing_if = "Vec::is_empty", default)]
556 pub modify: Vec<TemplateModifyOperation>,
557 #[serde(skip_serializing_if = "Vec::is_empty", default)]
559 pub remove: Vec<TemplateRemoveOperation>,
560 #[serde(skip_serializing_if = "Vec::is_empty", default)]
562 pub add: Vec<TemplateAddOperation>,
563}
564
565#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
567#[cfg_attr(feature = "schema", derive(JsonSchema))]
568#[serde(transparent)]
569pub struct TemplateComponentSelector {
570 pub fields: BTreeMap<String, serde_json::Value>,
572}
573
574impl TemplateComponentSelector {
575 #[must_use]
577 pub fn is_empty(&self) -> bool {
578 self.fields.is_empty()
579 }
580
581 #[must_use]
583 pub fn matches(&self, component: &TemplateComponent) -> bool {
584 let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
585 else {
586 return false;
587 };
588
589 self.fields.iter().all(|(key, expected)| {
590 component_fields
591 .get(key)
592 .is_some_and(|actual| selector_value_matches(expected, actual))
593 })
594 }
595}
596
597fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
598 match (expected, actual) {
599 (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
600 expected_fields.iter().all(|(key, expected_value)| {
601 actual_fields.get(key).is_some_and(|actual_value| {
602 selector_value_matches(expected_value, actual_value)
603 })
604 })
605 }
606 (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
607 expected_items.len() == actual_items.len()
608 && expected_items.iter().zip(actual_items.iter()).all(
609 |(expected_item, actual_item)| {
610 selector_value_matches(expected_item, actual_item)
611 },
612 )
613 }
614 _ => expected == actual,
615 }
616}
617
618#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
620#[cfg_attr(feature = "schema", derive(JsonSchema))]
621#[serde(rename_all = "kebab-case", deny_unknown_fields)]
622pub struct TemplateModifyOperation {
623 #[serde(rename = "match")]
625 pub match_selector: TemplateComponentSelector,
626 #[serde(skip_serializing_if = "Option::is_none")]
628 pub label_form: Option<LabelForm>,
629 #[serde(flatten, default)]
631 pub rendering: Rendering,
632}
633
634#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
636#[cfg_attr(feature = "schema", derive(JsonSchema))]
637#[serde(rename_all = "kebab-case", deny_unknown_fields)]
638pub struct TemplateRemoveOperation {
639 #[serde(rename = "match")]
641 pub match_selector: TemplateComponentSelector,
642}
643
644#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
646#[cfg_attr(feature = "schema", derive(JsonSchema))]
647#[serde(rename_all = "kebab-case", deny_unknown_fields)]
648pub struct TemplateAddOperation {
649 #[serde(skip_serializing_if = "Option::is_none")]
651 pub before: Option<TemplateComponentSelector>,
652 #[serde(skip_serializing_if = "Option::is_none")]
654 pub after: Option<TemplateComponentSelector>,
655 pub component: TemplateComponent,
657}
658
659#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
661#[cfg_attr(feature = "schema", derive(JsonSchema))]
662#[serde(rename_all = "kebab-case")]
663pub struct RoleLabel {
664 pub term: String,
666 #[serde(default)]
668 pub form: RoleLabelForm,
669 #[serde(default)]
671 pub placement: LabelPlacement,
672}
673
674#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
676#[cfg_attr(feature = "schema", derive(JsonSchema))]
677#[serde(rename_all = "kebab-case")]
678pub enum RoleLabelForm {
679 #[default]
680 Short,
681 Long,
682}
683
684#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
686#[cfg_attr(feature = "schema", derive(JsonSchema))]
687#[serde(rename_all = "kebab-case")]
688pub enum LabelPlacement {
689 Prefix,
690 #[default]
691 Suffix,
692}
693
694#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
696#[cfg_attr(feature = "schema", derive(JsonSchema))]
697#[serde(rename_all = "kebab-case", deny_unknown_fields)]
698pub struct TemplateContributor {
699 pub contributor: ContributorRole,
701 pub form: ContributorForm,
703 #[serde(skip_serializing_if = "Option::is_none")]
705 pub label: Option<RoleLabel>,
706 #[serde(skip_serializing_if = "Option::is_none")]
709 pub name_order: Option<NameOrder>,
710 #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
712 pub name_form: Option<crate::options::contributors::NameForm>,
713 #[serde(skip_serializing_if = "Option::is_none")]
715 pub delimiter: Option<String>,
716 #[serde(skip_serializing_if = "Option::is_none")]
718 pub sort_separator: Option<String>,
719 #[serde(skip_serializing_if = "Option::is_none")]
721 pub shorten: Option<crate::options::ShortenListOptions>,
722 #[serde(skip_serializing_if = "Option::is_none")]
725 pub and: Option<crate::options::AndOptions>,
726 #[serde(flatten, default)]
727 pub rendering: Rendering,
728 #[serde(skip_serializing_if = "Option::is_none")]
730 pub links: Option<crate::options::LinksConfig>,
731 #[serde(skip_serializing_if = "Option::is_none")]
733 pub gender: Option<GrammaticalGender>,
734
735 #[serde(skip_serializing_if = "Option::is_none")]
737 pub custom: Option<HashMap<String, serde_json::Value>>,
738}
739
740#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
742#[cfg_attr(feature = "schema", derive(JsonSchema))]
743#[serde(rename_all = "kebab-case")]
744pub enum NameOrder {
745 GivenFirst,
747 #[default]
749 FamilyFirst,
750 FamilyFirstOnly,
752}
753
754#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
756#[cfg_attr(feature = "schema", derive(JsonSchema))]
757#[serde(rename_all = "kebab-case")]
758pub enum ContributorForm {
759 #[default]
760 Long,
761 Short,
762 FamilyOnly,
763 Verb,
764 VerbShort,
765}
766
767crate::str_enum! {
768 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
770 pub enum ContributorRole {
771 #[default] Author = "author",
772 Chair = "chair",
773 Editor = "editor",
774 Translator = "translator",
775 Director = "director",
776 Publisher = "publisher",
777 Recipient = "recipient",
778 Interviewer = "interviewer",
779 Interviewee = "interviewee",
780 Guest = "guest",
781 Inventor = "inventor",
782 Counsel = "counsel",
783 Composer = "composer",
784 CollectionEditor = "collection-editor",
785 ContainerAuthor = "container-author",
786 EditorialDirector = "editorial-director",
787 TextualEditor = "textual-editor",
788 Illustrator = "illustrator",
789 OriginalAuthor = "original-author",
790 ReviewedAuthor = "reviewed-author"
791 }
792}
793
794#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
796#[cfg_attr(feature = "schema", derive(JsonSchema))]
797#[serde(rename_all = "kebab-case", deny_unknown_fields)]
798pub struct TemplateDate {
799 pub date: DateVariable,
800 pub form: DateForm,
801 #[serde(skip_serializing_if = "Option::is_none")]
803 pub fallback: Option<Vec<TemplateComponent>>,
804 #[serde(flatten, default)]
805 pub rendering: Rendering,
806 #[serde(skip_serializing_if = "Option::is_none")]
808 pub links: Option<crate::options::LinksConfig>,
809
810 #[serde(skip_serializing_if = "Option::is_none")]
812 pub custom: Option<HashMap<String, serde_json::Value>>,
813}
814
815#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
817#[cfg_attr(feature = "schema", derive(JsonSchema))]
818#[serde(rename_all = "kebab-case")]
819pub enum DateVariable {
820 #[default]
821 Issued,
822 Accessed,
823 OriginalPublished,
824 Submitted,
825 EventDate,
826}
827
828crate::str_enum! {
829 #[derive(Debug, Default, Clone, PartialEq)]
831 pub enum DateForm {
832 #[default]
833 Year = "year",
834 YearMonth = "year-month",
835 Full = "full",
836 MonthDay = "month-day",
837 YearMonthDay = "year-month-day",
838 DayMonthAbbrYear = "day-month-abbr-year",
839 MonthAbbrDayYear = "month-abbr-day-year"
841 }
842}
843
844#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
846#[cfg_attr(feature = "schema", derive(JsonSchema))]
847#[serde(rename_all = "kebab-case", deny_unknown_fields)]
848pub struct TemplateTitle {
849 pub title: TitleType,
850 #[serde(skip_serializing_if = "Option::is_none")]
851 pub form: Option<TitleForm>,
852 #[serde(skip_serializing_if = "Option::is_none")]
857 pub disambiguate_only: Option<bool>,
858 #[serde(flatten, default)]
859 pub rendering: Rendering,
860 #[serde(skip_serializing_if = "Option::is_none")]
862 pub links: Option<crate::options::LinksConfig>,
863
864 #[serde(skip_serializing_if = "Option::is_none")]
866 pub custom: Option<HashMap<String, serde_json::Value>>,
867}
868
869#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
871#[cfg_attr(feature = "schema", derive(JsonSchema))]
872#[serde(rename_all = "kebab-case")]
873#[non_exhaustive]
874pub enum TitleType {
875 #[default]
877 Primary,
878 ParentMonograph,
880 ParentSerial,
882}
883
884#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
886#[cfg_attr(feature = "schema", derive(JsonSchema))]
887#[serde(rename_all = "kebab-case")]
888pub enum TitleForm {
889 Short,
890 #[default]
891 Long,
892}
893
894#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
896#[cfg_attr(feature = "schema", derive(JsonSchema))]
897#[serde(rename_all = "kebab-case", deny_unknown_fields)]
898pub struct TemplateNumber {
899 pub number: NumberVariable,
900 #[serde(skip_serializing_if = "Option::is_none")]
901 pub form: Option<NumberForm>,
902 #[serde(skip_serializing_if = "Option::is_none")]
903 pub label_form: Option<LabelForm>,
904 #[serde(skip_serializing_if = "Option::is_none")]
907 pub show_with_locator: Option<bool>,
908 #[serde(flatten)]
909 pub rendering: Rendering,
910 #[serde(skip_serializing_if = "Option::is_none")]
912 pub links: Option<crate::options::LinksConfig>,
913 #[serde(skip_serializing_if = "Option::is_none")]
915 pub gender: Option<GrammaticalGender>,
916
917 #[serde(skip_serializing_if = "Option::is_none")]
919 pub custom: Option<HashMap<String, serde_json::Value>>,
920}
921
922#[derive(Debug, Default, Clone)]
929#[non_exhaustive]
930pub enum NumberVariable {
931 #[default]
932 Volume,
933 Issue,
934 Pages,
935 Edition,
936 ChapterNumber,
937 CollectionNumber,
938 NumberOfPages,
939 NumberOfVolumes,
940 CitationNumber,
941 FirstReferenceNoteNumber,
945 CitationLabel,
946 Number,
947 DocketNumber,
948 PatentNumber,
949 StandardNumber,
950 ReportNumber,
951 PartNumber,
952 SupplementNumber,
953 PrintingNumber,
954 Custom(String),
956}
957
958impl NumberVariable {
959 #[must_use]
961 pub fn as_key(&self) -> Cow<'_, str> {
962 match self {
963 Self::Volume => Cow::Borrowed("volume"),
964 Self::Issue => Cow::Borrowed("issue"),
965 Self::Pages => Cow::Borrowed("pages"),
966 Self::Edition => Cow::Borrowed("edition"),
967 Self::ChapterNumber => Cow::Borrowed("chapter-number"),
968 Self::CollectionNumber => Cow::Borrowed("collection-number"),
969 Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
970 Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
971 Self::CitationNumber => Cow::Borrowed("citation-number"),
972 Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
973 Self::CitationLabel => Cow::Borrowed("citation-label"),
974 Self::Number => Cow::Borrowed("number"),
975 Self::DocketNumber => Cow::Borrowed("docket-number"),
976 Self::PatentNumber => Cow::Borrowed("patent-number"),
977 Self::StandardNumber => Cow::Borrowed("standard-number"),
978 Self::ReportNumber => Cow::Borrowed("report-number"),
979 Self::PartNumber => Cow::Borrowed("part-number"),
980 Self::SupplementNumber => Cow::Borrowed("supplement-number"),
981 Self::PrintingNumber => Cow::Borrowed("printing-number"),
982 Self::Custom(value) => normalize_kind_key(value)
983 .map(Cow::Owned)
984 .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
985 }
986 }
987
988 fn from_key(value: &str) -> Result<Self, String> {
989 let canonical = normalize_kind_key(value)
990 .ok_or_else(|| "number variable must not be empty".to_string())?;
991 Ok(match canonical.as_str() {
992 "volume" => Self::Volume,
993 "issue" => Self::Issue,
994 "pages" => Self::Pages,
995 "edition" => Self::Edition,
996 "chapter-number" => Self::ChapterNumber,
997 "collection-number" => Self::CollectionNumber,
998 "number-of-pages" => Self::NumberOfPages,
999 "number-of-volumes" => Self::NumberOfVolumes,
1000 "citation-number" => Self::CitationNumber,
1001 "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1002 "citation-label" => Self::CitationLabel,
1003 "number" => Self::Number,
1004 "docket-number" => Self::DocketNumber,
1005 "patent-number" => Self::PatentNumber,
1006 "standard-number" => Self::StandardNumber,
1007 "report-number" => Self::ReportNumber,
1008 "part-number" => Self::PartNumber,
1009 "supplement-number" => Self::SupplementNumber,
1010 "printing-number" => Self::PrintingNumber,
1011 _ => Self::Custom(canonical),
1012 })
1013 }
1014}
1015
1016impl PartialEq for NumberVariable {
1017 fn eq(&self, other: &Self) -> bool {
1018 self.as_key().as_ref() == other.as_key().as_ref()
1019 }
1020}
1021
1022impl Eq for NumberVariable {}
1023
1024impl Hash for NumberVariable {
1025 fn hash<H: Hasher>(&self, state: &mut H) {
1026 self.as_key().as_ref().hash(state);
1027 }
1028}
1029
1030impl Serialize for NumberVariable {
1031 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1032 where
1033 S: Serializer,
1034 {
1035 serializer.serialize_str(self.as_key().as_ref())
1036 }
1037}
1038
1039impl<'de> Deserialize<'de> for NumberVariable {
1040 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1041 where
1042 D: Deserializer<'de>,
1043 {
1044 let value = String::deserialize(deserializer)?;
1045 Self::from_key(&value).map_err(serde::de::Error::custom)
1046 }
1047}
1048
1049#[cfg(feature = "schema")]
1050impl JsonSchema for NumberVariable {
1051 fn schema_name() -> std::borrow::Cow<'static, str> {
1052 "NumberVariable".into()
1053 }
1054
1055 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1056 schemars::json_schema!({
1057 "type": "string",
1058 "description": "Known number variable keyword or custom kebab-case identifier."
1059 })
1060 }
1061}
1062
1063#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1065#[cfg_attr(feature = "schema", derive(JsonSchema))]
1066#[serde(rename_all = "lowercase")]
1067pub enum NumberForm {
1068 #[default]
1069 Numeric,
1070 Ordinal,
1071 Roman,
1072}
1073
1074fn normalize_kind_key(value: &str) -> Option<String> {
1075 let mut normalized = String::new();
1076 let mut pending_dash = false;
1077
1078 for ch in value.trim().chars() {
1079 if ch.is_ascii_alphanumeric() {
1080 if pending_dash && !normalized.is_empty() {
1081 normalized.push('-');
1082 }
1083 normalized.push(ch.to_ascii_lowercase());
1084 pending_dash = false;
1085 } else if !normalized.is_empty() {
1086 pending_dash = true;
1087 }
1088 }
1089
1090 if normalized.is_empty() {
1091 None
1092 } else {
1093 Some(normalized)
1094 }
1095}
1096
1097#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1099#[cfg_attr(feature = "schema", derive(JsonSchema))]
1100#[serde(rename_all = "kebab-case")]
1101pub enum LabelForm {
1102 Long,
1103 #[default]
1104 Short,
1105 Symbol,
1106}
1107
1108#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1110#[cfg_attr(feature = "schema", derive(JsonSchema))]
1111#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1112pub struct TemplateVariable {
1113 pub variable: SimpleVariable,
1114 #[serde(flatten)]
1115 pub rendering: Rendering,
1116 #[serde(skip_serializing_if = "Option::is_none")]
1118 pub links: Option<crate::options::LinksConfig>,
1119
1120 #[serde(skip_serializing_if = "Option::is_none")]
1122 pub custom: Option<HashMap<String, serde_json::Value>>,
1123}
1124
1125#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1132#[cfg_attr(feature = "schema", derive(JsonSchema))]
1133#[serde(rename_all = "kebab-case")]
1134#[non_exhaustive]
1135pub enum SimpleVariable {
1136 #[default]
1137 Doi,
1138 Isbn,
1139 Issn,
1140 Url,
1141 Pmid,
1142 Pmcid,
1143 Abstract,
1144 Note,
1145 Annote,
1146 Keyword,
1147 Genre,
1148 Medium,
1149 Source,
1150 Status,
1151 Archive,
1152 ArchiveLocation,
1153 ArchiveName,
1154 ArchivePlace,
1155 ArchiveCollection,
1156 ArchiveCollectionId,
1157 ArchiveSeries,
1158 ArchiveBox,
1159 ArchiveFolder,
1160 ArchiveItem,
1161 ArchiveUrl,
1162 EprintId,
1163 EprintServer,
1164 EprintClass,
1165 Publisher,
1166 PublisherPlace,
1167 OriginalPublisher,
1168 OriginalPublisherPlace,
1169 EventPlace,
1170 Dimensions,
1171 Scale,
1172 Version,
1173 Locator,
1174 ContainerTitleShort,
1175 Authority,
1176 Code,
1177 Reporter,
1178 Page,
1179 Section,
1180 Volume,
1181 Number,
1182 DocketNumber,
1183 PatentNumber,
1184 StandardNumber,
1185 ReportNumber,
1186 AdsBibcode,
1187}
1188
1189#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1191#[cfg_attr(feature = "schema", derive(JsonSchema))]
1192#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1193pub struct TemplateTerm {
1194 pub term: GeneralTerm,
1196 #[serde(skip_serializing_if = "Option::is_none")]
1198 pub form: Option<TermForm>,
1199 #[serde(skip_serializing_if = "Option::is_none")]
1201 pub gender: Option<GrammaticalGender>,
1202 #[serde(flatten, default)]
1203 pub rendering: Rendering,
1204
1205 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub custom: Option<HashMap<String, serde_json::Value>>,
1208}
1209
1210#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1213#[cfg_attr(feature = "schema", derive(JsonSchema))]
1214#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1215pub struct TemplateGroup {
1216 pub group: Vec<TemplateComponent>,
1217 #[serde(skip_serializing_if = "Option::is_none")]
1218 pub delimiter: Option<DelimiterPunctuation>,
1219 #[serde(flatten, default)]
1220 pub rendering: Rendering,
1221
1222 #[serde(skip_serializing_if = "Option::is_none")]
1224 pub custom: Option<HashMap<String, serde_json::Value>>,
1225}
1226
1227#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1229#[serde(rename_all = "kebab-case")]
1230pub enum DelimiterPunctuation {
1231 #[default]
1232 Comma,
1233 Semicolon,
1234 Period,
1235 Colon,
1236 Ampersand,
1237 VerticalLine,
1238 Slash,
1239 Hyphen,
1240 Space,
1241 None,
1242 #[serde(untagged)]
1244 Custom(String),
1245}
1246
1247#[cfg(feature = "schema")]
1248impl JsonSchema for DelimiterPunctuation {
1249 fn schema_name() -> std::borrow::Cow<'static, str> {
1250 "DelimiterPunctuation".into()
1251 }
1252
1253 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1254 schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1255 }
1256}
1257
1258impl DelimiterPunctuation {
1259 pub fn to_string_with_space(&self) -> String {
1263 match self {
1264 Self::Comma => ", ".to_string(),
1265 Self::Semicolon => "; ".to_string(),
1266 Self::Period => ". ".to_string(),
1267 Self::Colon => ": ".to_string(),
1268 Self::Ampersand => " & ".to_string(),
1269 Self::VerticalLine => " | ".to_string(),
1270 Self::Slash => "/".to_string(),
1271 Self::Hyphen => "-".to_string(),
1272 Self::Space => " ".to_string(),
1273 Self::None => "".to_string(),
1274 Self::Custom(s) => s.clone(),
1275 }
1276 }
1277
1278 pub fn from_csl_string(s: &str) -> Self {
1283 if s == " " {
1284 return Self::Space;
1285 }
1286
1287 let trimmed = s.trim();
1288 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1289 return Self::None;
1290 }
1291
1292 match trimmed {
1293 "," => Self::Comma,
1294 ";" => Self::Semicolon,
1295 "." => Self::Period,
1296 ":" => Self::Colon,
1297 "&" => Self::Ampersand,
1298 "|" => Self::VerticalLine,
1299 "/" => Self::Slash,
1300 "-" => Self::Hyphen,
1301 _ => Self::Custom(s.to_string()),
1302 }
1303 }
1304}
1305
1306#[cfg(test)]
1307#[allow(
1308 clippy::unwrap_used,
1309 clippy::expect_used,
1310 clippy::panic,
1311 clippy::indexing_slicing,
1312 clippy::todo,
1313 clippy::unimplemented,
1314 clippy::unreachable,
1315 clippy::get_unwrap,
1316 reason = "Panicking is acceptable and often desired in tests."
1317)]
1318mod tests {
1319 use super::*;
1320
1321 #[test]
1322 fn test_contributor_deserialization() {
1323 let yaml = r#"
1324contributor: author
1325form: long
1326"#;
1327 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1328 assert_eq!(comp.contributor, ContributorRole::Author);
1329 assert_eq!(comp.form, ContributorForm::Long);
1330 }
1331
1332 #[test]
1333 fn test_template_component_untagged() {
1334 let yaml = r#"
1335- contributor: author
1336 form: short
1337- date: issued
1338 form: year
1339- title: primary
1340"#;
1341 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1342 assert_eq!(components.len(), 3);
1343
1344 match &components[0] {
1345 TemplateComponent::Contributor(c) => {
1346 assert_eq!(c.contributor, ContributorRole::Author);
1347 }
1348 _ => panic!("Expected Contributor"),
1349 }
1350
1351 match &components[1] {
1352 TemplateComponent::Date(d) => {
1353 assert_eq!(d.date, DateVariable::Issued);
1354 }
1355 _ => panic!("Expected Date"),
1356 }
1357 }
1358
1359 #[test]
1360 fn test_flattened_rendering() {
1361 let yaml = r#"
1363- title: parent-monograph
1364 prefix: "In "
1365 emph: true
1366- date: issued
1367 form: year
1368 wrap: parentheses
1369"#;
1370 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1371 assert_eq!(components.len(), 2);
1372
1373 match &components[0] {
1374 TemplateComponent::Title(t) => {
1375 assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1376 assert_eq!(t.rendering.emph, Some(true));
1377 }
1378 _ => panic!("Expected Title"),
1379 }
1380
1381 match &components[1] {
1382 TemplateComponent::Date(d) => {
1383 assert_eq!(
1384 d.rendering.wrap,
1385 Some(WrapConfig {
1386 punctuation: WrapPunctuation::Parentheses,
1387 inner_prefix: None,
1388 inner_suffix: None,
1389 })
1390 );
1391 }
1392 _ => panic!("Expected Date"),
1393 }
1394 }
1395
1396 #[test]
1397 fn test_number_variable_custom_normalizes_manual_construction() {
1398 let number = NumberVariable::Custom("Reel Label".to_string());
1399
1400 assert_eq!(number.as_key(), "reel-label");
1401 assert_eq!(
1402 number,
1403 serde_yaml::from_str::<NumberVariable>("reel-label")
1404 .expect("custom number variable should parse")
1405 );
1406 assert_eq!(
1407 serde_json::to_string(&number).expect("custom number variable should serialize"),
1408 "\"reel-label\""
1409 );
1410 }
1411
1412 #[test]
1413 fn test_contributor_with_wrap() {
1414 let yaml = r#"
1415contributor: publisher
1416form: short
1417wrap: parentheses
1418"#;
1419 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1420 assert_eq!(comp.contributor, ContributorRole::Publisher);
1421 assert_eq!(
1422 comp.rendering.wrap,
1423 Some(WrapConfig {
1424 punctuation: WrapPunctuation::Parentheses,
1425 inner_prefix: None,
1426 inner_suffix: None,
1427 })
1428 );
1429 }
1430
1431 #[test]
1432 fn test_variable_deserialization() {
1433 let yaml = "variable: publisher\n";
1435 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1436 match comp {
1437 TemplateComponent::Variable(v) => {
1438 assert_eq!(v.variable, SimpleVariable::Publisher);
1439 }
1440 _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1441 }
1442 }
1443
1444 #[test]
1445 fn test_variable_array_parsing() {
1446 let yaml = r#"
1447- variable: doi
1448 prefix: "https://doi.org/"
1449- variable: publisher
1450"#;
1451 let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1452 assert_eq!(comps.len(), 2);
1453 match &comps[0] {
1454 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1455 _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1456 }
1457 match &comps[1] {
1458 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1459 _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1460 }
1461 }
1462
1463 #[test]
1464 fn test_type_selector_default_only_matches_default_context() {
1465 let selector = TypeSelector::Single("default".to_string());
1466 assert!(selector.matches("default"));
1467 assert!(!selector.matches("article-journal"));
1468
1469 let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1470 assert!(mixed.matches("default"));
1471 assert!(mixed.matches("chapter"));
1472 assert!(!mixed.matches("book"));
1473 }
1474
1475 #[test]
1476 fn test_template_component_selector_matches_nested_partial_group() {
1477 let component: TemplateComponent = serde_yaml::from_str(
1478 r#"
1479delimiter: ""
1480group:
1481- number: citation-number
1482 wrap:
1483 punctuation: brackets
1484- contributor: author
1485 form: long
1486"#,
1487 )
1488 .unwrap();
1489 let selector = TemplateComponentSelector {
1490 fields: BTreeMap::from([(
1491 "group".to_string(),
1492 serde_json::json!([
1493 { "number": "citation-number" },
1494 { "contributor": "author" }
1495 ]),
1496 )]),
1497 };
1498
1499 assert!(selector.matches(&component));
1500 }
1501
1502 #[test]
1503 fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1504 assert_eq!(
1505 DelimiterPunctuation::from_csl_string("none"),
1506 DelimiterPunctuation::None
1507 );
1508 assert_eq!(
1509 DelimiterPunctuation::from_csl_string(" none "),
1510 DelimiterPunctuation::None
1511 );
1512 assert_eq!(
1513 DelimiterPunctuation::from_csl_string(" "),
1514 DelimiterPunctuation::Space
1515 );
1516 assert_eq!(
1517 DelimiterPunctuation::from_csl_string(" : "),
1518 DelimiterPunctuation::Colon
1519 );
1520 }
1521}