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 FirstReferenceNoteNumber,
926 CitationLabel,
927 Number,
928 DocketNumber,
929 PatentNumber,
930 StandardNumber,
931 ReportNumber,
932 PartNumber,
933 SupplementNumber,
934 PrintingNumber,
935 Custom(String),
937}
938
939impl NumberVariable {
940 #[must_use]
942 pub fn as_key(&self) -> Cow<'_, str> {
943 match self {
944 Self::Volume => Cow::Borrowed("volume"),
945 Self::Issue => Cow::Borrowed("issue"),
946 Self::Pages => Cow::Borrowed("pages"),
947 Self::Edition => Cow::Borrowed("edition"),
948 Self::ChapterNumber => Cow::Borrowed("chapter-number"),
949 Self::CollectionNumber => Cow::Borrowed("collection-number"),
950 Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
951 Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
952 Self::CitationNumber => Cow::Borrowed("citation-number"),
953 Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
954 Self::CitationLabel => Cow::Borrowed("citation-label"),
955 Self::Number => Cow::Borrowed("number"),
956 Self::DocketNumber => Cow::Borrowed("docket-number"),
957 Self::PatentNumber => Cow::Borrowed("patent-number"),
958 Self::StandardNumber => Cow::Borrowed("standard-number"),
959 Self::ReportNumber => Cow::Borrowed("report-number"),
960 Self::PartNumber => Cow::Borrowed("part-number"),
961 Self::SupplementNumber => Cow::Borrowed("supplement-number"),
962 Self::PrintingNumber => Cow::Borrowed("printing-number"),
963 Self::Custom(value) => normalize_kind_key(value)
964 .map(Cow::Owned)
965 .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
966 }
967 }
968
969 fn from_key(value: &str) -> Result<Self, String> {
970 let canonical = normalize_kind_key(value)
971 .ok_or_else(|| "number variable must not be empty".to_string())?;
972 Ok(match canonical.as_str() {
973 "volume" => Self::Volume,
974 "issue" => Self::Issue,
975 "pages" => Self::Pages,
976 "edition" => Self::Edition,
977 "chapter-number" => Self::ChapterNumber,
978 "collection-number" => Self::CollectionNumber,
979 "number-of-pages" => Self::NumberOfPages,
980 "number-of-volumes" => Self::NumberOfVolumes,
981 "citation-number" => Self::CitationNumber,
982 "first-reference-note-number" => Self::FirstReferenceNoteNumber,
983 "citation-label" => Self::CitationLabel,
984 "number" => Self::Number,
985 "docket-number" => Self::DocketNumber,
986 "patent-number" => Self::PatentNumber,
987 "standard-number" => Self::StandardNumber,
988 "report-number" => Self::ReportNumber,
989 "part-number" => Self::PartNumber,
990 "supplement-number" => Self::SupplementNumber,
991 "printing-number" => Self::PrintingNumber,
992 _ => Self::Custom(canonical),
993 })
994 }
995}
996
997impl PartialEq for NumberVariable {
998 fn eq(&self, other: &Self) -> bool {
999 self.as_key().as_ref() == other.as_key().as_ref()
1000 }
1001}
1002
1003impl Eq for NumberVariable {}
1004
1005impl Hash for NumberVariable {
1006 fn hash<H: Hasher>(&self, state: &mut H) {
1007 self.as_key().as_ref().hash(state);
1008 }
1009}
1010
1011impl Serialize for NumberVariable {
1012 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1013 where
1014 S: Serializer,
1015 {
1016 serializer.serialize_str(self.as_key().as_ref())
1017 }
1018}
1019
1020impl<'de> Deserialize<'de> for NumberVariable {
1021 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1022 where
1023 D: Deserializer<'de>,
1024 {
1025 let value = String::deserialize(deserializer)?;
1026 Self::from_key(&value).map_err(serde::de::Error::custom)
1027 }
1028}
1029
1030#[cfg(feature = "schema")]
1031impl JsonSchema for NumberVariable {
1032 fn schema_name() -> std::borrow::Cow<'static, str> {
1033 "NumberVariable".into()
1034 }
1035
1036 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1037 schemars::json_schema!({
1038 "type": "string",
1039 "description": "Known number variable keyword or custom kebab-case identifier."
1040 })
1041 }
1042}
1043
1044#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1046#[cfg_attr(feature = "schema", derive(JsonSchema))]
1047#[serde(rename_all = "lowercase")]
1048pub enum NumberForm {
1049 #[default]
1050 Numeric,
1051 Ordinal,
1052 Roman,
1053}
1054
1055fn normalize_kind_key(value: &str) -> Option<String> {
1056 let mut normalized = String::new();
1057 let mut pending_dash = false;
1058
1059 for ch in value.trim().chars() {
1060 if ch.is_ascii_alphanumeric() {
1061 if pending_dash && !normalized.is_empty() {
1062 normalized.push('-');
1063 }
1064 normalized.push(ch.to_ascii_lowercase());
1065 pending_dash = false;
1066 } else if !normalized.is_empty() {
1067 pending_dash = true;
1068 }
1069 }
1070
1071 if normalized.is_empty() {
1072 None
1073 } else {
1074 Some(normalized)
1075 }
1076}
1077
1078#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1080#[cfg_attr(feature = "schema", derive(JsonSchema))]
1081#[serde(rename_all = "kebab-case")]
1082pub enum LabelForm {
1083 Long,
1084 #[default]
1085 Short,
1086 Symbol,
1087}
1088
1089#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1091#[cfg_attr(feature = "schema", derive(JsonSchema))]
1092#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1093pub struct TemplateVariable {
1094 pub variable: SimpleVariable,
1095 #[serde(flatten)]
1096 pub rendering: Rendering,
1097 #[serde(skip_serializing_if = "Option::is_none")]
1099 pub links: Option<crate::options::LinksConfig>,
1100
1101 #[serde(skip_serializing_if = "Option::is_none")]
1103 pub custom: Option<HashMap<String, serde_json::Value>>,
1104}
1105
1106#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1113#[cfg_attr(feature = "schema", derive(JsonSchema))]
1114#[serde(rename_all = "kebab-case")]
1115#[non_exhaustive]
1116pub enum SimpleVariable {
1117 #[default]
1118 Doi,
1119 Isbn,
1120 Issn,
1121 Url,
1122 Pmid,
1123 Pmcid,
1124 Abstract,
1125 Note,
1126 Annote,
1127 Keyword,
1128 Genre,
1129 Medium,
1130 Source,
1131 Status,
1132 Archive,
1133 ArchiveLocation,
1134 ArchiveName,
1135 ArchivePlace,
1136 ArchiveCollection,
1137 ArchiveCollectionId,
1138 ArchiveSeries,
1139 ArchiveBox,
1140 ArchiveFolder,
1141 ArchiveItem,
1142 ArchiveUrl,
1143 EprintId,
1144 EprintServer,
1145 EprintClass,
1146 Publisher,
1147 PublisherPlace,
1148 OriginalPublisher,
1149 OriginalPublisherPlace,
1150 EventPlace,
1151 Dimensions,
1152 Scale,
1153 Version,
1154 Locator,
1155 ContainerTitleShort,
1156 Authority,
1157 Code,
1158 Reporter,
1159 Page,
1160 Section,
1161 Volume,
1162 Number,
1163 DocketNumber,
1164 PatentNumber,
1165 StandardNumber,
1166 ReportNumber,
1167 AdsBibcode,
1168}
1169
1170#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1172#[cfg_attr(feature = "schema", derive(JsonSchema))]
1173#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1174pub struct TemplateTerm {
1175 pub term: GeneralTerm,
1177 #[serde(skip_serializing_if = "Option::is_none")]
1179 pub form: Option<TermForm>,
1180 #[serde(skip_serializing_if = "Option::is_none")]
1182 pub gender: Option<GrammaticalGender>,
1183 #[serde(flatten, default)]
1184 pub rendering: Rendering,
1185
1186 #[serde(skip_serializing_if = "Option::is_none")]
1188 pub custom: Option<HashMap<String, serde_json::Value>>,
1189}
1190
1191#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1194#[cfg_attr(feature = "schema", derive(JsonSchema))]
1195#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1196pub struct TemplateGroup {
1197 pub group: Vec<TemplateComponent>,
1198 #[serde(skip_serializing_if = "Option::is_none")]
1199 pub delimiter: Option<DelimiterPunctuation>,
1200 #[serde(flatten, default)]
1201 pub rendering: Rendering,
1202
1203 #[serde(skip_serializing_if = "Option::is_none")]
1205 pub custom: Option<HashMap<String, serde_json::Value>>,
1206}
1207
1208#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1210#[serde(rename_all = "kebab-case")]
1211pub enum DelimiterPunctuation {
1212 #[default]
1213 Comma,
1214 Semicolon,
1215 Period,
1216 Colon,
1217 Ampersand,
1218 VerticalLine,
1219 Slash,
1220 Hyphen,
1221 Space,
1222 None,
1223 #[serde(untagged)]
1225 Custom(String),
1226}
1227
1228#[cfg(feature = "schema")]
1229impl JsonSchema for DelimiterPunctuation {
1230 fn schema_name() -> std::borrow::Cow<'static, str> {
1231 "DelimiterPunctuation".into()
1232 }
1233
1234 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1235 schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1236 }
1237}
1238
1239impl DelimiterPunctuation {
1240 pub fn to_string_with_space(&self) -> String {
1244 match self {
1245 Self::Comma => ", ".to_string(),
1246 Self::Semicolon => "; ".to_string(),
1247 Self::Period => ". ".to_string(),
1248 Self::Colon => ": ".to_string(),
1249 Self::Ampersand => " & ".to_string(),
1250 Self::VerticalLine => " | ".to_string(),
1251 Self::Slash => "/".to_string(),
1252 Self::Hyphen => "-".to_string(),
1253 Self::Space => " ".to_string(),
1254 Self::None => "".to_string(),
1255 Self::Custom(s) => s.clone(),
1256 }
1257 }
1258
1259 pub fn from_csl_string(s: &str) -> Self {
1264 if s == " " {
1265 return Self::Space;
1266 }
1267
1268 let trimmed = s.trim();
1269 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1270 return Self::None;
1271 }
1272
1273 match trimmed {
1274 "," => Self::Comma,
1275 ";" => Self::Semicolon,
1276 "." => Self::Period,
1277 ":" => Self::Colon,
1278 "&" => Self::Ampersand,
1279 "|" => Self::VerticalLine,
1280 "/" => Self::Slash,
1281 "-" => Self::Hyphen,
1282 _ => Self::Custom(s.to_string()),
1283 }
1284 }
1285}
1286
1287#[cfg(test)]
1288#[allow(
1289 clippy::unwrap_used,
1290 clippy::expect_used,
1291 clippy::panic,
1292 clippy::indexing_slicing,
1293 clippy::todo,
1294 clippy::unimplemented,
1295 clippy::unreachable,
1296 clippy::get_unwrap,
1297 reason = "Panicking is acceptable and often desired in tests."
1298)]
1299mod tests {
1300 use super::*;
1301
1302 #[test]
1303 fn test_contributor_deserialization() {
1304 let yaml = r#"
1305contributor: author
1306form: long
1307"#;
1308 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1309 assert_eq!(comp.contributor, ContributorRole::Author);
1310 assert_eq!(comp.form, ContributorForm::Long);
1311 }
1312
1313 #[test]
1314 fn test_template_component_untagged() {
1315 let yaml = r#"
1316- contributor: author
1317 form: short
1318- date: issued
1319 form: year
1320- title: primary
1321"#;
1322 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1323 assert_eq!(components.len(), 3);
1324
1325 match &components[0] {
1326 TemplateComponent::Contributor(c) => {
1327 assert_eq!(c.contributor, ContributorRole::Author);
1328 }
1329 _ => panic!("Expected Contributor"),
1330 }
1331
1332 match &components[1] {
1333 TemplateComponent::Date(d) => {
1334 assert_eq!(d.date, DateVariable::Issued);
1335 }
1336 _ => panic!("Expected Date"),
1337 }
1338 }
1339
1340 #[test]
1341 fn test_flattened_rendering() {
1342 let yaml = r#"
1344- title: parent-monograph
1345 prefix: "In "
1346 emph: true
1347- date: issued
1348 form: year
1349 wrap: parentheses
1350"#;
1351 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1352 assert_eq!(components.len(), 2);
1353
1354 match &components[0] {
1355 TemplateComponent::Title(t) => {
1356 assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1357 assert_eq!(t.rendering.emph, Some(true));
1358 }
1359 _ => panic!("Expected Title"),
1360 }
1361
1362 match &components[1] {
1363 TemplateComponent::Date(d) => {
1364 assert_eq!(
1365 d.rendering.wrap,
1366 Some(WrapConfig {
1367 punctuation: WrapPunctuation::Parentheses,
1368 inner_prefix: None,
1369 inner_suffix: None,
1370 })
1371 );
1372 }
1373 _ => panic!("Expected Date"),
1374 }
1375 }
1376
1377 #[test]
1378 fn test_number_variable_custom_normalizes_manual_construction() {
1379 let number = NumberVariable::Custom("Reel Label".to_string());
1380
1381 assert_eq!(number.as_key(), "reel-label");
1382 assert_eq!(
1383 number,
1384 serde_yaml::from_str::<NumberVariable>("reel-label")
1385 .expect("custom number variable should parse")
1386 );
1387 assert_eq!(
1388 serde_json::to_string(&number).expect("custom number variable should serialize"),
1389 "\"reel-label\""
1390 );
1391 }
1392
1393 #[test]
1394 fn test_contributor_with_wrap() {
1395 let yaml = r#"
1396contributor: publisher
1397form: short
1398wrap: parentheses
1399"#;
1400 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1401 assert_eq!(comp.contributor, ContributorRole::Publisher);
1402 assert_eq!(
1403 comp.rendering.wrap,
1404 Some(WrapConfig {
1405 punctuation: WrapPunctuation::Parentheses,
1406 inner_prefix: None,
1407 inner_suffix: None,
1408 })
1409 );
1410 }
1411
1412 #[test]
1413 fn test_variable_deserialization() {
1414 let yaml = "variable: publisher\n";
1416 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1417 match comp {
1418 TemplateComponent::Variable(v) => {
1419 assert_eq!(v.variable, SimpleVariable::Publisher);
1420 }
1421 _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1422 }
1423 }
1424
1425 #[test]
1426 fn test_variable_array_parsing() {
1427 let yaml = r#"
1428- variable: doi
1429 prefix: "https://doi.org/"
1430- variable: publisher
1431"#;
1432 let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1433 assert_eq!(comps.len(), 2);
1434 match &comps[0] {
1435 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1436 _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1437 }
1438 match &comps[1] {
1439 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1440 _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1441 }
1442 }
1443
1444 #[test]
1445 fn test_type_selector_default_only_matches_default_context() {
1446 let selector = TypeSelector::Single("default".to_string());
1447 assert!(selector.matches("default"));
1448 assert!(!selector.matches("article-journal"));
1449
1450 let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1451 assert!(mixed.matches("default"));
1452 assert!(mixed.matches("chapter"));
1453 assert!(!mixed.matches("book"));
1454 }
1455
1456 #[test]
1457 fn test_template_component_selector_matches_nested_partial_group() {
1458 let component: TemplateComponent = serde_yaml::from_str(
1459 r#"
1460delimiter: ""
1461group:
1462- number: citation-number
1463 wrap:
1464 punctuation: brackets
1465- contributor: author
1466 form: long
1467"#,
1468 )
1469 .unwrap();
1470 let selector = TemplateComponentSelector {
1471 fields: BTreeMap::from([(
1472 "group".to_string(),
1473 serde_json::json!([
1474 { "number": "citation-number" },
1475 { "contributor": "author" }
1476 ]),
1477 )]),
1478 };
1479
1480 assert!(selector.matches(&component));
1481 }
1482
1483 #[test]
1484 fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1485 assert_eq!(
1486 DelimiterPunctuation::from_csl_string("none"),
1487 DelimiterPunctuation::None
1488 );
1489 assert_eq!(
1490 DelimiterPunctuation::from_csl_string(" none "),
1491 DelimiterPunctuation::None
1492 );
1493 assert_eq!(
1494 DelimiterPunctuation::from_csl_string(" "),
1495 DelimiterPunctuation::Space
1496 );
1497 assert_eq!(
1498 DelimiterPunctuation::from_csl_string(" : "),
1499 DelimiterPunctuation::Colon
1500 );
1501 }
1502}