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