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