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    /// Resolved E-001 capability: how `xmllint --xpath` serializes node-sets.
123    pub xpath_node_set_serialization: XPathNodeSetSerialization,
124    /// Resolved E-002 capability: parse-error diagnostic behavior.
125    pub parser_diagnostic: ParserDiagnostic,
126    /// Resolved E-004 capability: entity content storage in debug output.
127    pub entity_compact_storage: EntityCompactStorage,
128    /// Resolved E-005 capability: parser/validation exit codes.
129    pub validation_exit: ValidationExit,
130    /// Resolved E-003 capability: exit code for empty `xpath-attr` node-sets.
131    pub xpath_attr_empty_exit: XpathAttrEmptyExit,
132    /// Resolved E-007 capability: HTML dump newline behavior.
133    pub html_serializer: HtmlSerializer,
134    /// Resolved E-006 capability: `--valid` exit code without a DTD.
135    pub validation_no_dtd_exit: ValidationNoDtdExit,
136    /// Resolved capability: eager vs. lazy global-state initialisation
137    /// (2.12 rework).
138    pub global_state_init: GlobalStateInit,
139    /// Resolved E-008 capability: libxslt transform output stability.
140    pub xsl_transform: XslTransform,
141}
142
143/// Parse a version string like `"2.15.3"` into `(major, minor, patch)`.
144fn parse_version(v: &str) -> (u32, u32, u32) {
145    let mut it = v.trim_start_matches('v').split('.');
146    let major = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
147    let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
148    let patch = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
149    (major, minor, patch)
150}
151
152fn at_least(version: &str, major: u32, minor: u32) -> bool {
153    let (maj, min, _) = parse_version(version);
154    (maj, min) >= (major, minor)
155}
156
157/// Resolve the capability values for a target libxml2 version.
158///
159/// Boundaries are evidence-backed (E-001..E-008); see the module docs for
160/// the exact upstream commits/releases that created each change.
161pub fn capabilities_for_libxml2(version: &str) -> Capabilities {
162    Capabilities {
163        xpath_node_set_serialization: {
164            let (maj, min, pat) = parse_version(version);
165            if (maj, min) > (2, 9) || (maj == 2 && min == 9 && pat >= 10) {
166                XPathNodeSetSerialization::NewlineSeparated
167            } else {
168                XPathNodeSetSerialization::Concatenated
169            }
170        },
171        parser_diagnostic: {
172            let (maj, min, pat) = parse_version(version);
173            if (maj, min, pat) >= (2, 9, 10) && (maj, min, pat) < (2, 9, 11) {
174                ParserDiagnostic::Regression
175            } else if (maj, min) >= (2, 12) {
176                ParserDiagnostic::Single
177            } else {
178                ParserDiagnostic::Dual
179            }
180        },
181        entity_compact_storage: if at_least(version, 2, 13) {
182            EntityCompactStorage::Compact
183        } else {
184            EntityCompactStorage::Plain
185        },
186        validation_exit: if at_least(version, 2, 13) {
187            ValidationExit::Reworked
188        } else {
189            ValidationExit::Legacy
190        },
191        xpath_attr_empty_exit: {
192            let (maj, min) = (parse_version(version).0, parse_version(version).1);
193            if (maj, min) < (2, 11) {
194                XpathAttrEmptyExit::Legacy
195            } else if (maj, min) < (2, 12)
196                || (maj == 2 && min == 12 && parse_version(version).2 < 6)
197            {
198                XpathAttrEmptyExit::NoError
199            } else {
200                XpathAttrEmptyExit::Error11
201            }
202        },
203        html_serializer: if at_least(version, 2, 15) {
204            HtmlSerializer::SingleLine
205        } else {
206            HtmlSerializer::Formatted
207        },
208        validation_no_dtd_exit: if at_least(version, 2, 15) {
209            ValidationNoDtdExit::Ok0
210        } else {
211            ValidationNoDtdExit::Error3
212        },
213        global_state_init: if at_least(version, 2, 12) {
214            GlobalStateInit::Lazy
215        } else {
216            GlobalStateInit::Eager
217        },
218        xsl_transform: XslTransform::Stable,
219    }
220}
221
222/// A resolved compatibility profile for a target upstream version pair.
223///
224/// The candidate's current target is the system oracle
225/// (libxml2 2.15.3 / libxslt 1.1.45); emulating older releases resolves this
226/// profile against the capability table instead of ad-hoc version branches.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub struct CompatibilityProfile {
229    /// Target libxml2 version, e.g. "2.15.3".
230    pub libxml2_version: &'static str,
231    /// Target libxslt version, e.g. "1.1.45".
232    pub libxslt_version: &'static str,
233    /// Resolved capabilities.
234    pub capabilities: Capabilities,
235}
236
237impl CompatibilityProfile {
238    /// The candidate's current-system profile (libxml2 2.15.3 / libxslt 1.1.45).
239    pub fn current() -> CompatibilityProfile {
240        CompatibilityProfile {
241            libxml2_version: "2.15.3",
242            libxslt_version: "1.1.45",
243            capabilities: capabilities_for_libxml2("2.15.3"),
244        }
245    }
246
247    /// Resolve a profile for an explicit libxml2 version (libxslt assumed
248    /// at its matching current version). Panics on versions newer than the
249    /// system oracle to avoid inventing unverifiable epochs.
250    pub fn for_libxml2(version: &str) -> CompatibilityProfile {
251        assert!(
252            parse_version(version) <= parse_version("2.15.3"),
253            "no evidence-backed epoch for libxml2 {version}"
254        );
255        CompatibilityProfile {
256            libxml2_version: "2.15.3",
257            libxslt_version: "1.1.45",
258            capabilities: capabilities_for_libxml2(version),
259        }
260    }
261}
262
263impl fmt::Display for CompatibilityProfile {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        write!(
266            f,
267            "profile(libxml2 {}, libxslt {}, caps={:?})",
268            self.libxml2_version, self.libxslt_version, self.capabilities
269        )
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    /// The boundary table below is the executable encoding of the E-epoch
278    /// findings (atlas/SEMANTIC_EPOCHS.md); each assertion is evidence-backed.
279    #[test]
280    fn e001_xpath_node_set_boundary() {
281        assert_eq!(
282            capabilities_for_libxml2("2.9.4").xpath_node_set_serialization,
283            XPathNodeSetSerialization::Concatenated
284        );
285        assert_eq!(
286            capabilities_for_libxml2("2.9.10").xpath_node_set_serialization,
287            XPathNodeSetSerialization::NewlineSeparated
288        );
289        assert_eq!(
290            CompatibilityProfile::current()
291                .capabilities
292                .xpath_node_set_serialization,
293            XPathNodeSetSerialization::NewlineSeparated
294        );
295    }
296
297    #[test]
298    fn e002_parser_diagnostic_window() {
299        assert_eq!(
300            capabilities_for_libxml2("2.9.4").parser_diagnostic,
301            ParserDiagnostic::Dual
302        );
303        assert_eq!(
304            capabilities_for_libxml2("2.9.10").parser_diagnostic,
305            ParserDiagnostic::Regression
306        );
307        assert_eq!(
308            capabilities_for_libxml2("2.12.6").parser_diagnostic,
309            ParserDiagnostic::Single
310        );
311    }
312
313    #[test]
314    fn e004_entity_compact_boundary() {
315        assert_eq!(
316            capabilities_for_libxml2("2.12.6").entity_compact_storage,
317            EntityCompactStorage::Plain
318        );
319        assert_eq!(
320            capabilities_for_libxml2("2.13.0").entity_compact_storage,
321            EntityCompactStorage::Compact
322        );
323    }
324
325    #[test]
326    fn e005_validation_exit_boundary() {
327        assert_eq!(
328            capabilities_for_libxml2("2.12.6").validation_exit,
329            ValidationExit::Legacy
330        );
331        assert_eq!(
332            capabilities_for_libxml2("2.13.0").validation_exit,
333            ValidationExit::Reworked
334        );
335    }
336
337    #[test]
338    fn e003_xpath_attr_empty_exit_chain() {
339        assert_eq!(
340            capabilities_for_libxml2("2.9.14").xpath_attr_empty_exit,
341            XpathAttrEmptyExit::Legacy
342        );
343        assert_eq!(
344            capabilities_for_libxml2("2.11.5").xpath_attr_empty_exit,
345            XpathAttrEmptyExit::NoError
346        );
347        assert_eq!(
348            capabilities_for_libxml2("2.12.6").xpath_attr_empty_exit,
349            XpathAttrEmptyExit::Error11
350        );
351    }
352
353    #[test]
354    fn e006_e007_boundaries() {
355        assert_eq!(
356            capabilities_for_libxml2("2.14.1").validation_no_dtd_exit,
357            ValidationNoDtdExit::Error3
358        );
359        assert_eq!(
360            capabilities_for_libxml2("2.15.0").validation_no_dtd_exit,
361            ValidationNoDtdExit::Ok0
362        );
363        assert_eq!(
364            capabilities_for_libxml2("2.14.1").html_serializer,
365            HtmlSerializer::Formatted
366        );
367        assert_eq!(
368            capabilities_for_libxml2("2.15.0").html_serializer,
369            HtmlSerializer::SingleLine
370        );
371    }
372
373    #[test]
374    fn global_state_init_boundary() {
375        assert_eq!(
376            capabilities_for_libxml2("2.11.5").global_state_init,
377            GlobalStateInit::Eager
378        );
379        assert_eq!(
380            capabilities_for_libxml2("2.12.0").global_state_init,
381            GlobalStateInit::Lazy
382        );
383    }
384
385    #[test]
386    fn xslt_transform_stable_epoch() {
387        // E-008: byte-identical output 1.1.26 .. 1.1.45.
388        assert_eq!(
389            capabilities_for_libxml2("2.15.3").xsl_transform,
390            XslTransform::Stable
391        );
392    }
393
394    #[test]
395    fn current_profile_resolves_current_epochs() {
396        let p = CompatibilityProfile::current();
397        assert_eq!(p.libxml2_version, "2.15.3");
398        assert_eq!(p.capabilities.parser_diagnostic, ParserDiagnostic::Single);
399        assert_eq!(p.capabilities.html_serializer, HtmlSerializer::SingleLine);
400        assert_eq!(
401            p.capabilities.validation_no_dtd_exit,
402            ValidationNoDtdExit::Ok0
403        );
404    }
405}