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 Message(TemplateMessage),
477 Group(TemplateGroup),
478 Term(TemplateTerm),
479 TypeLabel(TemplateTypeLabel),
480}
481
482impl Default for TemplateComponent {
483 fn default() -> Self {
484 TemplateComponent::Variable(TemplateVariable::default())
485 }
486}
487
488impl TemplateComponent {
489 pub fn rendering(&self) -> &Rendering {
493 crate::dispatch_component!(self, |inner| &inner.rendering)
494 }
495
496 pub fn rendering_mut(&mut self) -> &mut Rendering {
501 crate::dispatch_component!(self, |inner| &mut inner.rendering)
502 }
503}
504
505#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
507#[cfg_attr(feature = "schema", derive(JsonSchema))]
508#[serde(untagged)]
509pub enum TemplateVariant {
510 Full(Vec<TemplateComponent>),
512 Diff(TemplateVariantDiff),
514}
515
516impl TemplateVariant {
517 #[must_use]
519 pub fn as_template(&self) -> Option<&[TemplateComponent]> {
520 match self {
521 Self::Full(template) => Some(template.as_slice()),
522 Self::Diff(_) => None,
523 }
524 }
525
526 pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
528 match self {
529 Self::Full(template) => Some(template),
530 Self::Diff(_) => None,
531 }
532 }
533
534 #[must_use]
536 pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
537 match self {
538 Self::Full(template) => Some(template),
539 Self::Diff(_) => None,
540 }
541 }
542}
543
544impl From<Vec<TemplateComponent>> for TemplateVariant {
545 fn from(template: Vec<TemplateComponent>) -> Self {
546 Self::Full(template)
547 }
548}
549
550#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
552#[cfg_attr(feature = "schema", derive(JsonSchema))]
553#[serde(rename_all = "kebab-case", deny_unknown_fields)]
554pub struct TemplateVariantDiff {
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub extends: Option<TypeSelector>,
558 #[serde(skip_serializing_if = "Vec::is_empty", default)]
560 pub modify: Vec<TemplateModifyOperation>,
561 #[serde(skip_serializing_if = "Vec::is_empty", default)]
563 pub remove: Vec<TemplateRemoveOperation>,
564 #[serde(skip_serializing_if = "Vec::is_empty", default)]
566 pub add: Vec<TemplateAddOperation>,
567}
568
569#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
571#[cfg_attr(feature = "schema", derive(JsonSchema))]
572#[serde(transparent)]
573pub struct TemplateComponentSelector {
574 pub fields: BTreeMap<String, serde_json::Value>,
576}
577
578impl TemplateComponentSelector {
579 #[must_use]
581 pub fn is_empty(&self) -> bool {
582 self.fields.is_empty()
583 }
584
585 #[must_use]
587 pub fn matches(&self, component: &TemplateComponent) -> bool {
588 let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
589 else {
590 return false;
591 };
592
593 self.fields.iter().all(|(key, expected)| {
594 component_fields
595 .get(key)
596 .is_some_and(|actual| selector_value_matches(expected, actual))
597 })
598 }
599}
600
601fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
602 match (expected, actual) {
603 (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
604 expected_fields.iter().all(|(key, expected_value)| {
605 actual_fields.get(key).is_some_and(|actual_value| {
606 selector_value_matches(expected_value, actual_value)
607 })
608 })
609 }
610 (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
611 expected_items.len() == actual_items.len()
612 && expected_items.iter().zip(actual_items.iter()).all(
613 |(expected_item, actual_item)| {
614 selector_value_matches(expected_item, actual_item)
615 },
616 )
617 }
618 _ => expected == actual,
619 }
620}
621
622#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
624#[cfg_attr(feature = "schema", derive(JsonSchema))]
625#[serde(rename_all = "kebab-case", deny_unknown_fields)]
626pub struct TemplateModifyOperation {
627 #[serde(rename = "match")]
629 pub match_selector: TemplateComponentSelector,
630 #[serde(skip_serializing_if = "Option::is_none")]
632 pub label_form: Option<LabelForm>,
633 #[serde(flatten, default)]
635 pub rendering: Rendering,
636}
637
638#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
640#[cfg_attr(feature = "schema", derive(JsonSchema))]
641#[serde(rename_all = "kebab-case", deny_unknown_fields)]
642pub struct TemplateRemoveOperation {
643 #[serde(rename = "match")]
645 pub match_selector: TemplateComponentSelector,
646}
647
648#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
650#[cfg_attr(feature = "schema", derive(JsonSchema))]
651#[serde(rename_all = "kebab-case", deny_unknown_fields)]
652pub struct TemplateAddOperation {
653 #[serde(skip_serializing_if = "Option::is_none")]
655 pub before: Option<TemplateComponentSelector>,
656 #[serde(skip_serializing_if = "Option::is_none")]
658 pub after: Option<TemplateComponentSelector>,
659 pub component: TemplateComponent,
661}
662
663#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
665#[cfg_attr(feature = "schema", derive(JsonSchema))]
666#[serde(rename_all = "kebab-case")]
667pub struct RoleLabel {
668 pub term: String,
670 #[serde(default)]
672 pub form: RoleLabelForm,
673 #[serde(default)]
675 pub placement: LabelPlacement,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub text_case: Option<crate::options::titles::TextCase>,
681}
682
683#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
685#[cfg_attr(feature = "schema", derive(JsonSchema))]
686#[serde(rename_all = "kebab-case")]
687pub enum RoleLabelForm {
688 #[default]
689 Short,
690 Long,
691}
692
693#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
695#[cfg_attr(feature = "schema", derive(JsonSchema))]
696#[serde(rename_all = "kebab-case")]
697pub enum LabelPlacement {
698 Prefix,
699 #[default]
700 Suffix,
701}
702
703#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
705#[cfg_attr(feature = "schema", derive(JsonSchema))]
706#[serde(rename_all = "kebab-case", deny_unknown_fields)]
707pub struct TemplateContributor {
708 pub contributor: ContributorRole,
710 pub form: ContributorForm,
712 #[serde(skip_serializing_if = "Option::is_none")]
714 pub label: Option<RoleLabel>,
715 #[serde(skip_serializing_if = "Option::is_none")]
718 pub name_order: Option<NameOrder>,
719 #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
721 pub name_form: Option<crate::options::contributors::NameForm>,
722 #[serde(skip_serializing_if = "Option::is_none")]
724 pub delimiter: Option<String>,
725 #[serde(skip_serializing_if = "Option::is_none")]
727 pub sort_separator: Option<String>,
728 #[serde(skip_serializing_if = "Option::is_none")]
730 pub shorten: Option<crate::options::ShortenListOptions>,
731 #[serde(skip_serializing_if = "Option::is_none")]
734 pub and: Option<crate::options::AndOptions>,
735 #[serde(flatten, default)]
736 pub rendering: Rendering,
737 #[serde(skip_serializing_if = "Option::is_none")]
739 pub links: Option<crate::options::LinksConfig>,
740 #[serde(skip_serializing_if = "Option::is_none")]
742 pub gender: Option<GrammaticalGender>,
743
744 #[serde(skip_serializing_if = "Option::is_none")]
746 pub custom: Option<HashMap<String, serde_json::Value>>,
747}
748
749#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
751#[cfg_attr(feature = "schema", derive(JsonSchema))]
752#[serde(rename_all = "kebab-case")]
753pub enum NameOrder {
754 GivenFirst,
756 #[default]
758 FamilyFirst,
759 FamilyFirstOnly,
761}
762
763#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
765#[cfg_attr(feature = "schema", derive(JsonSchema))]
766#[serde(rename_all = "kebab-case")]
767pub enum ContributorForm {
768 #[default]
769 Long,
770 Short,
771 FamilyOnly,
772 Verb,
773 VerbShort,
774}
775
776crate::str_enum! {
777 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
779 pub enum ContributorRole {
780 #[default] Author = "author",
781 Chair = "chair",
782 Editor = "editor",
783 Translator = "translator",
784 Director = "director",
785 Publisher = "publisher",
786 Recipient = "recipient",
787 Interviewer = "interviewer",
788 Interviewee = "interviewee",
789 Guest = "guest",
790 Performer = "performer",
791 Inventor = "inventor",
792 Counsel = "counsel",
793 Composer = "composer",
794 Writer = "writer",
795 CollectionEditor = "collection-editor",
796 ContainerAuthor = "container-author",
797 EditorialDirector = "editorial-director",
798 TextualEditor = "textual-editor",
799 Illustrator = "illustrator",
800 OriginalAuthor = "original-author",
801 ReviewedAuthor = "reviewed-author"
802 }
803}
804
805#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
807#[cfg_attr(feature = "schema", derive(JsonSchema))]
808#[serde(rename_all = "kebab-case", deny_unknown_fields)]
809pub struct TemplateDate {
810 pub date: DateVariable,
811 pub form: DateForm,
812 #[serde(skip_serializing_if = "Option::is_none")]
814 pub fallback: Option<Vec<TemplateComponent>>,
815 #[serde(flatten, default)]
816 pub rendering: Rendering,
817 #[serde(skip_serializing_if = "Option::is_none")]
819 pub links: Option<crate::options::LinksConfig>,
820
821 #[serde(skip_serializing_if = "Option::is_none")]
823 pub custom: Option<HashMap<String, serde_json::Value>>,
824}
825
826#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
828#[cfg_attr(feature = "schema", derive(JsonSchema))]
829#[serde(rename_all = "kebab-case")]
830pub enum DateVariable {
831 #[default]
832 Issued,
833 Accessed,
834 OriginalPublished,
835 Submitted,
836 EventDate,
837}
838
839crate::str_enum! {
840 #[derive(Debug, Default, Clone, PartialEq)]
842 pub enum DateForm {
843 #[default]
844 Year = "year",
845 YearMonth = "year-month",
846 Month = "month",
849 Full = "full",
850 MonthDay = "month-day",
851 YearMonthDay = "year-month-day",
852 DayMonthAbbrYear = "day-month-abbr-year",
853 MonthAbbrDayYear = "month-abbr-day-year"
855 }
856}
857
858#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
860#[cfg_attr(feature = "schema", derive(JsonSchema))]
861#[serde(rename_all = "kebab-case", deny_unknown_fields)]
862pub struct TemplateTitle {
863 pub title: TitleType,
864 #[serde(skip_serializing_if = "Option::is_none")]
865 pub form: Option<TitleForm>,
866 #[serde(skip_serializing_if = "Option::is_none")]
871 pub disambiguate_only: Option<bool>,
872 #[serde(flatten, default)]
873 pub rendering: Rendering,
874 #[serde(skip_serializing_if = "Option::is_none")]
876 pub links: Option<crate::options::LinksConfig>,
877
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub custom: Option<HashMap<String, serde_json::Value>>,
881}
882
883#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
885#[cfg_attr(feature = "schema", derive(JsonSchema))]
886#[serde(rename_all = "kebab-case")]
887#[non_exhaustive]
888pub enum TitleType {
889 #[default]
891 Primary,
892 ContainerTitle,
894 ParentMonograph,
896 ParentSerial,
898 CollectionTitle,
900 Original,
902}
903
904#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
906#[cfg_attr(feature = "schema", derive(JsonSchema))]
907#[serde(rename_all = "kebab-case")]
908pub enum TitleForm {
909 Short,
910 #[default]
911 Long,
912}
913
914#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
916#[cfg_attr(feature = "schema", derive(JsonSchema))]
917#[serde(rename_all = "kebab-case", deny_unknown_fields)]
918pub struct TemplateNumber {
919 pub number: NumberVariable,
920 #[serde(skip_serializing_if = "Option::is_none")]
921 pub form: Option<NumberForm>,
922 #[serde(skip_serializing_if = "Option::is_none")]
923 pub label_form: Option<LabelForm>,
924 #[serde(skip_serializing_if = "Option::is_none")]
927 pub show_with_locator: Option<bool>,
928 #[serde(flatten)]
929 pub rendering: Rendering,
930 #[serde(skip_serializing_if = "Option::is_none")]
932 pub links: Option<crate::options::LinksConfig>,
933 #[serde(skip_serializing_if = "Option::is_none")]
935 pub gender: Option<GrammaticalGender>,
936
937 #[serde(skip_serializing_if = "Option::is_none")]
939 pub custom: Option<HashMap<String, serde_json::Value>>,
940}
941
942#[derive(Debug, Default, Clone)]
949#[non_exhaustive]
950pub enum NumberVariable {
951 #[default]
952 Volume,
953 Issue,
954 Pages,
955 Edition,
956 ChapterNumber,
957 CollectionNumber,
958 NumberOfPages,
959 NumberOfVolumes,
960 CitationNumber,
961 FirstReferenceNoteNumber,
965 CitationLabel,
966 Number,
967 DocketNumber,
968 PatentNumber,
969 StandardNumber,
970 ReportNumber,
971 PartNumber,
972 SupplementNumber,
973 PrintingNumber,
974 Custom(String),
976}
977
978impl NumberVariable {
979 #[must_use]
981 pub fn as_key(&self) -> Cow<'_, str> {
982 match self {
983 Self::Volume => Cow::Borrowed("volume"),
984 Self::Issue => Cow::Borrowed("issue"),
985 Self::Pages => Cow::Borrowed("pages"),
986 Self::Edition => Cow::Borrowed("edition"),
987 Self::ChapterNumber => Cow::Borrowed("chapter-number"),
988 Self::CollectionNumber => Cow::Borrowed("collection-number"),
989 Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
990 Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
991 Self::CitationNumber => Cow::Borrowed("citation-number"),
992 Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
993 Self::CitationLabel => Cow::Borrowed("citation-label"),
994 Self::Number => Cow::Borrowed("number"),
995 Self::DocketNumber => Cow::Borrowed("docket-number"),
996 Self::PatentNumber => Cow::Borrowed("patent-number"),
997 Self::StandardNumber => Cow::Borrowed("standard-number"),
998 Self::ReportNumber => Cow::Borrowed("report-number"),
999 Self::PartNumber => Cow::Borrowed("part-number"),
1000 Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1001 Self::PrintingNumber => Cow::Borrowed("printing-number"),
1002 Self::Custom(value) => normalize_kind_key(value)
1003 .map(Cow::Owned)
1004 .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1005 }
1006 }
1007
1008 fn from_key(value: &str) -> Result<Self, String> {
1009 let canonical = normalize_kind_key(value)
1010 .ok_or_else(|| "number variable must not be empty".to_string())?;
1011 Ok(match canonical.as_str() {
1012 "volume" => Self::Volume,
1013 "issue" => Self::Issue,
1014 "pages" => Self::Pages,
1015 "edition" => Self::Edition,
1016 "chapter-number" => Self::ChapterNumber,
1017 "collection-number" => Self::CollectionNumber,
1018 "number-of-pages" => Self::NumberOfPages,
1019 "number-of-volumes" => Self::NumberOfVolumes,
1020 "citation-number" => Self::CitationNumber,
1021 "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1022 "citation-label" => Self::CitationLabel,
1023 "number" => Self::Number,
1024 "docket-number" => Self::DocketNumber,
1025 "patent-number" => Self::PatentNumber,
1026 "standard-number" => Self::StandardNumber,
1027 "report-number" => Self::ReportNumber,
1028 "part-number" => Self::PartNumber,
1029 "supplement-number" => Self::SupplementNumber,
1030 "printing-number" => Self::PrintingNumber,
1031 _ => Self::Custom(canonical),
1032 })
1033 }
1034}
1035
1036impl PartialEq for NumberVariable {
1037 fn eq(&self, other: &Self) -> bool {
1038 self.as_key().as_ref() == other.as_key().as_ref()
1039 }
1040}
1041
1042impl Eq for NumberVariable {}
1043
1044impl Hash for NumberVariable {
1045 fn hash<H: Hasher>(&self, state: &mut H) {
1046 self.as_key().as_ref().hash(state);
1047 }
1048}
1049
1050impl Serialize for NumberVariable {
1051 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1052 where
1053 S: Serializer,
1054 {
1055 serializer.serialize_str(self.as_key().as_ref())
1056 }
1057}
1058
1059impl<'de> Deserialize<'de> for NumberVariable {
1060 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1061 where
1062 D: Deserializer<'de>,
1063 {
1064 let value = String::deserialize(deserializer)?;
1065 Self::from_key(&value).map_err(serde::de::Error::custom)
1066 }
1067}
1068
1069#[cfg(feature = "schema")]
1070impl JsonSchema for NumberVariable {
1071 fn schema_name() -> std::borrow::Cow<'static, str> {
1072 "NumberVariable".into()
1073 }
1074
1075 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1076 schemars::json_schema!({
1077 "type": "string",
1078 "description": "Known number variable keyword or custom kebab-case identifier."
1079 })
1080 }
1081}
1082
1083#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1085#[cfg_attr(feature = "schema", derive(JsonSchema))]
1086#[serde(rename_all = "lowercase")]
1087pub enum NumberForm {
1088 #[default]
1089 Numeric,
1090 Ordinal,
1091 Roman,
1092}
1093
1094fn normalize_kind_key(value: &str) -> Option<String> {
1095 let mut normalized = String::new();
1096 let mut pending_dash = false;
1097
1098 for ch in value.trim().chars() {
1099 if ch.is_ascii_alphanumeric() {
1100 if pending_dash && !normalized.is_empty() {
1101 normalized.push('-');
1102 }
1103 normalized.push(ch.to_ascii_lowercase());
1104 pending_dash = false;
1105 } else if !normalized.is_empty() {
1106 pending_dash = true;
1107 }
1108 }
1109
1110 if normalized.is_empty() {
1111 None
1112 } else {
1113 Some(normalized)
1114 }
1115}
1116
1117#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1119#[cfg_attr(feature = "schema", derive(JsonSchema))]
1120#[serde(rename_all = "kebab-case")]
1121pub enum LabelForm {
1122 Long,
1123 #[default]
1124 Short,
1125 Symbol,
1126}
1127
1128#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1130#[cfg_attr(feature = "schema", derive(JsonSchema))]
1131#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1132pub struct TemplateVariable {
1133 pub variable: SimpleVariable,
1134 #[serde(flatten)]
1135 pub rendering: Rendering,
1136 #[serde(skip_serializing_if = "Option::is_none")]
1138 pub links: Option<crate::options::LinksConfig>,
1139
1140 #[serde(skip_serializing_if = "Option::is_none")]
1142 pub custom: Option<HashMap<String, serde_json::Value>>,
1143}
1144
1145#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1151#[cfg_attr(feature = "schema", derive(JsonSchema))]
1152#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1153pub struct TemplateMessage {
1154 pub message: String,
1156 #[serde(skip_serializing_if = "Option::is_none")]
1158 pub form: Option<TermForm>,
1159 #[serde(skip_serializing_if = "Option::is_none")]
1161 pub gender: Option<GrammaticalGender>,
1162 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1164 pub args: HashMap<String, MessageArgSource>,
1165 #[serde(flatten, default)]
1166 pub rendering: Rendering,
1167
1168 #[serde(skip_serializing_if = "Option::is_none")]
1170 pub custom: Option<HashMap<String, serde_json::Value>>,
1171}
1172
1173#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1175#[cfg_attr(feature = "schema", derive(JsonSchema))]
1176#[serde(untagged)]
1177pub enum MessageArgSource {
1178 Literal { literal: String },
1180 Contributor(TemplateContributor),
1182 Date(TemplateDate),
1184 Group(TemplateGroup),
1186 Title(TemplateTitle),
1188 Number(TemplateNumber),
1190 Variable(TemplateVariable),
1192 Term(TemplateTerm),
1194}
1195
1196impl MessageArgSource {
1197 #[must_use]
1200 pub fn as_template_component(&self) -> Option<TemplateComponent> {
1201 match self {
1202 Self::Literal { .. } => None,
1203 Self::Contributor(component) => Some(TemplateComponent::Contributor(component.clone())),
1204 Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1205 Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1206 Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1207 Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1208 Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1209 Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1210 }
1211 }
1212}
1213
1214#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1221#[cfg_attr(feature = "schema", derive(JsonSchema))]
1222#[serde(rename_all = "kebab-case")]
1223#[non_exhaustive]
1224pub enum SimpleVariable {
1225 #[default]
1226 Doi,
1227 Isbn,
1228 Issn,
1229 Url,
1230 Pmid,
1231 Pmcid,
1232 Abstract,
1233 Note,
1234 Annote,
1235 Keyword,
1236 Genre,
1237 RawGenre,
1238 Medium,
1239 RawMedium,
1240 Source,
1241 Status,
1242 Archive,
1243 ArchiveLocation,
1244 ArchiveName,
1245 ArchivePlace,
1246 ArchiveCollection,
1247 ArchiveCollectionId,
1248 ArchiveSeries,
1249 ArchiveBox,
1250 ArchiveFolder,
1251 ArchiveItem,
1252 ArchiveUrl,
1253 EprintId,
1254 EprintServer,
1255 EprintClass,
1256 Publisher,
1257 PublisherPlace,
1258 OriginalPublisher,
1259 OriginalPublisherPlace,
1260 EventTitle,
1261 EventPlace,
1262 Dimensions,
1263 References,
1264 Scale,
1265 Version,
1266 Locator,
1267 ContainerTitleShort,
1268 Authority,
1269 Code,
1270 Reporter,
1271 Page,
1272 Section,
1273 Volume,
1274 Number,
1275 DocketNumber,
1276 PatentNumber,
1277 StandardNumber,
1278 ReportNumber,
1279 AdsBibcode,
1280}
1281
1282#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1284#[cfg_attr(feature = "schema", derive(JsonSchema))]
1285#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1286pub struct TemplateTerm {
1287 pub term: GeneralTerm,
1289 #[serde(skip_serializing_if = "Option::is_none")]
1291 pub form: Option<TermForm>,
1292 #[serde(skip_serializing_if = "Option::is_none")]
1294 pub gender: Option<GrammaticalGender>,
1295 #[serde(flatten, default)]
1296 pub rendering: Rendering,
1297
1298 #[serde(skip_serializing_if = "Option::is_none")]
1300 pub custom: Option<HashMap<String, serde_json::Value>>,
1301}
1302
1303#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1310#[cfg_attr(feature = "schema", derive(JsonSchema))]
1311#[serde(rename_all = "kebab-case")]
1312#[non_exhaustive]
1313pub enum TypeLabelSource {
1314 #[default]
1317 ReferenceType,
1318}
1319
1320#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1330#[cfg_attr(feature = "schema", derive(JsonSchema))]
1331#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1332pub struct TemplateTypeLabel {
1333 #[serde(rename = "type-label")]
1335 pub type_label: TypeLabelSource,
1336 #[serde(flatten, default)]
1337 pub rendering: Rendering,
1338
1339 #[serde(skip_serializing_if = "Option::is_none")]
1341 pub custom: Option<HashMap<String, serde_json::Value>>,
1342}
1343
1344#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1347#[cfg_attr(feature = "schema", derive(JsonSchema))]
1348#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1349pub struct TemplateGroup {
1350 pub group: Vec<TemplateComponent>,
1351 #[serde(skip_serializing_if = "Option::is_none")]
1353 pub render_when: Option<TemplateGroupCondition>,
1354 #[serde(skip_serializing_if = "Option::is_none")]
1355 pub delimiter: Option<DelimiterPunctuation>,
1356 #[serde(flatten, default)]
1357 pub rendering: Rendering,
1358
1359 #[serde(skip_serializing_if = "Option::is_none")]
1361 pub custom: Option<HashMap<String, serde_json::Value>>,
1362}
1363
1364#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1366#[cfg_attr(feature = "schema", derive(JsonSchema))]
1367#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1368pub struct TemplateGroupCondition {
1369 #[serde(skip_serializing_if = "Option::is_none")]
1371 pub field_present: Option<TemplateConditionField>,
1372 #[serde(skip_serializing_if = "Option::is_none")]
1374 pub field_absent: Option<TemplateConditionField>,
1375}
1376
1377#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1379#[cfg_attr(feature = "schema", derive(JsonSchema))]
1380#[serde(rename_all = "kebab-case")]
1381pub enum TemplateConditionField {
1382 Author,
1384 Editor,
1386 Recipient,
1388 Translator,
1390 Title,
1392 CollectionTitle,
1394 Issued,
1396 OriginalPublished,
1398 Publisher,
1400 OriginalPublisher,
1402 OriginalPublisherPlace,
1404 OriginalTitle,
1406 Doi,
1408 Genre,
1410 Archive,
1412 ArchiveLocation,
1414}
1415
1416#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1418#[serde(rename_all = "kebab-case")]
1419pub enum DelimiterPunctuation {
1420 #[default]
1421 Comma,
1422 Semicolon,
1423 Period,
1424 Colon,
1425 Ampersand,
1426 VerticalLine,
1427 Slash,
1428 Hyphen,
1429 Space,
1430 None,
1431 #[serde(untagged)]
1433 Custom(String),
1434}
1435
1436#[cfg(feature = "schema")]
1437impl JsonSchema for DelimiterPunctuation {
1438 fn schema_name() -> std::borrow::Cow<'static, str> {
1439 "DelimiterPunctuation".into()
1440 }
1441
1442 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1443 schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1444 }
1445}
1446
1447impl DelimiterPunctuation {
1448 pub fn to_string_with_space(&self) -> String {
1452 match self {
1453 Self::Comma => ", ".to_string(),
1454 Self::Semicolon => "; ".to_string(),
1455 Self::Period => ". ".to_string(),
1456 Self::Colon => ": ".to_string(),
1457 Self::Ampersand => " & ".to_string(),
1458 Self::VerticalLine => " | ".to_string(),
1459 Self::Slash => "/".to_string(),
1460 Self::Hyphen => "-".to_string(),
1461 Self::Space => " ".to_string(),
1462 Self::None => "".to_string(),
1463 Self::Custom(s) => s.clone(),
1464 }
1465 }
1466
1467 pub fn from_csl_string(s: &str) -> Self {
1472 if s == " " {
1473 return Self::Space;
1474 }
1475
1476 let trimmed = s.trim();
1477 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1478 return Self::None;
1479 }
1480
1481 match trimmed {
1482 "," => Self::Comma,
1483 ";" => Self::Semicolon,
1484 "." => Self::Period,
1485 ":" => Self::Colon,
1486 "&" => Self::Ampersand,
1487 "|" => Self::VerticalLine,
1488 "/" => Self::Slash,
1489 "-" => Self::Hyphen,
1490 _ => Self::Custom(s.to_string()),
1491 }
1492 }
1493}
1494
1495#[cfg(test)]
1496#[allow(
1497 clippy::unwrap_used,
1498 clippy::expect_used,
1499 clippy::panic,
1500 clippy::indexing_slicing,
1501 clippy::todo,
1502 clippy::unimplemented,
1503 clippy::unreachable,
1504 clippy::get_unwrap,
1505 reason = "Panicking is acceptable and often desired in tests."
1506)]
1507mod tests {
1508 use super::*;
1509
1510 #[test]
1511 fn test_contributor_deserialization() {
1512 let yaml = r#"
1513contributor: author
1514form: long
1515"#;
1516 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1517 assert_eq!(comp.contributor, ContributorRole::Author);
1518 assert_eq!(comp.form, ContributorForm::Long);
1519 }
1520
1521 #[test]
1522 fn test_template_component_untagged() {
1523 let yaml = r#"
1524- contributor: author
1525 form: short
1526- date: issued
1527 form: year
1528- title: primary
1529"#;
1530 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1531 assert_eq!(components.len(), 3);
1532
1533 match &components[0] {
1534 TemplateComponent::Contributor(c) => {
1535 assert_eq!(c.contributor, ContributorRole::Author);
1536 }
1537 _ => panic!("Expected Contributor"),
1538 }
1539
1540 match &components[1] {
1541 TemplateComponent::Date(d) => {
1542 assert_eq!(d.date, DateVariable::Issued);
1543 }
1544 _ => panic!("Expected Date"),
1545 }
1546 }
1547
1548 #[test]
1549 fn test_flattened_rendering() {
1550 let yaml = r#"
1552- title: parent-monograph
1553 prefix: "In "
1554 emph: true
1555- date: issued
1556 form: year
1557 wrap: parentheses
1558"#;
1559 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1560 assert_eq!(components.len(), 2);
1561
1562 match &components[0] {
1563 TemplateComponent::Title(t) => {
1564 assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1565 assert_eq!(t.rendering.emph, Some(true));
1566 }
1567 _ => panic!("Expected Title"),
1568 }
1569
1570 match &components[1] {
1571 TemplateComponent::Date(d) => {
1572 assert_eq!(
1573 d.rendering.wrap,
1574 Some(WrapConfig {
1575 punctuation: WrapPunctuation::Parentheses,
1576 inner_prefix: None,
1577 inner_suffix: None,
1578 })
1579 );
1580 }
1581 _ => panic!("Expected Date"),
1582 }
1583 }
1584
1585 #[test]
1586 fn test_number_variable_custom_normalizes_manual_construction() {
1587 let number = NumberVariable::Custom("Reel Label".to_string());
1588
1589 assert_eq!(number.as_key(), "reel-label");
1590 assert_eq!(
1591 number,
1592 serde_yaml::from_str::<NumberVariable>("reel-label")
1593 .expect("custom number variable should parse")
1594 );
1595 assert_eq!(
1596 serde_json::to_string(&number).expect("custom number variable should serialize"),
1597 "\"reel-label\""
1598 );
1599 }
1600
1601 #[test]
1602 fn test_contributor_with_wrap() {
1603 let yaml = r#"
1604contributor: publisher
1605form: short
1606wrap: parentheses
1607"#;
1608 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1609 assert_eq!(comp.contributor, ContributorRole::Publisher);
1610 assert_eq!(
1611 comp.rendering.wrap,
1612 Some(WrapConfig {
1613 punctuation: WrapPunctuation::Parentheses,
1614 inner_prefix: None,
1615 inner_suffix: None,
1616 })
1617 );
1618 }
1619
1620 #[test]
1621 fn test_variable_deserialization() {
1622 let yaml = "variable: publisher\n";
1624 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1625 match comp {
1626 TemplateComponent::Variable(v) => {
1627 assert_eq!(v.variable, SimpleVariable::Publisher);
1628 }
1629 _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1630 }
1631 }
1632
1633 #[test]
1634 fn test_message_component_deserialization() {
1635 let yaml = r#"
1636message: pattern.in-container
1637args:
1638 container:
1639 group:
1640 - title: parent-monograph
1641 emph: true
1642text-case: capitalize-first
1643"#;
1644 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1645
1646 match comp {
1647 TemplateComponent::Message(message) => {
1648 assert_eq!(message.message, "pattern.in-container");
1649 assert!(matches!(
1650 message.args.get("container"),
1651 Some(MessageArgSource::Group(group)) if group.group.len() == 1
1652 && matches!(
1653 group.group.first(),
1654 Some(TemplateComponent::Title(title))
1655 if title.title == TitleType::ParentMonograph
1656 && title.rendering.emph == Some(true)
1657 )
1658 ));
1659 assert_eq!(
1660 message.rendering.text_case,
1661 Some(crate::options::titles::TextCase::CapitalizeFirst)
1662 );
1663 }
1664 _ => panic!("Expected Message component, got {comp:?}"),
1665 }
1666 }
1667
1668 #[test]
1669 fn test_term_backed_message_component_deserializes_form() {
1670 let yaml = r#"
1671message: term.in
1672form: long
1673suffix: ":"
1674"#;
1675 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1676
1677 match comp {
1678 TemplateComponent::Message(message) => {
1679 assert_eq!(message.message, "term.in");
1680 assert_eq!(message.form, Some(TermForm::Long));
1681 assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1682 }
1683 _ => panic!("Expected Message component, got {comp:?}"),
1684 }
1685 }
1686
1687 #[test]
1688 fn test_group_deserializes_term_backed_message_component_with_form() {
1689 let yaml = r#"
1690group:
1691- message: term.in
1692 form: long
1693 suffix: ":"
1694- title: parent-monograph
1695"#;
1696 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1697
1698 match comp {
1699 TemplateComponent::Group(group) => {
1700 assert!(matches!(
1701 group.group.first(),
1702 Some(TemplateComponent::Message(message))
1703 if message.message == "term.in"
1704 && message.form == Some(TermForm::Long)
1705 && message.rendering.suffix.as_deref() == Some(":")
1706 ));
1707 }
1708 _ => panic!("Expected Group component, got {comp:?}"),
1709 }
1710 }
1711
1712 #[test]
1713 fn test_variable_array_parsing() {
1714 let yaml = r#"
1715- variable: doi
1716 prefix: "https://doi.org/"
1717- variable: publisher
1718"#;
1719 let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1720 assert_eq!(comps.len(), 2);
1721 match &comps[0] {
1722 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1723 _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1724 }
1725 match &comps[1] {
1726 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1727 _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1728 }
1729 }
1730
1731 #[test]
1732 fn test_type_selector_default_only_matches_default_context() {
1733 let selector = TypeSelector::Single("default".to_string());
1734 assert!(selector.matches("default"));
1735 assert!(!selector.matches("article-journal"));
1736
1737 let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1738 assert!(mixed.matches("default"));
1739 assert!(mixed.matches("chapter"));
1740 assert!(!mixed.matches("book"));
1741 }
1742
1743 #[test]
1744 fn test_template_component_selector_matches_nested_partial_group() {
1745 let component: TemplateComponent = serde_yaml::from_str(
1746 r#"
1747delimiter: ""
1748group:
1749- number: citation-number
1750 wrap:
1751 punctuation: brackets
1752- contributor: author
1753 form: long
1754"#,
1755 )
1756 .unwrap();
1757 let selector = TemplateComponentSelector {
1758 fields: BTreeMap::from([(
1759 "group".to_string(),
1760 serde_json::json!([
1761 { "number": "citation-number" },
1762 { "contributor": "author" }
1763 ]),
1764 )]),
1765 };
1766
1767 assert!(selector.matches(&component));
1768 }
1769
1770 #[test]
1771 fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1772 assert_eq!(
1773 DelimiterPunctuation::from_csl_string("none"),
1774 DelimiterPunctuation::None
1775 );
1776 assert_eq!(
1777 DelimiterPunctuation::from_csl_string(" none "),
1778 DelimiterPunctuation::None
1779 );
1780 assert_eq!(
1781 DelimiterPunctuation::from_csl_string(" "),
1782 DelimiterPunctuation::Space
1783 );
1784 assert_eq!(
1785 DelimiterPunctuation::from_csl_string(" : "),
1786 DelimiterPunctuation::Colon
1787 );
1788 }
1789}