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 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub prefix: Option<String>,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub suffix: Option<String>,
692}
693
694#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
696#[cfg_attr(feature = "schema", derive(JsonSchema))]
697#[serde(rename_all = "kebab-case")]
698pub enum RoleLabelForm {
699 #[default]
700 Short,
701 Long,
702}
703
704#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
706#[cfg_attr(feature = "schema", derive(JsonSchema))]
707#[serde(rename_all = "kebab-case")]
708pub enum LabelPlacement {
709 Prefix,
710 #[default]
711 Suffix,
712}
713
714#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
716#[cfg_attr(feature = "schema", derive(JsonSchema))]
717#[serde(rename_all = "kebab-case", deny_unknown_fields)]
718pub struct TemplateContributor {
719 pub contributor: ContributorRole,
721 pub form: ContributorForm,
723 #[serde(skip_serializing_if = "Option::is_none")]
725 pub label: Option<RoleLabel>,
726 #[serde(skip_serializing_if = "Option::is_none")]
729 pub name_order: Option<NameOrder>,
730 #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
732 pub name_form: Option<crate::options::contributors::NameForm>,
733 #[serde(skip_serializing_if = "Option::is_none")]
735 pub delimiter: Option<String>,
736 #[serde(skip_serializing_if = "Option::is_none")]
738 pub sort_separator: Option<String>,
739 #[serde(skip_serializing_if = "Option::is_none")]
741 pub shorten: Option<crate::options::ShortenListOptions>,
742 #[serde(skip_serializing_if = "Option::is_none")]
745 pub and: Option<crate::options::AndOptions>,
746 #[serde(flatten, default)]
747 pub rendering: Rendering,
748 #[serde(skip_serializing_if = "Option::is_none")]
750 pub links: Option<crate::options::LinksConfig>,
751 #[serde(skip_serializing_if = "Option::is_none")]
753 pub gender: Option<GrammaticalGender>,
754
755 #[serde(skip_serializing_if = "Option::is_none")]
757 pub custom: Option<HashMap<String, serde_json::Value>>,
758}
759
760#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
762#[cfg_attr(feature = "schema", derive(JsonSchema))]
763#[serde(rename_all = "kebab-case")]
764pub enum NameOrder {
765 GivenFirst,
767 #[default]
769 FamilyFirst,
770 FamilyFirstOnly,
772}
773
774#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
776#[cfg_attr(feature = "schema", derive(JsonSchema))]
777#[serde(rename_all = "kebab-case")]
778pub enum ContributorForm {
779 #[default]
780 Long,
781 Short,
782 FamilyOnly,
783 Verb,
784 VerbShort,
785}
786
787crate::str_enum! {
788 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
790 pub enum ContributorRole {
791 #[default] Author = "author",
792 Chair = "chair",
793 Editor = "editor",
794 Translator = "translator",
795 Director = "director",
796 Publisher = "publisher",
797 Recipient = "recipient",
798 Interviewer = "interviewer",
799 Interviewee = "interviewee",
800 Guest = "guest",
801 Performer = "performer",
802 Inventor = "inventor",
803 Counsel = "counsel",
804 Composer = "composer",
805 Writer = "writer",
806 CollectionEditor = "collection-editor",
807 ContainerAuthor = "container-author",
808 EditorialDirector = "editorial-director",
809 TextualEditor = "textual-editor",
810 Illustrator = "illustrator",
811 OriginalAuthor = "original-author",
812 ReviewedAuthor = "reviewed-author"
813 }
814}
815
816#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
818#[cfg_attr(feature = "schema", derive(JsonSchema))]
819#[serde(rename_all = "kebab-case", deny_unknown_fields)]
820pub struct TemplateDate {
821 pub date: DateVariable,
822 pub form: DateForm,
823 #[serde(skip_serializing_if = "Option::is_none")]
825 pub fallback: Option<Vec<TemplateComponent>>,
826 #[serde(flatten, default)]
827 pub rendering: Rendering,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 pub links: Option<crate::options::LinksConfig>,
831
832 #[serde(skip_serializing_if = "Option::is_none")]
834 pub custom: Option<HashMap<String, serde_json::Value>>,
835}
836
837#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
839#[cfg_attr(feature = "schema", derive(JsonSchema))]
840#[serde(rename_all = "kebab-case")]
841pub enum DateVariable {
842 #[default]
843 Issued,
844 Accessed,
845 OriginalPublished,
846 Submitted,
847 EventDate,
848}
849
850crate::str_enum! {
851 #[derive(Debug, Default, Clone, PartialEq)]
853 pub enum DateForm {
854 #[default]
855 Year = "year",
856 YearMonth = "year-month",
857 Month = "month",
860 Full = "full",
861 MonthDay = "month-day",
862 YearMonthDay = "year-month-day",
863 DayMonthAbbrYear = "day-month-abbr-year",
864 MonthAbbrDayYear = "month-abbr-day-year"
866 }
867}
868
869#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
871#[cfg_attr(feature = "schema", derive(JsonSchema))]
872#[serde(rename_all = "kebab-case", deny_unknown_fields)]
873pub struct TemplateTitle {
874 pub title: TitleType,
875 #[serde(skip_serializing_if = "Option::is_none")]
876 pub form: Option<TitleForm>,
877 #[serde(skip_serializing_if = "Option::is_none")]
882 pub disambiguate_only: Option<bool>,
883 #[serde(flatten, default)]
884 pub rendering: Rendering,
885 #[serde(skip_serializing_if = "Option::is_none")]
887 pub links: Option<crate::options::LinksConfig>,
888
889 #[serde(skip_serializing_if = "Option::is_none")]
891 pub custom: Option<HashMap<String, serde_json::Value>>,
892}
893
894#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
896#[cfg_attr(feature = "schema", derive(JsonSchema))]
897#[serde(rename_all = "kebab-case")]
898#[non_exhaustive]
899pub enum TitleType {
900 #[default]
902 Primary,
903 ContainerTitle,
905 ParentMonograph,
907 ParentSerial,
909 CollectionTitle,
911 Original,
913}
914
915#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
917#[cfg_attr(feature = "schema", derive(JsonSchema))]
918#[serde(rename_all = "kebab-case")]
919pub enum TitleForm {
920 Short,
921 #[default]
922 Long,
923}
924
925#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
927#[cfg_attr(feature = "schema", derive(JsonSchema))]
928#[serde(rename_all = "kebab-case", deny_unknown_fields)]
929pub struct TemplateNumber {
930 pub number: NumberVariable,
931 #[serde(skip_serializing_if = "Option::is_none")]
932 pub form: Option<NumberForm>,
933 #[serde(skip_serializing_if = "Option::is_none")]
934 pub label_form: Option<LabelForm>,
935 #[serde(skip_serializing_if = "Option::is_none")]
938 pub show_with_locator: Option<bool>,
939 #[serde(flatten)]
940 pub rendering: Rendering,
941 #[serde(skip_serializing_if = "Option::is_none")]
943 pub links: Option<crate::options::LinksConfig>,
944 #[serde(skip_serializing_if = "Option::is_none")]
946 pub gender: Option<GrammaticalGender>,
947
948 #[serde(skip_serializing_if = "Option::is_none")]
950 pub custom: Option<HashMap<String, serde_json::Value>>,
951}
952
953#[derive(Debug, Default, Clone)]
960#[non_exhaustive]
961pub enum NumberVariable {
962 #[default]
963 Volume,
964 Issue,
965 Pages,
966 Edition,
967 ChapterNumber,
968 CollectionNumber,
969 NumberOfPages,
970 NumberOfVolumes,
971 CitationNumber,
972 FirstReferenceNoteNumber,
976 CitationLabel,
977 Number,
978 DocketNumber,
979 PatentNumber,
980 StandardNumber,
981 ReportNumber,
982 PartNumber,
983 SupplementNumber,
984 PrintingNumber,
985 Custom(String),
987}
988
989impl NumberVariable {
990 #[must_use]
992 pub fn as_key(&self) -> Cow<'_, str> {
993 match self {
994 Self::Volume => Cow::Borrowed("volume"),
995 Self::Issue => Cow::Borrowed("issue"),
996 Self::Pages => Cow::Borrowed("pages"),
997 Self::Edition => Cow::Borrowed("edition"),
998 Self::ChapterNumber => Cow::Borrowed("chapter-number"),
999 Self::CollectionNumber => Cow::Borrowed("collection-number"),
1000 Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1001 Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1002 Self::CitationNumber => Cow::Borrowed("citation-number"),
1003 Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1004 Self::CitationLabel => Cow::Borrowed("citation-label"),
1005 Self::Number => Cow::Borrowed("number"),
1006 Self::DocketNumber => Cow::Borrowed("docket-number"),
1007 Self::PatentNumber => Cow::Borrowed("patent-number"),
1008 Self::StandardNumber => Cow::Borrowed("standard-number"),
1009 Self::ReportNumber => Cow::Borrowed("report-number"),
1010 Self::PartNumber => Cow::Borrowed("part-number"),
1011 Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1012 Self::PrintingNumber => Cow::Borrowed("printing-number"),
1013 Self::Custom(value) => normalize_kind_key(value)
1014 .map(Cow::Owned)
1015 .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1016 }
1017 }
1018
1019 fn from_key(value: &str) -> Result<Self, String> {
1020 let canonical = normalize_kind_key(value)
1021 .ok_or_else(|| "number variable must not be empty".to_string())?;
1022 Ok(match canonical.as_str() {
1023 "volume" => Self::Volume,
1024 "issue" => Self::Issue,
1025 "pages" => Self::Pages,
1026 "edition" => Self::Edition,
1027 "chapter-number" => Self::ChapterNumber,
1028 "collection-number" => Self::CollectionNumber,
1029 "number-of-pages" => Self::NumberOfPages,
1030 "number-of-volumes" => Self::NumberOfVolumes,
1031 "citation-number" => Self::CitationNumber,
1032 "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1033 "citation-label" => Self::CitationLabel,
1034 "number" => Self::Number,
1035 "docket-number" => Self::DocketNumber,
1036 "patent-number" => Self::PatentNumber,
1037 "standard-number" => Self::StandardNumber,
1038 "report-number" => Self::ReportNumber,
1039 "part-number" => Self::PartNumber,
1040 "supplement-number" => Self::SupplementNumber,
1041 "printing-number" => Self::PrintingNumber,
1042 _ => Self::Custom(canonical),
1043 })
1044 }
1045}
1046
1047impl PartialEq for NumberVariable {
1048 fn eq(&self, other: &Self) -> bool {
1049 self.as_key().as_ref() == other.as_key().as_ref()
1050 }
1051}
1052
1053impl Eq for NumberVariable {}
1054
1055impl Hash for NumberVariable {
1056 fn hash<H: Hasher>(&self, state: &mut H) {
1057 self.as_key().as_ref().hash(state);
1058 }
1059}
1060
1061impl Serialize for NumberVariable {
1062 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1063 where
1064 S: Serializer,
1065 {
1066 serializer.serialize_str(self.as_key().as_ref())
1067 }
1068}
1069
1070impl<'de> Deserialize<'de> for NumberVariable {
1071 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1072 where
1073 D: Deserializer<'de>,
1074 {
1075 let value = String::deserialize(deserializer)?;
1076 Self::from_key(&value).map_err(serde::de::Error::custom)
1077 }
1078}
1079
1080#[cfg(feature = "schema")]
1081impl JsonSchema for NumberVariable {
1082 fn schema_name() -> std::borrow::Cow<'static, str> {
1083 "NumberVariable".into()
1084 }
1085
1086 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1087 schemars::json_schema!({
1088 "type": "string",
1089 "description": "Known number variable keyword or custom kebab-case identifier."
1090 })
1091 }
1092}
1093
1094#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1096#[cfg_attr(feature = "schema", derive(JsonSchema))]
1097#[serde(rename_all = "lowercase")]
1098pub enum NumberForm {
1099 #[default]
1100 Numeric,
1101 Ordinal,
1102 Roman,
1103}
1104
1105fn normalize_kind_key(value: &str) -> Option<String> {
1106 let mut normalized = String::new();
1107 let mut pending_dash = false;
1108
1109 for ch in value.trim().chars() {
1110 if ch.is_ascii_alphanumeric() {
1111 if pending_dash && !normalized.is_empty() {
1112 normalized.push('-');
1113 }
1114 normalized.push(ch.to_ascii_lowercase());
1115 pending_dash = false;
1116 } else if !normalized.is_empty() {
1117 pending_dash = true;
1118 }
1119 }
1120
1121 if normalized.is_empty() {
1122 None
1123 } else {
1124 Some(normalized)
1125 }
1126}
1127
1128#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1130#[cfg_attr(feature = "schema", derive(JsonSchema))]
1131#[serde(rename_all = "kebab-case")]
1132pub enum LabelForm {
1133 Long,
1134 #[default]
1135 Short,
1136 Symbol,
1137}
1138
1139#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1141#[cfg_attr(feature = "schema", derive(JsonSchema))]
1142#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1143pub struct TemplateVariable {
1144 pub variable: SimpleVariable,
1145 #[serde(flatten)]
1146 pub rendering: Rendering,
1147 #[serde(skip_serializing_if = "Option::is_none")]
1149 pub links: Option<crate::options::LinksConfig>,
1150
1151 #[serde(skip_serializing_if = "Option::is_none")]
1153 pub custom: Option<HashMap<String, serde_json::Value>>,
1154}
1155
1156#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1162#[cfg_attr(feature = "schema", derive(JsonSchema))]
1163#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1164pub struct TemplateMessage {
1165 pub message: String,
1167 #[serde(skip_serializing_if = "Option::is_none")]
1169 pub form: Option<TermForm>,
1170 #[serde(skip_serializing_if = "Option::is_none")]
1172 pub gender: Option<GrammaticalGender>,
1173 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1175 pub args: HashMap<String, MessageArgSource>,
1176 #[serde(flatten, default)]
1177 pub rendering: Rendering,
1178
1179 #[serde(skip_serializing_if = "Option::is_none")]
1181 pub custom: Option<HashMap<String, serde_json::Value>>,
1182}
1183
1184#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1186#[cfg_attr(feature = "schema", derive(JsonSchema))]
1187#[serde(untagged)]
1188pub enum MessageArgSource {
1189 Literal { literal: String },
1191 Contributor(TemplateContributor),
1193 Date(TemplateDate),
1195 Group(TemplateGroup),
1197 Title(TemplateTitle),
1199 Number(TemplateNumber),
1201 Variable(TemplateVariable),
1203 Term(TemplateTerm),
1205}
1206
1207impl MessageArgSource {
1208 #[must_use]
1211 pub fn as_template_component(&self) -> Option<TemplateComponent> {
1212 match self {
1213 Self::Literal { .. } => None,
1214 Self::Contributor(component) => Some(TemplateComponent::Contributor(component.clone())),
1215 Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1216 Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1217 Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1218 Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1219 Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1220 Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1221 }
1222 }
1223}
1224
1225#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1232#[cfg_attr(feature = "schema", derive(JsonSchema))]
1233#[serde(rename_all = "kebab-case")]
1234#[non_exhaustive]
1235pub enum SimpleVariable {
1236 #[default]
1237 Doi,
1238 Isbn,
1239 Issn,
1240 Url,
1241 Pmid,
1242 Pmcid,
1243 Abstract,
1244 Note,
1245 Annote,
1246 Keyword,
1247 Genre,
1248 RawGenre,
1249 Medium,
1250 RawMedium,
1251 Source,
1252 Status,
1253 Archive,
1254 ArchiveLocation,
1255 ArchiveName,
1256 ArchivePlace,
1257 ArchiveCollection,
1258 ArchiveCollectionId,
1259 ArchiveSeries,
1260 ArchiveBox,
1261 ArchiveFolder,
1262 ArchiveItem,
1263 ArchiveUrl,
1264 EprintId,
1265 EprintServer,
1266 EprintClass,
1267 Publisher,
1268 PublisherPlace,
1269 OriginalPublisher,
1270 OriginalPublisherPlace,
1271 EventTitle,
1272 EventPlace,
1273 Dimensions,
1274 References,
1275 Scale,
1276 Version,
1277 Locator,
1278 ContainerTitleShort,
1279 Authority,
1280 Code,
1281 Reporter,
1282 Page,
1283 Section,
1284 Volume,
1285 Number,
1286 DocketNumber,
1287 PatentNumber,
1288 StandardNumber,
1289 ReportNumber,
1290 AdsBibcode,
1291}
1292
1293#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1295#[cfg_attr(feature = "schema", derive(JsonSchema))]
1296#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1297pub struct TemplateTerm {
1298 pub term: GeneralTerm,
1300 #[serde(skip_serializing_if = "Option::is_none")]
1302 pub form: Option<TermForm>,
1303 #[serde(skip_serializing_if = "Option::is_none")]
1305 pub gender: Option<GrammaticalGender>,
1306 #[serde(flatten, default)]
1307 pub rendering: Rendering,
1308
1309 #[serde(skip_serializing_if = "Option::is_none")]
1311 pub custom: Option<HashMap<String, serde_json::Value>>,
1312}
1313
1314#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1321#[cfg_attr(feature = "schema", derive(JsonSchema))]
1322#[serde(rename_all = "kebab-case")]
1323#[non_exhaustive]
1324pub enum TypeLabelSource {
1325 #[default]
1328 ReferenceType,
1329}
1330
1331#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1341#[cfg_attr(feature = "schema", derive(JsonSchema))]
1342#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1343pub struct TemplateTypeLabel {
1344 #[serde(rename = "type-label")]
1346 pub type_label: TypeLabelSource,
1347 #[serde(flatten, default)]
1348 pub rendering: Rendering,
1349
1350 #[serde(skip_serializing_if = "Option::is_none")]
1352 pub custom: Option<HashMap<String, serde_json::Value>>,
1353}
1354
1355#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1358#[cfg_attr(feature = "schema", derive(JsonSchema))]
1359#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1360pub struct TemplateGroup {
1361 pub group: Vec<TemplateComponent>,
1362 #[serde(skip_serializing_if = "Option::is_none")]
1364 pub render_when: Option<TemplateGroupCondition>,
1365 #[serde(skip_serializing_if = "Option::is_none")]
1366 pub delimiter: Option<DelimiterPunctuation>,
1367 #[serde(flatten, default)]
1368 pub rendering: Rendering,
1369
1370 #[serde(skip_serializing_if = "Option::is_none")]
1372 pub custom: Option<HashMap<String, serde_json::Value>>,
1373}
1374
1375#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1377#[cfg_attr(feature = "schema", derive(JsonSchema))]
1378#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1379pub struct TemplateGroupCondition {
1380 #[serde(skip_serializing_if = "Option::is_none")]
1382 pub field_present: Option<TemplateConditionField>,
1383 #[serde(skip_serializing_if = "Option::is_none")]
1385 pub field_absent: Option<TemplateConditionField>,
1386}
1387
1388#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1390#[cfg_attr(feature = "schema", derive(JsonSchema))]
1391#[serde(rename_all = "kebab-case")]
1392pub enum TemplateConditionField {
1393 Author,
1395 Editor,
1397 Recipient,
1399 Translator,
1401 Title,
1403 CollectionTitle,
1405 Issued,
1407 OriginalPublished,
1409 Publisher,
1411 OriginalPublisher,
1413 OriginalPublisherPlace,
1415 OriginalTitle,
1417 Doi,
1419 Genre,
1421 Archive,
1423 ArchiveLocation,
1425}
1426
1427#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1429#[serde(rename_all = "kebab-case")]
1430pub enum DelimiterPunctuation {
1431 #[default]
1432 Comma,
1433 Semicolon,
1434 Period,
1435 Colon,
1436 Ampersand,
1437 VerticalLine,
1438 Slash,
1439 Hyphen,
1440 Space,
1441 None,
1442 #[serde(untagged)]
1444 Custom(String),
1445}
1446
1447#[cfg(feature = "schema")]
1448impl JsonSchema for DelimiterPunctuation {
1449 fn schema_name() -> std::borrow::Cow<'static, str> {
1450 "DelimiterPunctuation".into()
1451 }
1452
1453 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1454 schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1455 }
1456}
1457
1458impl DelimiterPunctuation {
1459 pub fn to_string_with_space(&self) -> String {
1463 match self {
1464 Self::Comma => ", ".to_string(),
1465 Self::Semicolon => "; ".to_string(),
1466 Self::Period => ". ".to_string(),
1467 Self::Colon => ": ".to_string(),
1468 Self::Ampersand => " & ".to_string(),
1469 Self::VerticalLine => " | ".to_string(),
1470 Self::Slash => "/".to_string(),
1471 Self::Hyphen => "-".to_string(),
1472 Self::Space => " ".to_string(),
1473 Self::None => "".to_string(),
1474 Self::Custom(s) => s.clone(),
1475 }
1476 }
1477
1478 pub fn from_csl_string(s: &str) -> Self {
1483 if s == " " {
1484 return Self::Space;
1485 }
1486
1487 let trimmed = s.trim();
1488 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1489 return Self::None;
1490 }
1491
1492 match trimmed {
1493 "," => Self::Comma,
1494 ";" => Self::Semicolon,
1495 "." => Self::Period,
1496 ":" => Self::Colon,
1497 "&" => Self::Ampersand,
1498 "|" => Self::VerticalLine,
1499 "/" => Self::Slash,
1500 "-" => Self::Hyphen,
1501 _ => Self::Custom(s.to_string()),
1502 }
1503 }
1504}
1505
1506#[cfg(test)]
1507#[allow(
1508 clippy::unwrap_used,
1509 clippy::expect_used,
1510 clippy::panic,
1511 clippy::indexing_slicing,
1512 clippy::todo,
1513 clippy::unimplemented,
1514 clippy::unreachable,
1515 clippy::get_unwrap,
1516 reason = "Panicking is acceptable and often desired in tests."
1517)]
1518mod tests {
1519 use super::*;
1520
1521 #[test]
1522 fn test_contributor_deserialization() {
1523 let yaml = r#"
1524contributor: author
1525form: long
1526"#;
1527 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1528 assert_eq!(comp.contributor, ContributorRole::Author);
1529 assert_eq!(comp.form, ContributorForm::Long);
1530 }
1531
1532 #[test]
1533 fn test_template_component_untagged() {
1534 let yaml = r#"
1535- contributor: author
1536 form: short
1537- date: issued
1538 form: year
1539- title: primary
1540"#;
1541 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1542 assert_eq!(components.len(), 3);
1543
1544 match &components[0] {
1545 TemplateComponent::Contributor(c) => {
1546 assert_eq!(c.contributor, ContributorRole::Author);
1547 }
1548 _ => panic!("Expected Contributor"),
1549 }
1550
1551 match &components[1] {
1552 TemplateComponent::Date(d) => {
1553 assert_eq!(d.date, DateVariable::Issued);
1554 }
1555 _ => panic!("Expected Date"),
1556 }
1557 }
1558
1559 #[test]
1560 fn test_flattened_rendering() {
1561 let yaml = r#"
1563- title: parent-monograph
1564 prefix: "In "
1565 emph: true
1566- date: issued
1567 form: year
1568 wrap: parentheses
1569"#;
1570 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1571 assert_eq!(components.len(), 2);
1572
1573 match &components[0] {
1574 TemplateComponent::Title(t) => {
1575 assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1576 assert_eq!(t.rendering.emph, Some(true));
1577 }
1578 _ => panic!("Expected Title"),
1579 }
1580
1581 match &components[1] {
1582 TemplateComponent::Date(d) => {
1583 assert_eq!(
1584 d.rendering.wrap,
1585 Some(WrapConfig {
1586 punctuation: WrapPunctuation::Parentheses,
1587 inner_prefix: None,
1588 inner_suffix: None,
1589 })
1590 );
1591 }
1592 _ => panic!("Expected Date"),
1593 }
1594 }
1595
1596 #[test]
1597 fn test_number_variable_custom_normalizes_manual_construction() {
1598 let number = NumberVariable::Custom("Reel Label".to_string());
1599
1600 assert_eq!(number.as_key(), "reel-label");
1601 assert_eq!(
1602 number,
1603 serde_yaml::from_str::<NumberVariable>("reel-label")
1604 .expect("custom number variable should parse")
1605 );
1606 assert_eq!(
1607 serde_json::to_string(&number).expect("custom number variable should serialize"),
1608 "\"reel-label\""
1609 );
1610 }
1611
1612 #[test]
1613 fn test_contributor_with_wrap() {
1614 let yaml = r#"
1615contributor: publisher
1616form: short
1617wrap: parentheses
1618"#;
1619 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1620 assert_eq!(comp.contributor, ContributorRole::Publisher);
1621 assert_eq!(
1622 comp.rendering.wrap,
1623 Some(WrapConfig {
1624 punctuation: WrapPunctuation::Parentheses,
1625 inner_prefix: None,
1626 inner_suffix: None,
1627 })
1628 );
1629 }
1630
1631 #[test]
1632 fn test_variable_deserialization() {
1633 let yaml = "variable: publisher\n";
1635 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1636 match comp {
1637 TemplateComponent::Variable(v) => {
1638 assert_eq!(v.variable, SimpleVariable::Publisher);
1639 }
1640 _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1641 }
1642 }
1643
1644 #[test]
1645 fn test_message_component_deserialization() {
1646 let yaml = r#"
1647message: pattern.in-container
1648args:
1649 container:
1650 group:
1651 - title: parent-monograph
1652 emph: true
1653text-case: capitalize-first
1654"#;
1655 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1656
1657 match comp {
1658 TemplateComponent::Message(message) => {
1659 assert_eq!(message.message, "pattern.in-container");
1660 assert!(matches!(
1661 message.args.get("container"),
1662 Some(MessageArgSource::Group(group)) if group.group.len() == 1
1663 && matches!(
1664 group.group.first(),
1665 Some(TemplateComponent::Title(title))
1666 if title.title == TitleType::ParentMonograph
1667 && title.rendering.emph == Some(true)
1668 )
1669 ));
1670 assert_eq!(
1671 message.rendering.text_case,
1672 Some(crate::options::titles::TextCase::CapitalizeFirst)
1673 );
1674 }
1675 _ => panic!("Expected Message component, got {comp:?}"),
1676 }
1677 }
1678
1679 #[test]
1680 fn test_term_backed_message_component_deserializes_form() {
1681 let yaml = r#"
1682message: term.in
1683form: long
1684suffix: ":"
1685"#;
1686 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1687
1688 match comp {
1689 TemplateComponent::Message(message) => {
1690 assert_eq!(message.message, "term.in");
1691 assert_eq!(message.form, Some(TermForm::Long));
1692 assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1693 }
1694 _ => panic!("Expected Message component, got {comp:?}"),
1695 }
1696 }
1697
1698 #[test]
1699 fn test_group_deserializes_term_backed_message_component_with_form() {
1700 let yaml = r#"
1701group:
1702- message: term.in
1703 form: long
1704 suffix: ":"
1705- title: parent-monograph
1706"#;
1707 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1708
1709 match comp {
1710 TemplateComponent::Group(group) => {
1711 assert!(matches!(
1712 group.group.first(),
1713 Some(TemplateComponent::Message(message))
1714 if message.message == "term.in"
1715 && message.form == Some(TermForm::Long)
1716 && message.rendering.suffix.as_deref() == Some(":")
1717 ));
1718 }
1719 _ => panic!("Expected Group component, got {comp:?}"),
1720 }
1721 }
1722
1723 #[test]
1724 fn test_variable_array_parsing() {
1725 let yaml = r#"
1726- variable: doi
1727 prefix: "https://doi.org/"
1728- variable: publisher
1729"#;
1730 let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1731 assert_eq!(comps.len(), 2);
1732 match &comps[0] {
1733 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1734 _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1735 }
1736 match &comps[1] {
1737 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1738 _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1739 }
1740 }
1741
1742 #[test]
1743 fn test_type_selector_default_only_matches_default_context() {
1744 let selector = TypeSelector::Single("default".to_string());
1745 assert!(selector.matches("default"));
1746 assert!(!selector.matches("article-journal"));
1747
1748 let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1749 assert!(mixed.matches("default"));
1750 assert!(mixed.matches("chapter"));
1751 assert!(!mixed.matches("book"));
1752 }
1753
1754 #[test]
1755 fn test_template_component_selector_matches_nested_partial_group() {
1756 let component: TemplateComponent = serde_yaml::from_str(
1757 r#"
1758delimiter: ""
1759group:
1760- number: citation-number
1761 wrap:
1762 punctuation: brackets
1763- contributor: author
1764 form: long
1765"#,
1766 )
1767 .unwrap();
1768 let selector = TemplateComponentSelector {
1769 fields: BTreeMap::from([(
1770 "group".to_string(),
1771 serde_json::json!([
1772 { "number": "citation-number" },
1773 { "contributor": "author" }
1774 ]),
1775 )]),
1776 };
1777
1778 assert!(selector.matches(&component));
1779 }
1780
1781 #[test]
1782 fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1783 assert_eq!(
1784 DelimiterPunctuation::from_csl_string("none"),
1785 DelimiterPunctuation::None
1786 );
1787 assert_eq!(
1788 DelimiterPunctuation::from_csl_string(" none "),
1789 DelimiterPunctuation::None
1790 );
1791 assert_eq!(
1792 DelimiterPunctuation::from_csl_string(" "),
1793 DelimiterPunctuation::Space
1794 );
1795 assert_eq!(
1796 DelimiterPunctuation::from_csl_string(" : "),
1797 DelimiterPunctuation::Colon
1798 );
1799 }
1800}