Skip to main content

citum_engine/api/
forward_compat.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Forward-compat unknown-field walking for `citum check`.
7//!
8//! Tolerant style option/section structs capture unknown keys into a
9//! `unknown_fields` map at parse time (see
10//! `docs/specs/FORWARD_COMPATIBILITY.md`). This module walks a parsed
11//! [`Style`] and reports every populated capture, so CLI surfaces such as
12//! `citum check --strict` can emit them as warnings or errors.
13
14use citum_schema::CitationSpec;
15use citum_schema::Style;
16use citum_schema::options::{
17    BibliographyOptions, CitationOptions, Config, LocatorConfig, SortingConfig, SubstituteConfig,
18};
19
20/// A single populated `unknown_fields` capture located in a parsed [`Style`].
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct UnknownFieldPath {
23    /// Dotted path to the struct that captured the keys.
24    pub path: String,
25    /// The unknown keys captured at that path.
26    pub keys: Vec<String>,
27}
28
29/// Walk a parsed [`Style`] and collect every populated `unknown_fields`
30/// capture (top-level, options, nested option structs, nested citation
31/// specs).
32#[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}