1use citum_schema::CitationSpec;
15use citum_schema::Style;
16use citum_schema::options::{
17 BibliographyOptions, CitationOptions, Config, LocatorConfig, SortingConfig, SubstituteConfig,
18};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct UnknownFieldPath {
23 pub path: String,
25 pub keys: Vec<String>,
27}
28
29#[must_use]
33pub fn collect_unknown_field_paths(style: &Style) -> Vec<UnknownFieldPath> {
34 let mut out = Vec::new();
35 push_keys(&mut out, "$", style.unknown_fields.keys());
36
37 if let Some(options) = &style.options {
38 walk_config(&mut out, "$.options", options);
39 }
40 if let Some(citation) = &style.citation {
41 walk_citation_spec(&mut out, "$.citation", citation);
42 }
43 if let Some(bib) = &style.bibliography {
44 push_keys(&mut out, "$.bibliography", bib.unknown_fields.keys());
45 if let Some(bo) = &bib.options {
46 push_keys(&mut out, "$.bibliography.options", bo.unknown_fields.keys());
47 walk_bibliography_options_nested(&mut out, "$.bibliography.options", bo);
48 }
49 }
50
51 out
52}
53
54fn walk_citation_spec(out: &mut Vec<UnknownFieldPath>, base: &str, spec: &CitationSpec) {
55 push_keys(out, base, spec.unknown_fields.keys());
56 if let Some(co) = &spec.options {
57 push_keys(out, &format!("{base}.options"), co.unknown_fields.keys());
58 walk_citation_options_nested(out, &format!("{base}.options"), co);
59 }
60 if let Some(child) = &spec.integral {
61 walk_citation_spec(out, &format!("{base}.integral"), child);
62 }
63 if let Some(child) = &spec.non_integral {
64 walk_citation_spec(out, &format!("{base}.non-integral"), child);
65 }
66 if let Some(child) = &spec.subsequent {
67 walk_citation_spec(out, &format!("{base}.subsequent"), child);
68 }
69 if let Some(child) = &spec.ibid {
70 walk_citation_spec(out, &format!("{base}.ibid"), child);
71 }
72}
73
74fn walk_config(out: &mut Vec<UnknownFieldPath>, base: &str, c: &Config) {
75 push_keys(out, base, c.unknown_fields.keys());
76 if let Some(contributors) = &c.contributors {
77 push_keys(
78 out,
79 &format!("{base}.contributors"),
80 contributors.unknown_fields.keys(),
81 );
82 }
83 if let Some(SubstituteConfig::Explicit(sub)) = &c.substitute {
84 push_keys(
85 out,
86 &format!("{base}.substitute"),
87 sub.unknown_fields.keys(),
88 );
89 }
90 if let Some(dates) = &c.dates {
91 push_keys(out, &format!("{base}.dates"), dates.unknown_fields.keys());
92 }
93 if let Some(sorting) = &c.sorting {
94 walk_sorting_config(out, &format!("{base}.sorting"), sorting);
95 }
96 if let Some(titles) = &c.titles {
97 push_keys(out, &format!("{base}.titles"), titles.unknown_fields.keys());
98 }
99 if let Some(locators) = &c.locators {
100 walk_locator_config(out, &format!("{base}.locators"), locators);
101 }
102 if let Some(notes) = &c.notes {
103 push_keys(out, &format!("{base}.notes"), notes.unknown_fields.keys());
104 }
105 if let Some(integral) = &c.integral_name_memory {
106 push_keys(
107 out,
108 &format!("{base}.integral-name-memory"),
109 integral.unknown_fields.keys(),
110 );
111 }
112 if let Some(org) = &c.org_abbreviation_memory {
113 push_keys(
114 out,
115 &format!("{base}.org-abbreviation-memory"),
116 org.unknown_fields.keys(),
117 );
118 }
119}
120
121fn walk_locator_config(out: &mut Vec<UnknownFieldPath>, base: &str, lc: &LocatorConfig) {
122 push_keys(out, base, lc.unknown_fields.keys());
123 for (kind, cfg) in &lc.kinds {
124 push_keys(
125 out,
126 &format!("{base}.kinds.{kind:?}"),
127 cfg.unknown_fields.keys(),
128 );
129 }
130 for (idx, pattern) in lc.patterns.iter().enumerate() {
131 push_keys(
132 out,
133 &format!("{base}.patterns[{idx}]"),
134 pattern.unknown_fields.keys(),
135 );
136 }
137}
138
139fn walk_citation_options_nested(out: &mut Vec<UnknownFieldPath>, base: &str, co: &CitationOptions) {
140 if let Some(contributors) = &co.contributors {
141 push_keys(
142 out,
143 &format!("{base}.contributors"),
144 contributors.unknown_fields.keys(),
145 );
146 }
147 if let Some(dates) = &co.dates {
148 push_keys(out, &format!("{base}.dates"), dates.unknown_fields.keys());
149 }
150 if let Some(titles) = &co.titles {
151 push_keys(out, &format!("{base}.titles"), titles.unknown_fields.keys());
152 }
153 if let Some(locators) = &co.locators {
154 walk_locator_config(out, &format!("{base}.locators"), locators);
155 }
156 if let Some(notes) = &co.notes {
157 push_keys(out, &format!("{base}.notes"), notes.unknown_fields.keys());
158 }
159 if let Some(integral) = &co.integral_name_memory {
160 push_keys(
161 out,
162 &format!("{base}.integral-name-memory"),
163 integral.unknown_fields.keys(),
164 );
165 }
166 if let Some(org) = &co.org_abbreviation_memory {
167 push_keys(
168 out,
169 &format!("{base}.org-abbreviation-memory"),
170 org.unknown_fields.keys(),
171 );
172 }
173}
174
175fn walk_bibliography_options_nested(
176 out: &mut Vec<UnknownFieldPath>,
177 base: &str,
178 bo: &BibliographyOptions,
179) {
180 if let Some(contributors) = &bo.contributors {
181 push_keys(
182 out,
183 &format!("{base}.contributors"),
184 contributors.unknown_fields.keys(),
185 );
186 }
187 if let Some(dates) = &bo.dates {
188 push_keys(out, &format!("{base}.dates"), dates.unknown_fields.keys());
189 }
190 if let Some(sorting) = &bo.sorting {
191 walk_sorting_config(out, &format!("{base}.sorting"), sorting);
192 }
193 if let Some(titles) = &bo.titles {
194 push_keys(out, &format!("{base}.titles"), titles.unknown_fields.keys());
195 }
196 if let Some(article_journal) = &bo.article_journal {
197 push_keys(
198 out,
199 &format!("{base}.article-journal"),
200 article_journal.unknown_fields.keys(),
201 );
202 }
203 if let Some(compound) = &bo.compound_numeric {
204 push_keys(
205 out,
206 &format!("{base}.compound-numeric"),
207 compound.unknown_fields.keys(),
208 );
209 }
210 if let Some(partitioning) = &bo.sort_partitioning {
211 push_keys(
212 out,
213 &format!("{base}.sort-partitioning"),
214 partitioning.unknown_fields.keys(),
215 );
216 }
217}
218
219fn walk_sorting_config(out: &mut Vec<UnknownFieldPath>, base: &str, sorting: &SortingConfig) {
220 push_keys(out, base, sorting.unknown_fields.keys());
221}
222
223fn push_keys<'a, I>(out: &mut Vec<UnknownFieldPath>, path: &str, keys: I)
224where
225 I: IntoIterator<Item = &'a String>,
226{
227 let collected: Vec<String> = keys.into_iter().cloned().collect();
228 if !collected.is_empty() {
229 out.push(UnknownFieldPath {
230 path: path.to_string(),
231 keys: collected,
232 });
233 }
234}
235
236#[cfg(test)]
237#[allow(clippy::unwrap_used, reason = "tests")]
238mod tests {
239 use super::*;
240 use rstest::rstest;
241
242 #[rstest]
243 #[case(
244 "info:\n title: Test\noptions:\n org-abbreviation-memory:\n future-key: true\n",
245 "top-level options"
246 )]
247 #[case(
248 "info:\n title: Test\ncitation:\n options:\n org-abbreviation-memory:\n future-key: true\n",
249 "citation options"
250 )]
251 fn collect_unknown_fields_reports_org_abbreviation_memory(
252 #[case] yaml: &str,
253 #[case] location: &str,
254 ) {
255 let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
256 let paths = collect_unknown_field_paths(&style);
257 let found = paths
258 .iter()
259 .any(|p| p.path.contains("org-abbreviation-memory"));
260 assert!(
261 found,
262 "expected org-abbreviation-memory path in {location} unknown fields, got: {paths:?}"
263 );
264 }
265}