Skip to main content

libxml_rs/compatibility/profiles/
mod.rs

1//! Capability epochs and compatibility profiles (§68, §85 Phase 11, 11.1-R).
2//!
3//! Historical behavior differences must flow through deliberate compatibility
4//! structures, never through scattered `if version == ...` branches. Each
5//! behavioral capability whose semantics changed at a documented upstream
6//! boundary is modelled as a *capability epoch*; a [`CompatibilityProfile`]
7//! resolves every capability for a target upstream version pair.
8//!
9//! The epoch boundaries are derived from the evidence in
10//! `atlas/SEMANTIC_EPOCHS.md` (E-001..E-008) and the surface delta engine
11//! (`tools/evidence/surface_delta_engine.py` -> `atlas/HISTORICAL_SURFACE_EPOCHS.json`).
12//! The candidate currently implements the current-system behavior
13//! (libxml2 2.15.3 / libxslt 1.1.45); the resolver exists so that future
14//! historical-emulation work (and any regression triage against older oracles)
15//! addresses one deliberate structure instead of ad-hoc version checks.
16//!
17//! # Capabilities
18//!
19//! | Capability | Upstream evidence | Boundary |
20//! |---|---|---|
21//! | `XPathNodeSetSerialization` | E-001 (xmllint --xpath output) | 2.9.10 |
22//! | `ParserDiagnostic` | E-002 (second parse-error diagnostic) | 2.12.x |
23//! | `EntityCompactStorage` | E-004 (entity content debug node) | 2.13.0 |
24//! | `ValidationExit` | E-005 (parser/validation exit codes) | 2.13.0 |
25//! | `XpathAttrEmptyExit` | E-003 (empty node-set exit code) | 2.11.0 / 2.12.6 |
26//! | `HtmlSerializer` | E-007 (HTML dump newlines) | 2.15.0 |
27//! | `ValidationNoDtdExit` | E-006 (--valid without DTD) | 2.15.0 |
28//! | `GlobalStateInit` | 2.12 lazy-init rework | 2.12.0 |
29//! | `XslTransform` | E-008 (libxslt output frozen) | stable since ≤1.1.26 |
30
31use core::fmt;
32
33/// Value of the XPath node-set serialization capability (E-001).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum XPathNodeSetSerialization {
36    /// `xmllint --xpath` prints nodes concatenated (<= 2.9.4).
37    Concatenated,
38    /// `xmllint --xpath` prints one node per line with a final newline
39    /// (>= 2.9.10; upstream-documented breaking change, commit da35eeae).
40    NewlineSeparated,
41}
42
43/// Value of the parser-diagnostic capability (E-002).
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ParserDiagnostic {
46    /// Two diagnostics for unexpected EOF ("Premature end of data in tag ..."
47    /// as the second line) — libxml2 <= 2.9.4 and >= 2.9.11 (fix de5b624f).
48    Dual,
49    /// The 2.9.10 regression variant ("EndTag: '</' not found").
50    Regression,
51    /// Single diagnostic; the second line was dropped in the 2.12.x error
52    /// handling rework (>= 2.12.6; crate's current epoch).
53    Single,
54}
55
56/// Value of the entity-content storage capability (E-004).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum EntityCompactStorage {
59    /// `--debug --noent` dumps the entity content child as `TEXT` (<= 2.12.6).
60    Plain,
61    /// Dumps as `TEXT compact` (>= 2.13.0, commit 8d04f0ee).
62    Compact,
63}
64
65/// Value of the validation-exit-code capability (E-005).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ValidationExit {
68    /// parse-error/undeclared exit 1, valid-invalid exit 4 (<= 2.12.6).
69    Legacy,
70    /// parse-error/undeclared exit 4, valid-invalid exit 3 (>= 2.13.0).
71    Reworked,
72}
73
74/// Value of the `xpath-attr` empty node-set exit code (E-003).
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum XpathAttrEmptyExit {
77    /// Exit 10 (<= 2.9.x era; "XPath set is empty").
78    Legacy,
79    /// Exit 0 (2.11.0..2.12.5, commit e85f9b98).
80    NoError,
81    /// Exit 11 (>= 2.12.6, commit 387a952b).
82    Error11,
83}
84
85/// Value of the HTML serialization capability (E-007).
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum HtmlSerializer {
88    /// Newline after elements in the dump path (<= 2.14.1).
89    Formatted,
90    /// Single-line output (>= 2.15.0; newline writes removed from HTMLtree.c).
91    SingleLine,
92}
93
94/// Value of the `--valid`-without-DTD exit capability (E-006).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ValidationNoDtdExit {
97    /// Exit 3 (2.13.0..2.14.1).
98    Error3,
99    /// Exit 0 (>= 2.15.0).
100    Ok0,
101}
102
103/// Value of the global-state initialisation capability (2.12 rework).
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum GlobalStateInit {
106    /// Eager static initialisation (<= 2.11.x).
107    Eager,
108    /// Lazy per-context initialisation (>= 2.12.0).
109    Lazy,
110}
111
112/// Value of the libxslt transform output capability (E-008).
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum XslTransform {
115    /// Transform output frozen; byte-identical 1.1.26 .. 1.1.45.
116    Stable,
117}
118
119/// Every capability the profiles module tracks, with its resolved value.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct Capabilities {
122    pub xpath_node_set_serialization: XPathNodeSetSerialization,
123    pub parser_diagnostic: ParserDiagnostic,
124    pub entity_compact_storage: EntityCompactStorage,
125    pub validation_exit: ValidationExit,
126    pub xpath_attr_empty_exit: XpathAttrEmptyExit,
127    pub html_serializer: HtmlSerializer,
128    pub validation_no_dtd_exit: ValidationNoDtdExit,
129    pub global_state_init: GlobalStateInit,
130    pub xsl_transform: XslTransform,
131}
132
133/// Parse a version string like `"2.15.3"` into `(major, minor, patch)`.
134fn parse_version(v: &str) -> (u32, u32, u32) {
135    let mut it = v.trim_start_matches('v').split('.');
136    let major = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
137    let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
138    let patch = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
139    (major, minor, patch)
140}
141
142fn at_least(version: &str, major: u32, minor: u32) -> bool {
143    let (maj, min, _) = parse_version(version);
144    (maj, min) >= (major, minor)
145}
146
147/// Resolve the capability values for a target libxml2 version.
148///
149/// Boundaries are evidence-backed (E-001..E-008); see the module docs for
150/// the exact upstream commits/releases that created each change.
151pub fn capabilities_for_libxml2(version: &str) -> Capabilities {
152    Capabilities {
153        xpath_node_set_serialization: {
154            let (maj, min, pat) = parse_version(version);
155            if (maj, min) > (2, 9) || (maj == 2 && min == 9 && pat >= 10) {
156                XPathNodeSetSerialization::NewlineSeparated
157            } else {
158                XPathNodeSetSerialization::Concatenated
159            }
160        },
161        parser_diagnostic: {
162            let (maj, min, pat) = parse_version(version);
163            if (maj, min, pat) >= (2, 9, 10) && (maj, min, pat) < (2, 9, 11) {
164                ParserDiagnostic::Regression
165            } else if (maj, min) >= (2, 12) {
166                ParserDiagnostic::Single
167            } else {
168                ParserDiagnostic::Dual
169            }
170        },
171        entity_compact_storage: if at_least(version, 2, 13) {
172            EntityCompactStorage::Compact
173        } else {
174            EntityCompactStorage::Plain
175        },
176        validation_exit: if at_least(version, 2, 13) {
177            ValidationExit::Reworked
178        } else {
179            ValidationExit::Legacy
180        },
181        xpath_attr_empty_exit: {
182            let (maj, min) = (parse_version(version).0, parse_version(version).1);
183            if (maj, min) < (2, 11) {
184                XpathAttrEmptyExit::Legacy
185            } else if (maj, min) < (2, 12)
186                || (maj == 2 && min == 12 && parse_version(version).2 < 6)
187            {
188                XpathAttrEmptyExit::NoError
189            } else {
190                XpathAttrEmptyExit::Error11
191            }
192        },
193        html_serializer: if at_least(version, 2, 15) {
194            HtmlSerializer::SingleLine
195        } else {
196            HtmlSerializer::Formatted
197        },
198        validation_no_dtd_exit: if at_least(version, 2, 15) {
199            ValidationNoDtdExit::Ok0
200        } else {
201            ValidationNoDtdExit::Error3
202        },
203        global_state_init: if at_least(version, 2, 12) {
204            GlobalStateInit::Lazy
205        } else {
206            GlobalStateInit::Eager
207        },
208        xsl_transform: XslTransform::Stable,
209    }
210}
211
212/// A resolved compatibility profile for a target upstream version pair.
213///
214/// The candidate's current target is the system oracle
215/// (libxml2 2.15.3 / libxslt 1.1.45); emulating older releases resolves this
216/// profile against the capability table instead of ad-hoc version branches.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct CompatibilityProfile {
219    /// Target libxml2 version, e.g. "2.15.3".
220    pub libxml2_version: &'static str,
221    /// Target libxslt version, e.g. "1.1.45".
222    pub libxslt_version: &'static str,
223    /// Resolved capabilities.
224    pub capabilities: Capabilities,
225}
226
227impl CompatibilityProfile {
228    /// The candidate's current-system profile (libxml2 2.15.3 / libxslt 1.1.45).
229    pub fn current() -> CompatibilityProfile {
230        CompatibilityProfile {
231            libxml2_version: "2.15.3",
232            libxslt_version: "1.1.45",
233            capabilities: capabilities_for_libxml2("2.15.3"),
234        }
235    }
236
237    /// Resolve a profile for an explicit libxml2 version (libxslt assumed
238    /// at its matching current version). Panics on versions newer than the
239    /// system oracle to avoid inventing unverifiable epochs.
240    pub fn for_libxml2(version: &str) -> CompatibilityProfile {
241        assert!(
242            parse_version(version) <= parse_version("2.15.3"),
243            "no evidence-backed epoch for libxml2 {version}"
244        );
245        CompatibilityProfile {
246            libxml2_version: "2.15.3",
247            libxslt_version: "1.1.45",
248            capabilities: capabilities_for_libxml2(version),
249        }
250    }
251}
252
253impl fmt::Display for CompatibilityProfile {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(
256            f,
257            "profile(libxml2 {}, libxslt {}, caps={:?})",
258            self.libxml2_version, self.libxslt_version, self.capabilities
259        )
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    /// The boundary table below is the executable encoding of the E-epoch
268    /// findings (atlas/SEMANTIC_EPOCHS.md); each assertion is evidence-backed.
269    #[test]
270    fn e001_xpath_node_set_boundary() {
271        assert_eq!(
272            capabilities_for_libxml2("2.9.4").xpath_node_set_serialization,
273            XPathNodeSetSerialization::Concatenated
274        );
275        assert_eq!(
276            capabilities_for_libxml2("2.9.10").xpath_node_set_serialization,
277            XPathNodeSetSerialization::NewlineSeparated
278        );
279        assert_eq!(
280            CompatibilityProfile::current()
281                .capabilities
282                .xpath_node_set_serialization,
283            XPathNodeSetSerialization::NewlineSeparated
284        );
285    }
286
287    #[test]
288    fn e002_parser_diagnostic_window() {
289        assert_eq!(
290            capabilities_for_libxml2("2.9.4").parser_diagnostic,
291            ParserDiagnostic::Dual
292        );
293        assert_eq!(
294            capabilities_for_libxml2("2.9.10").parser_diagnostic,
295            ParserDiagnostic::Regression
296        );
297        assert_eq!(
298            capabilities_for_libxml2("2.12.6").parser_diagnostic,
299            ParserDiagnostic::Single
300        );
301    }
302
303    #[test]
304    fn e004_entity_compact_boundary() {
305        assert_eq!(
306            capabilities_for_libxml2("2.12.6").entity_compact_storage,
307            EntityCompactStorage::Plain
308        );
309        assert_eq!(
310            capabilities_for_libxml2("2.13.0").entity_compact_storage,
311            EntityCompactStorage::Compact
312        );
313    }
314
315    #[test]
316    fn e005_validation_exit_boundary() {
317        assert_eq!(
318            capabilities_for_libxml2("2.12.6").validation_exit,
319            ValidationExit::Legacy
320        );
321        assert_eq!(
322            capabilities_for_libxml2("2.13.0").validation_exit,
323            ValidationExit::Reworked
324        );
325    }
326
327    #[test]
328    fn e003_xpath_attr_empty_exit_chain() {
329        assert_eq!(
330            capabilities_for_libxml2("2.9.14").xpath_attr_empty_exit,
331            XpathAttrEmptyExit::Legacy
332        );
333        assert_eq!(
334            capabilities_for_libxml2("2.11.5").xpath_attr_empty_exit,
335            XpathAttrEmptyExit::NoError
336        );
337        assert_eq!(
338            capabilities_for_libxml2("2.12.6").xpath_attr_empty_exit,
339            XpathAttrEmptyExit::Error11
340        );
341    }
342
343    #[test]
344    fn e006_e007_boundaries() {
345        assert_eq!(
346            capabilities_for_libxml2("2.14.1").validation_no_dtd_exit,
347            ValidationNoDtdExit::Error3
348        );
349        assert_eq!(
350            capabilities_for_libxml2("2.15.0").validation_no_dtd_exit,
351            ValidationNoDtdExit::Ok0
352        );
353        assert_eq!(
354            capabilities_for_libxml2("2.14.1").html_serializer,
355            HtmlSerializer::Formatted
356        );
357        assert_eq!(
358            capabilities_for_libxml2("2.15.0").html_serializer,
359            HtmlSerializer::SingleLine
360        );
361    }
362
363    #[test]
364    fn global_state_init_boundary() {
365        assert_eq!(
366            capabilities_for_libxml2("2.11.5").global_state_init,
367            GlobalStateInit::Eager
368        );
369        assert_eq!(
370            capabilities_for_libxml2("2.12.0").global_state_init,
371            GlobalStateInit::Lazy
372        );
373    }
374
375    #[test]
376    fn xslt_transform_stable_epoch() {
377        // E-008: byte-identical output 1.1.26 .. 1.1.45.
378        assert_eq!(
379            capabilities_for_libxml2("2.15.3").xsl_transform,
380            XslTransform::Stable
381        );
382    }
383
384    #[test]
385    fn current_profile_resolves_current_epochs() {
386        let p = CompatibilityProfile::current();
387        assert_eq!(p.libxml2_version, "2.15.3");
388        assert_eq!(p.capabilities.parser_diagnostic, ParserDiagnostic::Single);
389        assert_eq!(p.capabilities.html_serializer, HtmlSerializer::SingleLine);
390        assert_eq!(
391            p.capabilities.validation_no_dtd_exit,
392            ValidationNoDtdExit::Ok0
393        );
394    }
395}