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")]
685 pub wrap: Option<Box<WrapConfig>>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub prefix: Option<String>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub suffix: Option<String>,
697}
698
699#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
701#[cfg_attr(feature = "schema", derive(JsonSchema))]
702#[serde(rename_all = "kebab-case")]
703pub enum RoleLabelForm {
704 #[default]
705 Short,
706 Long,
707}
708
709#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
711#[cfg_attr(feature = "schema", derive(JsonSchema))]
712#[serde(rename_all = "kebab-case")]
713pub enum LabelPlacement {
714 Prefix,
715 #[default]
716 Suffix,
717}
718
719#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
721#[cfg_attr(feature = "schema", derive(JsonSchema))]
722#[serde(untagged)]
723pub enum ContributorRoles {
724 Single(ContributorRole),
726 Multiple(#[cfg_attr(feature = "schema", schemars(length(min = 2)))] Vec<ContributorRole>),
728}
729
730impl Default for ContributorRoles {
731 fn default() -> Self {
732 Self::Single(ContributorRole::Author)
733 }
734}
735
736impl ContributorRoles {
737 #[must_use]
739 pub fn as_slice(&self) -> &[ContributorRole] {
740 match self {
741 Self::Single(role) => std::slice::from_ref(role),
742 Self::Multiple(roles) => roles,
743 }
744 }
745
746 #[must_use]
748 pub fn as_single(&self) -> Option<&ContributorRole> {
749 match self {
750 Self::Single(role) => Some(role),
751 Self::Multiple(_) => None,
752 }
753 }
754
755 #[must_use]
757 pub fn is_multiple(&self) -> bool {
758 matches!(self, Self::Multiple(_))
759 }
760
761 #[must_use]
763 pub fn contains(&self, role: &ContributorRole) -> bool {
764 self.as_slice().contains(role)
765 }
766}
767
768impl From<ContributorRole> for ContributorRoles {
769 fn from(role: ContributorRole) -> Self {
770 Self::Single(role)
771 }
772}
773
774impl From<Vec<ContributorRole>> for ContributorRoles {
775 fn from(roles: Vec<ContributorRole>) -> Self {
776 Self::Multiple(roles)
777 }
778}
779
780impl PartialEq<ContributorRole> for ContributorRoles {
781 fn eq(&self, other: &ContributorRole) -> bool {
782 self.as_single() == Some(other)
783 }
784}
785
786#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
788#[cfg_attr(feature = "schema", derive(JsonSchema))]
789#[serde(rename_all = "kebab-case")]
790pub enum ContributorMergeOrder {
791 #[default]
793 Document,
794 Role,
796}
797
798#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
800#[cfg_attr(feature = "schema", derive(JsonSchema))]
801#[serde(rename_all = "kebab-case")]
802pub enum ContributorLabelMode {
803 #[default]
805 Individual,
806 Collective,
808 None,
810}
811
812#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
814#[cfg_attr(feature = "schema", derive(JsonSchema))]
815#[serde(rename_all = "kebab-case", deny_unknown_fields)]
816pub struct ContributorMergeRole {
817 #[serde(skip_serializing_if = "Option::is_none")]
819 pub labels: Option<ContributorLabelMode>,
820 #[serde(skip_serializing_if = "Option::is_none")]
822 pub label: Option<RoleLabel>,
823}
824
825#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
827#[cfg_attr(feature = "schema", derive(JsonSchema))]
828#[serde(rename_all = "kebab-case", deny_unknown_fields)]
829pub struct ContributorMerge {
830 #[serde(default)]
832 pub order: ContributorMergeOrder,
833 #[serde(default)]
835 pub labels: ContributorLabelMode,
836 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
838 pub roles: HashMap<ContributorRole, ContributorMergeRole>,
839 #[serde(default = "default_combine_same_person")]
841 pub combine_same_person: bool,
842 #[serde(skip_serializing_if = "Option::is_none")]
844 pub role_conjunction: Option<String>,
845}
846
847fn default_combine_same_person() -> bool {
848 true
849}
850
851impl Default for ContributorMerge {
852 fn default() -> Self {
853 Self {
854 order: ContributorMergeOrder::Document,
855 labels: ContributorLabelMode::Individual,
856 roles: HashMap::new(),
857 combine_same_person: true,
858 role_conjunction: None,
859 }
860 }
861}
862
863#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
865#[cfg_attr(feature = "schema", derive(JsonSchema))]
866#[serde(rename_all = "kebab-case", deny_unknown_fields)]
867pub struct TemplateContributor {
868 pub contributor: ContributorRoles,
870 pub form: ContributorForm,
872 #[serde(skip_serializing_if = "Option::is_none")]
874 pub label: Option<RoleLabel>,
875 #[serde(skip_serializing_if = "Option::is_none")]
877 pub merge: Option<ContributorMerge>,
878 #[serde(skip_serializing_if = "Option::is_none")]
881 pub name_order: Option<NameOrder>,
882 #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
884 pub name_form: Option<crate::options::contributors::NameForm>,
885 #[serde(skip_serializing_if = "Option::is_none")]
887 pub delimiter: Option<String>,
888 #[serde(skip_serializing_if = "Option::is_none")]
890 pub sort_separator: Option<String>,
891 #[serde(skip_serializing_if = "Option::is_none")]
893 pub shorten: Option<crate::options::ShortenListOptions>,
894 #[serde(skip_serializing_if = "Option::is_none")]
897 pub and: Option<crate::options::AndOptions>,
898 #[serde(flatten, default)]
899 pub rendering: Rendering,
900 #[serde(skip_serializing_if = "Option::is_none")]
902 pub links: Option<crate::options::LinksConfig>,
903 #[serde(skip_serializing_if = "Option::is_none")]
905 pub gender: Option<GrammaticalGender>,
906
907 #[serde(skip_serializing_if = "Option::is_none")]
909 pub custom: Option<HashMap<String, serde_json::Value>>,
910}
911
912#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
914#[cfg_attr(feature = "schema", derive(JsonSchema))]
915#[serde(rename_all = "kebab-case")]
916pub enum NameOrder {
917 GivenFirst,
919 #[default]
921 FamilyFirst,
922 FamilyFirstOnly,
924 FamilyFirstExceptLast,
929}
930
931#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
933#[cfg_attr(feature = "schema", derive(JsonSchema))]
934#[serde(rename_all = "kebab-case")]
935pub enum ContributorForm {
936 #[default]
937 Long,
938 Short,
939 FamilyOnly,
940 Verb,
941 VerbShort,
942}
943
944crate::str_enum! {
945 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
947 pub enum ContributorRole {
948 #[default] Author = "author",
949 Chair = "chair",
950 Editor = "editor",
951 Translator = "translator",
952 Director = "director",
953 Publisher = "publisher",
954 Recipient = "recipient",
955 Interviewer = "interviewer",
956 Interviewee = "interviewee",
957 Guest = "guest",
958 Performer = "performer",
959 Inventor = "inventor",
960 Counsel = "counsel",
961 Composer = "composer",
962 Writer = "writer",
963 Producer = "producer",
964 CollectionEditor = "collection-editor",
965 ContainerAuthor = "container-author",
966 EditorialDirector = "editorial-director",
967 TextualEditor = "textual-editor",
968 Illustrator = "illustrator",
969 OriginalAuthor = "original-author",
970 ReviewedAuthor = "reviewed-author"
971 }
972}
973
974#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
976#[cfg_attr(feature = "schema", derive(JsonSchema))]
977#[serde(rename_all = "kebab-case", deny_unknown_fields)]
978pub struct TemplateDate {
979 pub date: DateVariable,
980 pub form: DateForm,
981 #[serde(skip_serializing_if = "Option::is_none")]
983 pub fallback: Option<Vec<TemplateComponent>>,
984 #[serde(flatten, default)]
985 pub rendering: Rendering,
986 #[serde(skip_serializing_if = "Option::is_none")]
988 pub links: Option<crate::options::LinksConfig>,
989
990 #[serde(skip_serializing_if = "Option::is_none")]
992 pub custom: Option<HashMap<String, serde_json::Value>>,
993}
994
995#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
997#[cfg_attr(feature = "schema", derive(JsonSchema))]
998#[serde(rename_all = "kebab-case")]
999pub enum DateVariable {
1000 #[default]
1001 Issued,
1002 Accessed,
1003 OriginalPublished,
1004 Submitted,
1005 EventDate,
1006}
1007
1008crate::str_enum! {
1009 #[derive(Debug, Default, Clone, PartialEq)]
1011 pub enum DateForm {
1012 #[default]
1013 Year = "year",
1014 YearMonth = "year-month",
1015 Month = "month",
1018 Full = "full",
1019 MonthDay = "month-day",
1020 YearMonthDay = "year-month-day",
1021 DayMonthAbbrYear = "day-month-abbr-year",
1022 MonthAbbrDayYear = "month-abbr-day-year"
1024 }
1025}
1026
1027#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1029#[cfg_attr(feature = "schema", derive(JsonSchema))]
1030#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1031pub struct TemplateTitle {
1032 pub title: TitleType,
1033 #[serde(skip_serializing_if = "Option::is_none")]
1034 pub form: Option<TitleForm>,
1035 #[serde(skip_serializing_if = "Option::is_none")]
1040 pub disambiguate_only: Option<bool>,
1041 #[serde(flatten, default)]
1042 pub rendering: Rendering,
1043 #[serde(skip_serializing_if = "Option::is_none")]
1045 pub links: Option<crate::options::LinksConfig>,
1046
1047 #[serde(skip_serializing_if = "Option::is_none")]
1049 pub custom: Option<HashMap<String, serde_json::Value>>,
1050}
1051
1052#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1054#[cfg_attr(feature = "schema", derive(JsonSchema))]
1055#[serde(rename_all = "kebab-case")]
1056#[non_exhaustive]
1057pub enum TitleType {
1058 #[default]
1060 Primary,
1061 ContainerTitle,
1063 ParentMonograph,
1065 ParentSerial,
1067 CollectionTitle,
1069 Original,
1071}
1072
1073#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1075#[cfg_attr(feature = "schema", derive(JsonSchema))]
1076#[serde(rename_all = "kebab-case")]
1077pub enum TitleForm {
1078 Short,
1079 #[default]
1080 Long,
1081}
1082
1083#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1085#[cfg_attr(feature = "schema", derive(JsonSchema))]
1086#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1087pub struct TemplateNumber {
1088 pub number: NumberVariable,
1089 #[serde(skip_serializing_if = "Option::is_none")]
1090 pub form: Option<NumberForm>,
1091 #[serde(skip_serializing_if = "Option::is_none")]
1092 pub label_form: Option<LabelForm>,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub show_with_locator: Option<bool>,
1097 #[serde(flatten)]
1098 pub rendering: Rendering,
1099 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub links: Option<crate::options::LinksConfig>,
1102 #[serde(skip_serializing_if = "Option::is_none")]
1104 pub gender: Option<GrammaticalGender>,
1105
1106 #[serde(skip_serializing_if = "Option::is_none")]
1108 pub custom: Option<HashMap<String, serde_json::Value>>,
1109}
1110
1111#[derive(Debug, Default, Clone)]
1118#[non_exhaustive]
1119pub enum NumberVariable {
1120 #[default]
1121 Volume,
1122 Issue,
1123 Pages,
1124 Edition,
1125 ChapterNumber,
1126 CollectionNumber,
1127 NumberOfPages,
1128 NumberOfVolumes,
1129 CitationNumber,
1130 FirstReferenceNoteNumber,
1134 CitationLabel,
1135 Number,
1136 DocketNumber,
1137 PatentNumber,
1138 StandardNumber,
1139 ReportNumber,
1140 PartNumber,
1141 SupplementNumber,
1142 PrintingNumber,
1143 Custom(String),
1145}
1146
1147impl NumberVariable {
1148 #[must_use]
1150 pub fn as_key(&self) -> Cow<'_, str> {
1151 match self {
1152 Self::Volume => Cow::Borrowed("volume"),
1153 Self::Issue => Cow::Borrowed("issue"),
1154 Self::Pages => Cow::Borrowed("pages"),
1155 Self::Edition => Cow::Borrowed("edition"),
1156 Self::ChapterNumber => Cow::Borrowed("chapter-number"),
1157 Self::CollectionNumber => Cow::Borrowed("collection-number"),
1158 Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1159 Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1160 Self::CitationNumber => Cow::Borrowed("citation-number"),
1161 Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1162 Self::CitationLabel => Cow::Borrowed("citation-label"),
1163 Self::Number => Cow::Borrowed("number"),
1164 Self::DocketNumber => Cow::Borrowed("docket-number"),
1165 Self::PatentNumber => Cow::Borrowed("patent-number"),
1166 Self::StandardNumber => Cow::Borrowed("standard-number"),
1167 Self::ReportNumber => Cow::Borrowed("report-number"),
1168 Self::PartNumber => Cow::Borrowed("part-number"),
1169 Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1170 Self::PrintingNumber => Cow::Borrowed("printing-number"),
1171 Self::Custom(value) => normalize_kind_key(value)
1172 .map(Cow::Owned)
1173 .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1174 }
1175 }
1176
1177 fn from_key(value: &str) -> Result<Self, String> {
1178 let canonical = normalize_kind_key(value)
1179 .ok_or_else(|| "number variable must not be empty".to_string())?;
1180 Ok(match canonical.as_str() {
1181 "volume" => Self::Volume,
1182 "issue" => Self::Issue,
1183 "pages" => Self::Pages,
1184 "edition" => Self::Edition,
1185 "chapter-number" => Self::ChapterNumber,
1186 "collection-number" => Self::CollectionNumber,
1187 "number-of-pages" => Self::NumberOfPages,
1188 "number-of-volumes" => Self::NumberOfVolumes,
1189 "citation-number" => Self::CitationNumber,
1190 "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1191 "citation-label" => Self::CitationLabel,
1192 "number" => Self::Number,
1193 "docket-number" => Self::DocketNumber,
1194 "patent-number" => Self::PatentNumber,
1195 "standard-number" => Self::StandardNumber,
1196 "report-number" => Self::ReportNumber,
1197 "part-number" => Self::PartNumber,
1198 "supplement-number" => Self::SupplementNumber,
1199 "printing-number" => Self::PrintingNumber,
1200 _ => Self::Custom(canonical),
1201 })
1202 }
1203}
1204
1205impl PartialEq for NumberVariable {
1206 fn eq(&self, other: &Self) -> bool {
1207 self.as_key().as_ref() == other.as_key().as_ref()
1208 }
1209}
1210
1211impl Eq for NumberVariable {}
1212
1213impl Hash for NumberVariable {
1214 fn hash<H: Hasher>(&self, state: &mut H) {
1215 self.as_key().as_ref().hash(state);
1216 }
1217}
1218
1219impl Serialize for NumberVariable {
1220 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1221 where
1222 S: Serializer,
1223 {
1224 serializer.serialize_str(self.as_key().as_ref())
1225 }
1226}
1227
1228impl<'de> Deserialize<'de> for NumberVariable {
1229 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1230 where
1231 D: Deserializer<'de>,
1232 {
1233 let value = String::deserialize(deserializer)?;
1234 Self::from_key(&value).map_err(serde::de::Error::custom)
1235 }
1236}
1237
1238#[cfg(feature = "schema")]
1239impl JsonSchema for NumberVariable {
1240 fn schema_name() -> std::borrow::Cow<'static, str> {
1241 "NumberVariable".into()
1242 }
1243
1244 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1245 schemars::json_schema!({
1246 "type": "string",
1247 "description": "Known number variable keyword or custom kebab-case identifier."
1248 })
1249 }
1250}
1251
1252#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1254#[cfg_attr(feature = "schema", derive(JsonSchema))]
1255#[serde(rename_all = "lowercase")]
1256pub enum NumberForm {
1257 #[default]
1258 Numeric,
1259 Ordinal,
1260 Roman,
1261}
1262
1263fn normalize_kind_key(value: &str) -> Option<String> {
1264 let mut normalized = String::new();
1265 let mut pending_dash = false;
1266
1267 for ch in value.trim().chars() {
1268 if ch.is_ascii_alphanumeric() {
1269 if pending_dash && !normalized.is_empty() {
1270 normalized.push('-');
1271 }
1272 normalized.push(ch.to_ascii_lowercase());
1273 pending_dash = false;
1274 } else if !normalized.is_empty() {
1275 pending_dash = true;
1276 }
1277 }
1278
1279 if normalized.is_empty() {
1280 None
1281 } else {
1282 Some(normalized)
1283 }
1284}
1285
1286#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1288#[cfg_attr(feature = "schema", derive(JsonSchema))]
1289#[serde(rename_all = "kebab-case")]
1290pub enum LabelForm {
1291 Long,
1292 #[default]
1293 Short,
1294 Symbol,
1295}
1296
1297#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1299#[cfg_attr(feature = "schema", derive(JsonSchema))]
1300#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1301pub struct TemplateVariable {
1302 pub variable: SimpleVariable,
1303 #[serde(flatten)]
1304 pub rendering: Rendering,
1305 #[serde(skip_serializing_if = "Option::is_none")]
1307 pub links: Option<crate::options::LinksConfig>,
1308
1309 #[serde(skip_serializing_if = "Option::is_none")]
1311 pub custom: Option<HashMap<String, serde_json::Value>>,
1312}
1313
1314#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1320#[cfg_attr(feature = "schema", derive(JsonSchema))]
1321#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1322pub struct TemplateMessage {
1323 pub message: String,
1325 #[serde(skip_serializing_if = "Option::is_none")]
1327 pub form: Option<TermForm>,
1328 #[serde(skip_serializing_if = "Option::is_none")]
1330 pub gender: Option<GrammaticalGender>,
1331 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1333 pub args: HashMap<String, MessageArgSource>,
1334 #[serde(flatten, default)]
1335 pub rendering: Rendering,
1336
1337 #[serde(skip_serializing_if = "Option::is_none")]
1339 pub custom: Option<HashMap<String, serde_json::Value>>,
1340}
1341
1342#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1344#[cfg_attr(feature = "schema", derive(JsonSchema))]
1345#[serde(untagged)]
1346pub enum MessageArgSource {
1347 Literal { literal: String },
1349 Contributor(Box<TemplateContributor>),
1351 Date(TemplateDate),
1353 Group(TemplateGroup),
1355 Title(TemplateTitle),
1357 Number(TemplateNumber),
1359 Variable(TemplateVariable),
1361 Term(TemplateTerm),
1363}
1364
1365impl MessageArgSource {
1366 #[must_use]
1369 pub fn as_template_component(&self) -> Option<TemplateComponent> {
1370 match self {
1371 Self::Literal { .. } => None,
1372 Self::Contributor(component) => {
1373 Some(TemplateComponent::Contributor(component.as_ref().clone()))
1374 }
1375 Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1376 Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1377 Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1378 Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1379 Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1380 Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1381 }
1382 }
1383}
1384
1385#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1392#[cfg_attr(feature = "schema", derive(JsonSchema))]
1393#[serde(rename_all = "kebab-case")]
1394#[non_exhaustive]
1395pub enum SimpleVariable {
1396 #[default]
1397 Doi,
1398 Isbn,
1399 Issn,
1400 Url,
1401 Pmid,
1402 Pmcid,
1403 Abstract,
1404 Note,
1405 Annote,
1406 Keyword,
1407 Genre,
1408 RawGenre,
1409 Medium,
1410 RawMedium,
1411 Source,
1412 Status,
1413 Archive,
1414 ArchiveLocation,
1415 ArchiveName,
1416 ArchivePlace,
1417 ArchiveCollection,
1418 ArchiveCollectionId,
1419 ArchiveSeries,
1420 ArchiveBox,
1421 ArchiveFolder,
1422 ArchiveItem,
1423 ArchiveUrl,
1424 EprintId,
1425 EprintServer,
1426 EprintClass,
1427 Publisher,
1428 PublisherPlace,
1429 OriginalPublisher,
1430 OriginalPublisherPlace,
1431 EventTitle,
1432 EventPlace,
1433 Dimensions,
1434 References,
1435 Scale,
1436 Version,
1437 Locator,
1438 ContainerTitleShort,
1439 Authority,
1440 Code,
1441 Reporter,
1442 Page,
1443 Section,
1444 Volume,
1445 Number,
1446 DocketNumber,
1447 PatentNumber,
1448 StandardNumber,
1449 ReportNumber,
1450 AdsBibcode,
1451}
1452
1453#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1455#[cfg_attr(feature = "schema", derive(JsonSchema))]
1456#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1457pub struct TemplateTerm {
1458 pub term: GeneralTerm,
1460 #[serde(skip_serializing_if = "Option::is_none")]
1462 pub form: Option<TermForm>,
1463 #[serde(skip_serializing_if = "Option::is_none")]
1465 pub gender: Option<GrammaticalGender>,
1466 #[serde(flatten, default)]
1467 pub rendering: Rendering,
1468
1469 #[serde(skip_serializing_if = "Option::is_none")]
1471 pub custom: Option<HashMap<String, serde_json::Value>>,
1472}
1473
1474#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1481#[cfg_attr(feature = "schema", derive(JsonSchema))]
1482#[serde(rename_all = "kebab-case")]
1483#[non_exhaustive]
1484pub enum TypeLabelSource {
1485 #[default]
1488 ReferenceType,
1489}
1490
1491#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1501#[cfg_attr(feature = "schema", derive(JsonSchema))]
1502#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1503pub struct TemplateTypeLabel {
1504 #[serde(rename = "type-label")]
1506 pub type_label: TypeLabelSource,
1507 #[serde(flatten, default)]
1508 pub rendering: Rendering,
1509
1510 #[serde(skip_serializing_if = "Option::is_none")]
1512 pub custom: Option<HashMap<String, serde_json::Value>>,
1513}
1514
1515#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1518#[cfg_attr(feature = "schema", derive(JsonSchema))]
1519#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1520pub struct TemplateGroup {
1521 pub group: Vec<TemplateComponent>,
1522 #[serde(skip_serializing_if = "Option::is_none")]
1524 pub render_when: Option<TemplateGroupCondition>,
1525 #[serde(skip_serializing_if = "Option::is_none")]
1526 pub delimiter: Option<DelimiterPunctuation>,
1527 #[serde(flatten, default)]
1528 pub rendering: Rendering,
1529
1530 #[serde(skip_serializing_if = "Option::is_none")]
1532 pub custom: Option<HashMap<String, serde_json::Value>>,
1533}
1534
1535#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1537#[cfg_attr(feature = "schema", derive(JsonSchema))]
1538#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1539pub struct TemplateGroupCondition {
1540 #[serde(skip_serializing_if = "Option::is_none")]
1542 pub field_present: Option<TemplateConditionField>,
1543 #[serde(skip_serializing_if = "Option::is_none")]
1545 pub field_absent: Option<TemplateConditionField>,
1546}
1547
1548#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1550#[cfg_attr(feature = "schema", derive(JsonSchema))]
1551#[serde(rename_all = "kebab-case")]
1552pub enum TemplateConditionField {
1553 Author,
1555 Editor,
1557 Recipient,
1559 Translator,
1561 Title,
1563 CollectionTitle,
1565 Issued,
1567 OriginalPublished,
1569 Publisher,
1571 OriginalPublisher,
1573 OriginalPublisherPlace,
1575 OriginalTitle,
1577 Doi,
1579 Genre,
1581 Archive,
1583 ArchiveLocation,
1585}
1586
1587#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1589#[serde(rename_all = "kebab-case")]
1590pub enum DelimiterPunctuation {
1591 #[default]
1592 Comma,
1593 Semicolon,
1594 Period,
1595 Colon,
1596 Ampersand,
1597 VerticalLine,
1598 Slash,
1599 Hyphen,
1600 Space,
1601 None,
1602 #[serde(untagged)]
1604 Custom(String),
1605}
1606
1607#[cfg(feature = "schema")]
1608impl JsonSchema for DelimiterPunctuation {
1609 fn schema_name() -> std::borrow::Cow<'static, str> {
1610 "DelimiterPunctuation".into()
1611 }
1612
1613 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1614 schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1615 }
1616}
1617
1618impl DelimiterPunctuation {
1619 pub fn to_string_with_space(&self) -> String {
1623 match self {
1624 Self::Comma => ", ".to_string(),
1625 Self::Semicolon => "; ".to_string(),
1626 Self::Period => ". ".to_string(),
1627 Self::Colon => ": ".to_string(),
1628 Self::Ampersand => " & ".to_string(),
1629 Self::VerticalLine => " | ".to_string(),
1630 Self::Slash => "/".to_string(),
1631 Self::Hyphen => "-".to_string(),
1632 Self::Space => " ".to_string(),
1633 Self::None => "".to_string(),
1634 Self::Custom(s) => s.clone(),
1635 }
1636 }
1637
1638 pub fn from_csl_string(s: &str) -> Self {
1643 if s == " " {
1644 return Self::Space;
1645 }
1646
1647 let trimmed = s.trim();
1648 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1649 return Self::None;
1650 }
1651
1652 match trimmed {
1653 "," => Self::Comma,
1654 ";" => Self::Semicolon,
1655 "." => Self::Period,
1656 ":" => Self::Colon,
1657 "&" => Self::Ampersand,
1658 "|" => Self::VerticalLine,
1659 "/" => Self::Slash,
1660 "-" => Self::Hyphen,
1661 _ => Self::Custom(s.to_string()),
1662 }
1663 }
1664}
1665
1666#[cfg(test)]
1667#[allow(
1668 clippy::unwrap_used,
1669 clippy::expect_used,
1670 clippy::panic,
1671 clippy::indexing_slicing,
1672 clippy::todo,
1673 clippy::unimplemented,
1674 clippy::unreachable,
1675 clippy::get_unwrap,
1676 reason = "Panicking is acceptable and often desired in tests."
1677)]
1678mod tests {
1679 use super::*;
1680
1681 #[test]
1682 fn test_contributor_deserialization() {
1683 let yaml = r#"
1684contributor: author
1685form: long
1686"#;
1687 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1688 assert_eq!(comp.contributor, ContributorRole::Author);
1689 assert_eq!(comp.form, ContributorForm::Long);
1690 }
1691
1692 #[test]
1693 fn test_contributor_name_order_family_first_except_last_deserialization() {
1694 let yaml = r#"
1695contributor: author
1696form: long
1697name-order: family-first-except-last
1698"#;
1699 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1700 assert_eq!(comp.name_order, Some(NameOrder::FamilyFirstExceptLast));
1701 }
1702
1703 #[test]
1704 fn test_template_component_untagged() {
1705 let yaml = r#"
1706- contributor: author
1707 form: short
1708- date: issued
1709 form: year
1710- title: primary
1711"#;
1712 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1713 assert_eq!(components.len(), 3);
1714
1715 match &components[0] {
1716 TemplateComponent::Contributor(c) => {
1717 assert_eq!(c.contributor, ContributorRole::Author);
1718 }
1719 _ => panic!("Expected Contributor"),
1720 }
1721
1722 match &components[1] {
1723 TemplateComponent::Date(d) => {
1724 assert_eq!(d.date, DateVariable::Issued);
1725 }
1726 _ => panic!("Expected Date"),
1727 }
1728 }
1729
1730 #[test]
1731 fn test_flattened_rendering() {
1732 let yaml = r#"
1734- title: parent-monograph
1735 prefix: "In "
1736 emph: true
1737- date: issued
1738 form: year
1739 wrap: parentheses
1740"#;
1741 let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1742 assert_eq!(components.len(), 2);
1743
1744 match &components[0] {
1745 TemplateComponent::Title(t) => {
1746 assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1747 assert_eq!(t.rendering.emph, Some(true));
1748 }
1749 _ => panic!("Expected Title"),
1750 }
1751
1752 match &components[1] {
1753 TemplateComponent::Date(d) => {
1754 assert_eq!(
1755 d.rendering.wrap,
1756 Some(WrapConfig {
1757 punctuation: WrapPunctuation::Parentheses,
1758 inner_prefix: None,
1759 inner_suffix: None,
1760 })
1761 );
1762 }
1763 _ => panic!("Expected Date"),
1764 }
1765 }
1766
1767 #[test]
1768 fn test_number_variable_custom_normalizes_manual_construction() {
1769 let number = NumberVariable::Custom("Reel Label".to_string());
1770
1771 assert_eq!(number.as_key(), "reel-label");
1772 assert_eq!(
1773 number,
1774 serde_yaml::from_str::<NumberVariable>("reel-label")
1775 .expect("custom number variable should parse")
1776 );
1777 assert_eq!(
1778 serde_json::to_string(&number).expect("custom number variable should serialize"),
1779 "\"reel-label\""
1780 );
1781 }
1782
1783 #[test]
1784 fn test_contributor_with_wrap() {
1785 let yaml = r#"
1786contributor: publisher
1787form: short
1788wrap: parentheses
1789"#;
1790 let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1791 assert_eq!(comp.contributor, ContributorRole::Publisher);
1792 assert_eq!(
1793 comp.rendering.wrap,
1794 Some(WrapConfig {
1795 punctuation: WrapPunctuation::Parentheses,
1796 inner_prefix: None,
1797 inner_suffix: None,
1798 })
1799 );
1800 }
1801
1802 #[test]
1803 fn test_variable_deserialization() {
1804 let yaml = "variable: publisher\n";
1806 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1807 match comp {
1808 TemplateComponent::Variable(v) => {
1809 assert_eq!(v.variable, SimpleVariable::Publisher);
1810 }
1811 _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1812 }
1813 }
1814
1815 #[test]
1816 fn test_message_component_deserialization() {
1817 let yaml = r#"
1818message: pattern.in-container
1819args:
1820 container:
1821 group:
1822 - title: parent-monograph
1823 emph: true
1824text-case: capitalize-first
1825"#;
1826 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1827
1828 match comp {
1829 TemplateComponent::Message(message) => {
1830 assert_eq!(message.message, "pattern.in-container");
1831 assert!(matches!(
1832 message.args.get("container"),
1833 Some(MessageArgSource::Group(group)) if group.group.len() == 1
1834 && matches!(
1835 group.group.first(),
1836 Some(TemplateComponent::Title(title))
1837 if title.title == TitleType::ParentMonograph
1838 && title.rendering.emph == Some(true)
1839 )
1840 ));
1841 assert_eq!(
1842 message.rendering.text_case,
1843 Some(crate::options::titles::TextCase::CapitalizeFirst)
1844 );
1845 }
1846 _ => panic!("Expected Message component, got {comp:?}"),
1847 }
1848 }
1849
1850 #[test]
1851 fn test_term_backed_message_component_deserializes_form() {
1852 let yaml = r#"
1853message: term.in
1854form: long
1855suffix: ":"
1856"#;
1857 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1858
1859 match comp {
1860 TemplateComponent::Message(message) => {
1861 assert_eq!(message.message, "term.in");
1862 assert_eq!(message.form, Some(TermForm::Long));
1863 assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1864 }
1865 _ => panic!("Expected Message component, got {comp:?}"),
1866 }
1867 }
1868
1869 #[test]
1870 fn test_group_deserializes_term_backed_message_component_with_form() {
1871 let yaml = r#"
1872group:
1873- message: term.in
1874 form: long
1875 suffix: ":"
1876- title: parent-monograph
1877"#;
1878 let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1879
1880 match comp {
1881 TemplateComponent::Group(group) => {
1882 assert!(matches!(
1883 group.group.first(),
1884 Some(TemplateComponent::Message(message))
1885 if message.message == "term.in"
1886 && message.form == Some(TermForm::Long)
1887 && message.rendering.suffix.as_deref() == Some(":")
1888 ));
1889 }
1890 _ => panic!("Expected Group component, got {comp:?}"),
1891 }
1892 }
1893
1894 #[test]
1895 fn test_variable_array_parsing() {
1896 let yaml = r#"
1897- variable: doi
1898 prefix: "https://doi.org/"
1899- variable: publisher
1900"#;
1901 let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1902 assert_eq!(comps.len(), 2);
1903 match &comps[0] {
1904 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1905 _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1906 }
1907 match &comps[1] {
1908 TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1909 _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1910 }
1911 }
1912
1913 #[test]
1914 fn test_type_selector_default_only_matches_default_context() {
1915 let selector = TypeSelector::Single("default".to_string());
1916 assert!(selector.matches("default"));
1917 assert!(!selector.matches("article-journal"));
1918
1919 let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1920 assert!(mixed.matches("default"));
1921 assert!(mixed.matches("chapter"));
1922 assert!(!mixed.matches("book"));
1923 }
1924
1925 #[test]
1926 fn test_template_component_selector_matches_nested_partial_group() {
1927 let component: TemplateComponent = serde_yaml::from_str(
1928 r#"
1929delimiter: ""
1930group:
1931- number: citation-number
1932 wrap:
1933 punctuation: brackets
1934- contributor: author
1935 form: long
1936"#,
1937 )
1938 .unwrap();
1939 let selector = TemplateComponentSelector {
1940 fields: BTreeMap::from([(
1941 "group".to_string(),
1942 serde_json::json!([
1943 { "number": "citation-number" },
1944 { "contributor": "author" }
1945 ]),
1946 )]),
1947 };
1948
1949 assert!(selector.matches(&component));
1950 }
1951
1952 #[test]
1953 fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1954 assert_eq!(
1955 DelimiterPunctuation::from_csl_string("none"),
1956 DelimiterPunctuation::None
1957 );
1958 assert_eq!(
1959 DelimiterPunctuation::from_csl_string(" none "),
1960 DelimiterPunctuation::None
1961 );
1962 assert_eq!(
1963 DelimiterPunctuation::from_csl_string(" "),
1964 DelimiterPunctuation::Space
1965 );
1966 assert_eq!(
1967 DelimiterPunctuation::from_csl_string(" : "),
1968 DelimiterPunctuation::Colon
1969 );
1970 }
1971}