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