#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(untagged)]
pub enum ContributorConfigEntry {
Preset(crate::presets::ContributorPreset),
Explicit(Box<ContributorConfig>),
}
impl Default for ContributorConfigEntry {
fn default() -> Self {
ContributorConfigEntry::Explicit(Box::default())
}
}
impl ContributorConfigEntry {
pub fn resolve(&self) -> ContributorConfig {
match self {
ContributorConfigEntry::Preset(preset) => preset.config(),
ContributorConfigEntry::Explicit(config) => *config.clone(),
}
}
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct ContributorConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_as_sort: Option<DisplayAsSort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initialize_with: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initialize_with_hyphen: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shorten: Option<ShortenListOptions>,
#[serde(
default = "default_contributor_delimiter",
skip_serializing_if = "is_default_contributor_delimiter"
)]
pub delimiter: Option<crate::template::DelimiterPunctuation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub and: Option<AndOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delimiter_precedes_last: Option<DelimiterPrecedesLast>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delimiter_precedes_et_al: Option<DelimiterPrecedesLast>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_role_options",
default
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<RoleOptionsEntry>"))]
pub role: Option<RoleOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge: Option<crate::template::ContributorMerge>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suppress: Vec<ContributorSuppressionRule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub demote_non_dropping_particle: Option<DemoteNonDroppingParticle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_separator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name_form: Option<NameForm>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom: Option<HashMap<String, serde_json::Value>>,
#[serde(
flatten,
default,
skip_serializing_if = "std::collections::BTreeMap::is_empty"
)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
}
impl ContributorConfig {
pub fn merge(&mut self, other: &ContributorConfig) {
if other.display_as_sort.is_some() {
self.display_as_sort = other.display_as_sort;
}
if other.initialize_with.is_some() {
self.initialize_with = other.initialize_with.clone();
}
if other.initialize_with_hyphen.is_some() {
self.initialize_with_hyphen = other.initialize_with_hyphen;
}
if other.shorten.is_some() {
self.shorten = other.shorten.clone();
}
if other.delimiter.is_some() {
self.delimiter = other.delimiter.clone();
}
if other.and.is_some() {
self.and = other.and.clone();
}
if other.delimiter_precedes_last.is_some() {
self.delimiter_precedes_last = other.delimiter_precedes_last;
}
if other.delimiter_precedes_et_al.is_some() {
self.delimiter_precedes_et_al = other.delimiter_precedes_et_al;
}
if other.role.is_some() {
self.role = other.role.clone();
}
if other.merge.is_some() {
self.merge = other.merge.clone();
}
if !other.suppress.is_empty() {
self.suppress = other.suppress.clone();
}
if other.demote_non_dropping_particle.is_some() {
self.demote_non_dropping_particle = other.demote_non_dropping_particle;
}
if other.sort_separator.is_some() {
self.sort_separator = other.sort_separator.clone();
}
if other.name_form.is_some() {
self.name_form = other.name_form;
}
}
pub fn role_rendering(
&self,
role: &crate::template::ContributorRole,
) -> Option<&RoleRendering> {
self.role.as_ref()?.role_rendering(role)
}
pub fn effective_role_label_preset(
&self,
role: &crate::template::ContributorRole,
) -> Option<RoleLabelPreset> {
self.role
.as_ref()
.and_then(|role_options| role_options.effective_label_preset(role))
}
pub fn default_role_label_preset(
&self,
role: &crate::template::ContributorRole,
) -> Option<RoleLabelPreset> {
self.role
.as_ref()
.and_then(|role_options| role_options.defaults)
.and_then(|defaults| defaults.preset_for(role))
}
pub fn effective_role_name_order(
&self,
role: &crate::template::ContributorRole,
) -> Option<&crate::template::NameOrder> {
self.role_rendering(role)
.and_then(|rendering| rendering.name_order.as_ref())
}
pub fn role_label_presentation(
&self,
role: &crate::template::ContributorRole,
) -> Option<&RoleLabelPresentation> {
self.role_rendering(role)
.and_then(|rendering| rendering.label.as_ref())
}
#[must_use]
pub fn effective_merge<'a>(
&'a self,
component: Option<&'a crate::template::ContributorMerge>,
) -> std::borrow::Cow<'a, crate::template::ContributorMerge> {
match component.or(self.merge.as_ref()) {
Some(merge) => std::borrow::Cow::Borrowed(merge),
None => std::borrow::Cow::Owned(crate::template::ContributorMerge::default()),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ContributorSuppressionRule {
pub role: crate::template::ContributorRole,
pub when_identical_to: crate::template::ContributorRole,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum RoleLabelPreset {
None,
VerbPrefix,
VerbShortPrefix,
ShortSuffix,
ShortSuffixComma,
LongSuffix,
}
impl RoleLabelPreset {
pub fn from_form_str(form: &str) -> Option<Self> {
match form {
"none" => Some(Self::None),
"verb" => Some(Self::VerbPrefix),
"verb-short" => Some(Self::VerbShortPrefix),
"short" => Some(Self::ShortSuffix),
"short-comma" => Some(Self::ShortSuffixComma),
"long" => Some(Self::LongSuffix),
_ => None,
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum RoleLabelDefaults {
None,
Apa,
Mla,
}
impl RoleLabelDefaults {
pub fn preset_for(&self, role: &crate::template::ContributorRole) -> Option<RoleLabelPreset> {
use crate::template::ContributorRole;
match self {
Self::None => None,
Self::Apa => match role {
ContributorRole::Editor => Some(RoleLabelPreset::ShortSuffix),
ContributorRole::Writer | ContributorRole::Director => {
Some(RoleLabelPreset::LongSuffix)
}
_ => None,
},
Self::Mla => matches!(
role,
ContributorRole::Editor
| ContributorRole::Translator
| ContributorRole::Director
| ContributorRole::Illustrator
| ContributorRole::Interviewer
)
.then_some(RoleLabelPreset::LongSuffix),
}
}
#[must_use]
pub fn presentation_for(
&self,
role: &crate::template::ContributorRole,
) -> Option<RoleLabelPresentation> {
use crate::template::{ContributorRole, LabelPlacement, RoleLabelForm, WrapPunctuation};
let (form, text_case) = match (self, role) {
(Self::Apa, ContributorRole::Writer | ContributorRole::Director) => (
RoleLabelForm::Long,
Some(crate::options::titles::TextCase::Title),
),
_ => return None,
};
Some(RoleLabelPresentation {
form,
placement: LabelPlacement::Suffix,
text_case,
wrap: Some(Box::new(WrapPunctuation::Parentheses.into())),
prefix: None,
suffix: None,
})
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DemoteNonDroppingParticle {
Never,
SortOnly,
#[default]
DisplayAndSort,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum DisplayAsSort {
All,
First,
#[default]
None,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum NameForm {
#[default]
Full,
FamilyOnly,
Initials,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum AndOptions {
Text,
Symbol,
#[default]
None,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(untagged)]
pub enum RoleOptionsEntry {
Preset(RoleLabelPreset),
Explicit(Box<RoleOptions>),
}
impl RoleOptionsEntry {
pub fn resolve(self) -> RoleOptions {
match self {
RoleOptionsEntry::Preset(preset) => RoleOptions {
preset: Some(preset),
..Default::default()
},
RoleOptionsEntry::Explicit(opts) => *opts,
}
}
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct RoleOptions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub omit: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preset: Option<RoleLabelPreset>,
#[serde(skip_serializing_if = "Option::is_none")]
pub form: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub roles: Option<HashMap<String, RoleRendering>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<RoleLabelDefaults>,
}
impl RoleOptions {
pub fn role_rendering(
&self,
role: &crate::template::ContributorRole,
) -> Option<&RoleRendering> {
self.roles.as_ref()?.get(role.as_str())
}
pub fn effective_label_preset(
&self,
role: &crate::template::ContributorRole,
) -> Option<RoleLabelPreset> {
self.role_rendering(role)
.and_then(|rendering| rendering.preset)
.or(self.preset)
.or_else(|| {
self.form
.as_deref()
.and_then(RoleLabelPreset::from_form_str)
})
}
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct RoleRendering {
#[serde(skip_serializing_if = "Option::is_none")]
pub preset: Option<RoleLabelPreset>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<RoleLabelPresentation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emph: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strong: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub small_caps: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name_order: Option<crate::template::NameOrder>,
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct RoleLabelPresentation {
#[serde(default)]
pub form: crate::template::RoleLabelForm,
#[serde(default)]
pub placement: crate::template::LabelPlacement,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_case: Option<crate::options::titles::TextCase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrap: Option<Box<crate::template::WrapConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
}
impl RoleRendering {
pub fn to_rendering(&self) -> crate::template::Rendering {
crate::template::Rendering {
emph: self.emph,
strong: self.strong,
small_caps: self.small_caps,
prefix: self.prefix.clone().map(Into::into),
suffix: self.suffix.clone().map(Into::into),
..Default::default()
}
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DelimiterPrecedesLast {
AfterInvertedName,
Always,
Never,
#[default]
Contextual,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct ShortenListOptions {
pub min: u8,
pub use_first: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_last: Option<u8>,
#[serde(default)]
pub and_others: AndOtherOptions,
#[serde(default)]
pub delimiter_precedes_last: DelimiterPrecedesLast,
#[serde(skip_serializing_if = "Option::is_none")]
pub subsequent_min: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subsequent_use_first: Option<u8>,
}
impl Default for ShortenListOptions {
fn default() -> Self {
Self {
min: 4,
use_first: 1,
use_last: None,
and_others: AndOtherOptions::default(),
delimiter_precedes_last: DelimiterPrecedesLast::default(),
subsequent_min: None,
subsequent_use_first: None,
}
}
}
#[derive(Debug, Default, PartialEq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum AndOtherOptions {
#[default]
EtAl,
Text,
}
fn default_contributor_delimiter() -> Option<crate::template::DelimiterPunctuation> {
Some(crate::template::DelimiterPunctuation::Comma)
}
fn is_default_contributor_delimiter(value: &Option<crate::template::DelimiterPunctuation>) -> bool {
value
.as_ref()
.is_some_and(|delimiter| delimiter.as_default_str() == ", ")
}
fn deserialize_role_options<'de, D>(deserializer: D) -> Result<Option<RoleOptions>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: Option<RoleOptionsEntry> = Option::deserialize(deserializer)?;
Ok(value.map(|entry| entry.resolve()))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
use crate::template::ContributorRole;
#[test]
fn role_label_defaults_none_labels_no_role() {
assert_eq!(
RoleLabelDefaults::None.preset_for(&ContributorRole::Editor),
None
);
assert_eq!(
RoleLabelDefaults::None.preset_for(&ContributorRole::Translator),
None
);
}
#[test]
fn role_label_defaults_apa_labels_primary_audiovisual_roles() {
let apa = RoleLabelDefaults::Apa;
assert_eq!(
apa.preset_for(&ContributorRole::Editor),
Some(RoleLabelPreset::ShortSuffix)
);
assert_eq!(
apa.preset_for(&ContributorRole::Writer),
Some(RoleLabelPreset::LongSuffix)
);
assert_eq!(
apa.preset_for(&ContributorRole::Director),
Some(RoleLabelPreset::LongSuffix)
);
assert_eq!(apa.preset_for(&ContributorRole::Translator), None);
assert_eq!(apa.preset_for(&ContributorRole::Chair), None);
}
#[test]
fn role_label_defaults_mla_labels_documented_role_set() {
let mla = RoleLabelDefaults::Mla;
for role in [
ContributorRole::Editor,
ContributorRole::Translator,
ContributorRole::Director,
ContributorRole::Illustrator,
ContributorRole::Interviewer,
] {
assert_eq!(mla.preset_for(&role), Some(RoleLabelPreset::LongSuffix));
}
assert_eq!(mla.preset_for(&ContributorRole::Composer), None);
}
#[test]
fn role_label_defaults_round_trips_through_yaml() {
let yaml = "defaults: apa\n";
let opts: RoleOptions = serde_yaml::from_str(yaml).expect("valid role options yaml");
assert_eq!(opts.defaults, Some(RoleLabelDefaults::Apa));
let out = serde_yaml::to_string(&opts).expect("serializable role options");
assert_eq!(out, "defaults: apa\n");
}
#[test]
fn style_wide_merge_and_role_presentation_round_trip() {
let yaml = r#"role:
defaults: apa
roles:
director:
label:
form: long
placement: suffix
text-case: title
wrap: parentheses
merge:
order: role
labels: collective
combine-same-person: true
"#;
let config: ContributorConfig =
serde_yaml::from_str(yaml).expect("valid contributor defaults");
assert_eq!(
config.merge.as_ref().map(|merge| merge.labels),
Some(crate::template::ContributorLabelMode::Collective)
);
assert!(
config
.role_label_presentation(&ContributorRole::Director)
.and_then(|label| label.wrap.as_ref())
.is_some()
);
let serialized = serde_yaml::to_string(&config).expect("serializable defaults");
assert_eq!(
serde_yaml::from_str::<ContributorConfig>(&serialized).expect("round-trippable"),
config
);
}
#[test]
fn contributor_config_resolves_default_role_label_preset() {
let config: ContributorConfig =
serde_yaml::from_str("role:\n defaults: mla\n").expect("valid contributor config");
assert_eq!(
config.default_role_label_preset(&ContributorRole::Translator),
Some(RoleLabelPreset::LongSuffix)
);
assert_eq!(
config.default_role_label_preset(&ContributorRole::Composer),
None
);
}
}