#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(untagged)]
pub enum SubstituteConfig {
Preset(crate::presets::SubstitutePreset),
Explicit(Substitute),
}
impl Default for SubstituteConfig {
fn default() -> Self {
SubstituteConfig::Explicit(Substitute::default())
}
}
impl SubstituteConfig {
pub fn resolve(&self) -> Substitute {
match self {
SubstituteConfig::Preset(preset) => preset.config(),
SubstituteConfig::Explicit(config) => config.clone(),
}
}
pub fn resolve_ref(&self) -> Cow<'_, Substitute> {
match self {
SubstituteConfig::Preset(preset) => Cow::Owned(preset.config()),
SubstituteConfig::Explicit(config) => Cow::Borrowed(config),
}
}
pub fn resolve_or_default(config: Option<&Self>) -> Cow<'_, Substitute> {
config.map_or_else(
|| Cow::Owned(Substitute::default()),
SubstituteConfig::resolve_ref,
)
}
#[must_use]
pub fn merged(base: &Self, override_config: &Self) -> Self {
SubstituteConfig::Explicit(Substitute::merged(
&base.resolve(),
&override_config.resolve(),
))
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct Substitute {
#[serde(skip_serializing_if = "Option::is_none")]
pub contributor_role_form: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contributor_role_case: Option<crate::options::titles::TextCase>,
#[serde(default)]
pub template: Vec<SubstituteKey>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub overrides: HashMap<String, Vec<SubstituteKey>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub role_substitute: HashMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_quote: Option<SubstituteTitleQuoteMode>,
#[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 Default for Substitute {
fn default() -> Self {
Self {
contributor_role_form: None,
contributor_role_case: None,
template: vec![
SubstituteKey::Field(SubstituteField::Editor),
SubstituteKey::Field(SubstituteField::Title),
SubstituteKey::Field(SubstituteField::Translator),
],
overrides: HashMap::new(),
role_substitute: HashMap::new(),
title_quote: None,
unknown_fields: std::collections::BTreeMap::new(),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SubstituteTitleQuoteMode {
Always,
ByCategory,
}
impl Substitute {
pub fn merge(&mut self, other: &Self) {
if other.contributor_role_form.is_some() {
self.contributor_role_form = other.contributor_role_form.clone();
}
if other.contributor_role_case.is_some() {
self.contributor_role_case = other.contributor_role_case;
}
if !other.template.is_empty() {
self.template = other.template.clone();
}
self.overrides.extend(other.overrides.clone());
self.role_substitute.extend(other.role_substitute.clone());
if other.title_quote.is_some() {
self.title_quote = other.title_quote;
}
}
#[must_use]
pub fn merged(base: &Self, override_config: &Self) -> Self {
let mut result = base.clone();
result.merge(override_config);
result
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(untagged)]
pub enum SubstituteKey {
Field(SubstituteField),
Contributor(SubstituteContributor),
}
#[allow(
non_upper_case_globals,
reason = "preserve the legacy SubstituteKey constant API"
)]
impl SubstituteKey {
pub const CollectionEditor: Self = Self::Field(SubstituteField::CollectionEditor);
pub const Editor: Self = Self::Field(SubstituteField::Editor);
pub const ParentSerial: Self = Self::Field(SubstituteField::ParentSerial);
pub const Title: Self = Self::Field(SubstituteField::Title);
pub const Translator: Self = Self::Field(SubstituteField::Translator);
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SubstituteContributor {
pub contributor: crate::template::ContributorRoles,
}
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum SubstituteField {
#[serde(rename = "collection-editor")]
CollectionEditor,
Editor,
#[serde(rename = "parent-serial")]
ParentSerial,
Title,
Translator,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::{Substitute, SubstituteConfig, SubstituteField, SubstituteKey};
use std::borrow::Cow;
use std::collections::HashMap;
#[test]
fn explicit_substitute_resolution_borrows_while_presets_are_owned() {
let explicit = SubstituteConfig::Explicit(Substitute::default());
let preset = SubstituteConfig::Preset(crate::presets::SubstitutePreset::Standard);
assert!(matches!(explicit.resolve_ref(), Cow::Borrowed(_)));
assert!(matches!(preset.resolve_ref(), Cow::Owned(_)));
assert!(matches!(
SubstituteConfig::resolve_or_default(Some(&explicit)),
Cow::Borrowed(_)
));
assert!(matches!(
SubstituteConfig::resolve_or_default(None),
Cow::Owned(_)
));
}
#[test]
fn merged_substitute_configs_preserve_role_substitute_chains() {
let base = SubstituteConfig::Explicit(Substitute {
role_substitute: HashMap::from([(
"container-author".to_string(),
vec!["editor".to_string()],
)]),
..Default::default()
});
let override_config = SubstituteConfig::Preset(crate::presets::SubstitutePreset::Standard);
let merged = SubstituteConfig::merged(&base, &override_config).resolve();
assert_eq!(
merged.role_substitute.get("container-author"),
Some(&vec!["editor".to_string()])
);
assert_eq!(
merged.template,
vec![
SubstituteKey::Field(SubstituteField::Editor),
SubstituteKey::Field(SubstituteField::Title),
SubstituteKey::Field(SubstituteField::Translator)
]
);
}
#[test]
fn substitution_candidates_round_trip_scalar_and_merged_roles() {
let yaml = r#"template:
- editor
- contributor: director
overrides:
episode:
- contributor: [writer, director]
"#;
let parsed: Substitute = serde_yaml::from_str(yaml).expect("valid substitution yaml");
assert_eq!(
parsed.template[0],
SubstituteKey::Field(SubstituteField::Editor)
);
assert_eq!(
parsed.template[1],
SubstituteKey::Contributor(super::SubstituteContributor {
contributor: crate::template::ContributorRole::Director.into(),
})
);
let serialized = serde_yaml::to_string(&parsed).expect("serializable");
assert_eq!(
serde_yaml::from_str::<Substitute>(&serialized).expect("round-trippable"),
parsed
);
}
}