1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
/*
SPDX-License-Identifier: MIT OR Apache-2.0
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
*/
//! Citation section specification.
use std::collections::HashMap;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::grouping;
use crate::options::CitationOptions;
use crate::template::{
LocalizedTemplateSpec, Template, TemplateReference, TemplateVariants, locale_matches,
};
/// Citation collapse behavior for multi-item citations.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CitationCollapse {
/// Collapse adjacent citation numbers into a numeric range such as `1–3`.
CitationNumber,
}
/// Text-case transform applied when a citation renders at note start.
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum NoteStartTextCase {
/// Uppercase the first character of the rendered citation.
CapitalizeFirst,
/// Lowercase the rendered citation text.
Lowercase,
}
/// Citation specification.
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct CitationSpec {
/// Citation-specific option overrides merged over the style config.
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<CitationOptions>,
/// Reference to an embedded template preset or external template.
///
/// If both `template-ref` and `template` are present, `template` takes precedence.
#[serde(skip_serializing_if = "Option::is_none")]
pub template_ref: Option<TemplateReference>,
/// Default template when no localized override is selected.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub template: Option<Template>,
/// Locale-specific template overrides checked before the default template.
#[serde(skip_serializing_if = "Option::is_none")]
pub locales: Option<Vec<LocalizedTemplateSpec>>,
/// Type-specific template overrides for citations. When present, replaces
/// the default citation template for references of the specified types.
/// Type-variant lookup happens after mode (integral/non-integral) resolution.
/// If both the main spec and the active mode sub-spec have a `type-variants`
/// entry for the same type, the mode-specific one wins.
#[serde(skip_serializing_if = "Option::is_none", rename = "type-variants")]
pub type_variants: Option<TemplateVariants>,
/// Wrap the entire citation in punctuation. Preferred over prefix/suffix.
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap: Option<crate::template::WrapConfig>,
/// Prefix for the citation (use only when `wrap` doesn't suffice, e.g., " (" or "[Ref ").
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
/// Suffix for the citation (use only when `wrap` doesn't suffice).
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
/// Delimiter between components within a single citation item (e.g., ", " or " ").
/// Defaults to ", ".
#[serde(skip_serializing_if = "Option::is_none")]
pub delimiter: Option<String>,
/// Delimiter between multiple citation items (e.g., "; ").
/// Defaults to "; ".
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "multi-cite-delimiter")]
pub multi_cite_delimiter: Option<String>,
/// Optional collapse behavior for adjacent multi-item citations.
#[serde(skip_serializing_if = "Option::is_none")]
pub collapse: Option<CitationCollapse>,
/// Optional citation sorting specification.
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<grouping::GroupSortEntry>,
/// Configuration for integral (narrative) citations (e.g., "Smith (2020)").
/// Overrides fields from the main citation spec when mode is Integral.
#[serde(skip_serializing_if = "Option::is_none")]
pub integral: Option<Box<CitationSpec>>,
/// Configuration for non-integral (parenthetical) citations (e.g., "(Smith, 2020)").
/// Overrides fields from the main citation spec when mode is NonIntegral.
#[serde(skip_serializing_if = "Option::is_none")]
pub non_integral: Option<Box<CitationSpec>>,
/// Configuration for subsequent citations.
/// Overrides fields from the main citation spec when position is Subsequent.
/// Useful for short-form citations in note-based styles or author-date styles
/// that show abbreviated citations after the first mention.
#[serde(skip_serializing_if = "Option::is_none")]
pub subsequent: Option<Box<CitationSpec>>,
/// Configuration for ibid citations (ibid or ibid with locator).
/// Overrides fields from the main citation spec when position is Ibid or IbidWithLocator.
/// If present, takes precedence over `subsequent` for these positions.
/// Allows compact rendering like "ibid." or "ibid., p. 45".
#[serde(skip_serializing_if = "Option::is_none")]
pub ibid: Option<Box<CitationSpec>>,
/// Optional text-case transform for standalone note-start citation output.
///
/// This is a style-owned rendering dimension layered on top of the
/// existing repeated-note state, not a new citation `Position`.
#[serde(skip_serializing_if = "Option::is_none")]
pub note_start_text_case: Option<NoteStartTextCase>,
/// Custom user-defined fields for extensions.
#[serde(skip_serializing_if = "Option::is_none")]
pub custom: Option<HashMap<String, serde_json::Value>>,
/// Forward-compat: captures unknown keys when an older engine reads a
/// style produced by a newer schema. Empty by default; treated as a
/// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
#[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 CitationSpec {
/// Resolve the effective template for this citation.
///
/// Returns the explicit `template` if present, otherwise resolves `template-ref`.
/// Returns `None` if neither is specified.
pub fn resolve_template(&self) -> Option<Template> {
self.template.clone().or_else(|| {
self.template_ref
.as_ref()
.and_then(TemplateReference::citation_template)
})
}
/// Resolve the template for a language by checking localized overrides,
/// then the localized default, then the base template or preset.
pub fn resolve_template_for_language(&self, language: Option<&str>) -> Option<Template> {
if let Some(language) = language
&& let Some(locales) = &self.locales
&& let Some(matched) = locales.iter().find(|spec| {
spec.locale
.as_ref()
.is_some_and(|targets| locale_matches(targets, language))
})
{
return Some(matched.template.clone());
}
self.locales
.as_ref()
.and_then(|locales| {
locales
.iter()
.find(|spec| spec.default.unwrap_or(false))
.map(|spec| spec.template.clone())
})
.or_else(|| self.resolve_template())
}
/// Resolve the template for a given reference type and language.
///
/// First checks `type_variants` for an entry matching `ref_type`.
/// Falls back to `resolve_template_for_language` if no type-specific
/// template is found.
pub fn resolve_template_for_type(
&self,
ref_type: &str,
language: Option<&str>,
) -> Option<Template> {
if let Some(type_variants) = &self.type_variants {
for (selector, variant) in type_variants {
if selector.matches(ref_type) {
return variant.clone().into_template();
}
}
}
self.resolve_template_for_language(language)
}
/// Resolve the effective spec for a given citation mode.
///
/// If a mode-specific spec exists (e.g., `integral`), it merges with and overrides
/// the base spec.
pub fn resolve_for_mode(
&self,
mode: &crate::citation::CitationMode,
) -> std::borrow::Cow<'_, CitationSpec> {
use crate::citation::CitationMode;
let mode_spec = match mode {
CitationMode::Integral => self.integral.as_ref(),
CitationMode::NonIntegral => self.non_integral.as_ref(),
};
match mode_spec {
Some(spec) => {
// Merge logic: mode specific > base
let mut merged = self.clone();
// We don't want to recurse infinitely or keep the mode specs in the merged result
merged.integral = None;
merged.non_integral = None;
match (&mut merged.options, &spec.options) {
(Some(base), Some(mode)) => base.merge(mode),
(None, Some(mode)) => merged.options = Some(mode.clone()),
_ => {}
}
if spec.template_ref.is_some() {
merged.template_ref = spec.template_ref.clone();
}
if spec.template.is_some() {
merged.template = spec.template.clone();
}
if spec.locales.is_some() {
merged.locales = spec.locales.clone();
}
if spec.type_variants.is_some() {
merged.type_variants = spec.type_variants.clone();
}
if spec.wrap.is_some() {
merged.wrap = spec.wrap.clone();
}
if spec.prefix.is_some() {
merged.prefix = spec.prefix.clone();
}
if spec.suffix.is_some() {
merged.suffix = spec.suffix.clone();
}
if spec.delimiter.is_some() {
merged.delimiter = spec.delimiter.clone();
}
if spec.multi_cite_delimiter.is_some() {
merged.multi_cite_delimiter = spec.multi_cite_delimiter.clone();
}
if spec.collapse.is_some() {
merged.collapse = spec.collapse.clone();
}
if spec.sort.is_some() {
merged.sort = spec.sort.clone();
}
if spec.note_start_text_case.is_some() {
merged.note_start_text_case = spec.note_start_text_case;
}
std::borrow::Cow::Owned(merged)
}
None => std::borrow::Cow::Borrowed(self),
}
}
/// Resolve the effective spec for a given citation position.
///
/// If a position-specific spec exists (e.g., `ibid` for Ibid position),
/// it merges with and overrides the base spec. Position resolution should
/// be applied before mode resolution to allow position-specific modes.
///
/// Priority: ibid > subsequent > base
pub fn resolve_for_position(
&self,
position: Option<&crate::citation::Position>,
) -> std::borrow::Cow<'_, CitationSpec> {
use crate::citation::Position;
let position_spec = match position {
Some(Position::Ibid | Position::IbidWithLocator) => {
self.ibid.as_ref().or(self.subsequent.as_ref())
}
Some(Position::Subsequent) => self.subsequent.as_ref(),
Some(Position::First) | None => None,
};
match position_spec {
Some(spec) => {
// Merge logic: position specific > base
let mut merged = self.clone();
// Don't recurse infinitely or keep position specs in merged result
merged.subsequent = None;
merged.ibid = None;
match (&mut merged.options, &spec.options) {
(Some(base), Some(mode)) => base.merge(mode),
(None, Some(mode)) => merged.options = Some(mode.clone()),
_ => {}
}
if spec.template_ref.is_some() {
merged.template_ref = spec.template_ref.clone();
}
if spec.template.is_some() {
merged.template = spec.template.clone();
// A position spec with its own template is a complete override —
// clear inherited type_variants so the engine uses this template
// directly rather than branching by ref type. If the position spec
// wants type-specific rendering it must declare type_variants itself.
if spec.type_variants.is_none() {
merged.type_variants = None;
}
}
if spec.locales.is_some() {
merged.locales = spec.locales.clone();
}
if spec.type_variants.is_some() {
merged.type_variants = spec.type_variants.clone();
}
if spec.wrap.is_some() {
merged.wrap = spec.wrap.clone();
}
if spec.prefix.is_some() {
merged.prefix = spec.prefix.clone();
}
if spec.suffix.is_some() {
merged.suffix = spec.suffix.clone();
}
if spec.delimiter.is_some() {
merged.delimiter = spec.delimiter.clone();
}
if spec.multi_cite_delimiter.is_some() {
merged.multi_cite_delimiter = spec.multi_cite_delimiter.clone();
}
if spec.collapse.is_some() {
merged.collapse = spec.collapse.clone();
}
if spec.sort.is_some() {
merged.sort = spec.sort.clone();
}
if spec.note_start_text_case.is_some() {
merged.note_start_text_case = spec.note_start_text_case;
}
std::borrow::Cow::Owned(merged)
}
None => std::borrow::Cow::Borrowed(self),
}
}
}