1use citum_schema::CitationSpec;
15use citum_schema::Style;
16use citum_schema::options::{
17 BibliographyOptions, CitationOptions, Config, LocatorConfig, 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(titles) = &c.titles {
94 push_keys(out, &format!("{base}.titles"), titles.unknown_fields.keys());
95 }
96 if let Some(locators) = &c.locators {
97 walk_locator_config(out, &format!("{base}.locators"), locators);
98 }
99 if let Some(notes) = &c.notes {
100 push_keys(out, &format!("{base}.notes"), notes.unknown_fields.keys());
101 }
102 if let Some(integral) = &c.integral_name_memory {
103 push_keys(
104 out,
105 &format!("{base}.integral-name-memory"),
106 integral.unknown_fields.keys(),
107 );
108 }
109 if let Some(org) = &c.org_abbreviation_memory {
110 push_keys(
111 out,
112 &format!("{base}.org-abbreviation-memory"),
113 org.unknown_fields.keys(),
114 );
115 }
116}
117
118fn walk_locator_config(out: &mut Vec<UnknownFieldPath>, base: &str, lc: &LocatorConfig) {
119 push_keys(out, base, lc.unknown_fields.keys());
120 for (kind, cfg) in &lc.kinds {
121 push_keys(
122 out,
123 &format!("{base}.kinds.{kind:?}"),
124 cfg.unknown_fields.keys(),
125 );
126 }
127 for (idx, pattern) in lc.patterns.iter().enumerate() {
128 push_keys(
129 out,
130 &format!("{base}.patterns[{idx}]"),
131 pattern.unknown_fields.keys(),
132 );
133 }
134}
135
136fn walk_citation_options_nested(out: &mut Vec<UnknownFieldPath>, base: &str, co: &CitationOptions) {
137 if let Some(contributors) = &co.contributors {
138 push_keys(
139 out,
140 &format!("{base}.contributors"),
141 contributors.unknown_fields.keys(),
142 );
143 }
144 if let Some(dates) = &co.dates {
145 push_keys(out, &format!("{base}.dates"), dates.unknown_fields.keys());
146 }
147 if let Some(titles) = &co.titles {
148 push_keys(out, &format!("{base}.titles"), titles.unknown_fields.keys());
149 }
150 if let Some(locators) = &co.locators {
151 walk_locator_config(out, &format!("{base}.locators"), locators);
152 }
153 if let Some(notes) = &co.notes {
154 push_keys(out, &format!("{base}.notes"), notes.unknown_fields.keys());
155 }
156 if let Some(integral) = &co.integral_name_memory {
157 push_keys(
158 out,
159 &format!("{base}.integral-name-memory"),
160 integral.unknown_fields.keys(),
161 );
162 }
163 if let Some(org) = &co.org_abbreviation_memory {
164 push_keys(
165 out,
166 &format!("{base}.org-abbreviation-memory"),
167 org.unknown_fields.keys(),
168 );
169 }
170}
171
172fn walk_bibliography_options_nested(
173 out: &mut Vec<UnknownFieldPath>,
174 base: &str,
175 bo: &BibliographyOptions,
176) {
177 if let Some(contributors) = &bo.contributors {
178 push_keys(
179 out,
180 &format!("{base}.contributors"),
181 contributors.unknown_fields.keys(),
182 );
183 }
184 if let Some(dates) = &bo.dates {
185 push_keys(out, &format!("{base}.dates"), dates.unknown_fields.keys());
186 }
187 if let Some(titles) = &bo.titles {
188 push_keys(out, &format!("{base}.titles"), titles.unknown_fields.keys());
189 }
190 if let Some(article_journal) = &bo.article_journal {
191 push_keys(
192 out,
193 &format!("{base}.article-journal"),
194 article_journal.unknown_fields.keys(),
195 );
196 }
197 if let Some(compound) = &bo.compound_numeric {
198 push_keys(
199 out,
200 &format!("{base}.compound-numeric"),
201 compound.unknown_fields.keys(),
202 );
203 }
204 if let Some(partitioning) = &bo.sort_partitioning {
205 push_keys(
206 out,
207 &format!("{base}.sort-partitioning"),
208 partitioning.unknown_fields.keys(),
209 );
210 }
211}
212
213fn push_keys<'a, I>(out: &mut Vec<UnknownFieldPath>, path: &str, keys: I)
214where
215 I: IntoIterator<Item = &'a String>,
216{
217 let collected: Vec<String> = keys.into_iter().cloned().collect();
218 if !collected.is_empty() {
219 out.push(UnknownFieldPath {
220 path: path.to_string(),
221 keys: collected,
222 });
223 }
224}
225
226#[cfg(test)]
227#[allow(clippy::unwrap_used, reason = "tests")]
228mod tests {
229 use super::*;
230 use rstest::rstest;
231
232 #[rstest]
233 #[case(
234 "info:\n title: Test\noptions:\n org-abbreviation-memory:\n future-key: true\n",
235 "top-level options"
236 )]
237 #[case(
238 "info:\n title: Test\ncitation:\n options:\n org-abbreviation-memory:\n future-key: true\n",
239 "citation options"
240 )]
241 fn collect_unknown_fields_reports_org_abbreviation_memory(
242 #[case] yaml: &str,
243 #[case] location: &str,
244 ) {
245 let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
246 let paths = collect_unknown_field_paths(&style);
247 let found = paths
248 .iter()
249 .any(|p| p.path.contains("org-abbreviation-memory"));
250 assert!(
251 found,
252 "expected org-abbreviation-memory path in {location} unknown fields, got: {paths:?}"
253 );
254 }
255}