vastlint_core/lib.rs
1//! # vastlint-core
2//!
3//! A zero-I/O VAST XML validation library. Takes a VAST XML
4//! string and returns a structured [`ValidationResult`] listing every issue
5//! found, the detected VAST version, and a summary of error/warning/info counts.
6//!
7//! The entire public surface is two functions and a handful of types:
8//!
9//! - [`validate`] -- validate with default settings (most callers want this)
10//! - [`validate_with_context`] -- validate with rule overrides or wrapper depth
11//! - [`all_rules`] -- list the full 108-rule catalog
12//!
13//! # Performance — allocator recommendation
14//!
15//! `vastlint-core` builds an owned document tree on every call (one heap
16//! allocation per XML element, attribute, and text node). Under concurrent
17//! load the system allocator becomes a bottleneck because all threads compete
18//! for a shared free-list lock.
19//!
20//! Switching to [`mimalloc`](https://docs.rs/mimalloc) in your **binary**
21//! crate eliminates this contention and gives dramatically better throughput
22//! at high concurrency, especially for larger documents:
23//!
24//! ```toml
25//! # Cargo.toml (your binary, not a library crate)
26//! [dependencies]
27//! mimalloc = { version = "0.1", default-features = false }
28//! ```
29//!
30//! ```rust,ignore
31//! // src/main.rs
32//! use mimalloc::MiMalloc;
33//! #[global_allocator]
34//! static GLOBAL: MiMalloc = MiMalloc;
35//! ```
36//!
37//! Measured on Apple M4 (10 threads, production-realistic VAST tags):
38//!
39//! | Allocator | 17 KB tag | 44 KB tag |
40//! |---|---|---|
41//! | system (default) | 1,847 tags/s · 541 µs | 328 tags/s · 3,048 µs |
42//! | mimalloc | 15,760 tags/s · 63 µs | 2,635 tags/s · 380 µs |
43//!
44//! **mimalloc: ~8× throughput improvement on multi-threaded workloads.**
45//!
46//! > ⚠️ Do **not** set a global allocator in a library crate — it would
47//! > override the allocator for any host process that links you (Go, Python,
48//! > Ruby runtimes, etc.), which can cause heap corruption.
49//!
50//! # Quick start
51//!
52//! ```rust
53//! let xml = r#"<VAST version="2.0">
54//! <Ad><InLine>
55//! <AdSystem>Demo</AdSystem>
56//! <AdTitle>Ad</AdTitle>
57//! <Impression>https://t.example.com/imp</Impression>
58//! <Creatives>
59//! <Creative>
60//! <Linear>
61//! <Duration>00:00:15</Duration>
62//! <MediaFiles>
63//! <MediaFile delivery="progressive" type="video/mp4"
64//! width="640" height="360">
65//! https://cdn.example.com/ad.mp4
66//! </MediaFile>
67//! </MediaFiles>
68//! </Linear>
69//! </Creative>
70//! </Creatives>
71//! </InLine></Ad>
72//! </VAST>"#;
73//!
74//! let result = vastlint_core::validate(xml);
75//! assert_eq!(result.summary.errors, 0);
76//! ```
77//!
78//! # Design constraints
79//!
80//! The library has no I/O, no logging, no global state, and no async runtime.
81//! It can be embedded in a CLI, HTTP server, WASM module, or FFI binding
82//! without pulling in any platform-specific dependencies.
83//!
84//! Three crate dependencies: `quick-xml` (XML parsing), `url` (RFC 3986),
85//! and `phf` (compile-time hash maps).
86
87mod detect;
88mod parse;
89mod rules;
90mod summarize;
91
92use std::collections::HashMap;
93
94// ── Public types ─────────────────────────────────────────────────────────────
95
96/// The VAST version as declared in the `version` attribute or inferred from
97/// document structure.
98///
99/// Covers all versions published by IAB Tech Lab: 2.0 through 4.3.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum VastVersion {
102 V2_0,
103 V3_0,
104 V4_0,
105 V4_1,
106 V4_2,
107 V4_3,
108}
109
110impl VastVersion {
111 pub fn as_str(&self) -> &'static str {
112 match self {
113 VastVersion::V2_0 => "2.0",
114 VastVersion::V3_0 => "3.0",
115 VastVersion::V4_0 => "4.0",
116 VastVersion::V4_1 => "4.1",
117 VastVersion::V4_2 => "4.2",
118 VastVersion::V4_3 => "4.3",
119 }
120 }
121
122 /// Returns true if this version is 4.x or later.
123 pub fn is_v4(&self) -> bool {
124 matches!(
125 self,
126 VastVersion::V4_0 | VastVersion::V4_1 | VastVersion::V4_2 | VastVersion::V4_3
127 )
128 }
129
130 /// Returns true if this version is at least the given version.
131 pub fn at_least(&self, other: &VastVersion) -> bool {
132 self.ordinal() >= other.ordinal()
133 }
134
135 fn ordinal(&self) -> u8 {
136 match self {
137 VastVersion::V2_0 => 0,
138 VastVersion::V3_0 => 1,
139 VastVersion::V4_0 => 2,
140 VastVersion::V4_1 => 3,
141 VastVersion::V4_2 => 4,
142 VastVersion::V4_3 => 5,
143 }
144 }
145}
146
147/// How the version was determined.
148///
149/// Version detection is a two-pass process: first the `version` attribute on
150/// the root `<VAST>` element is read (declared), then the document structure
151/// is scanned for version-specific elements (inferred). When both are
152/// available, consistency is checked and a mismatch produces a warning.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum DetectedVersion {
155 /// Version attribute was present and recognised.
156 Declared(VastVersion),
157 /// Version attribute was absent or unrecognised; version inferred from
158 /// document structure.
159 Inferred(VastVersion),
160 /// Both declared and inferred — may or may not agree.
161 DeclaredAndInferred {
162 declared: VastVersion,
163 inferred: VastVersion,
164 consistent: bool,
165 },
166 /// Could not determine version.
167 Unknown,
168}
169
170impl DetectedVersion {
171 /// Returns the best available version, preferring the declared value.
172 pub fn best(&self) -> Option<&VastVersion> {
173 match self {
174 DetectedVersion::Declared(v) => Some(v),
175 DetectedVersion::Inferred(v) => Some(v),
176 DetectedVersion::DeclaredAndInferred { declared, .. } => Some(declared),
177 DetectedVersion::Unknown => None,
178 }
179 }
180}
181
182/// Issue severity, based strictly on spec language.
183///
184/// Error — spec says "must" or "required": the tag will likely fail to serve.
185/// Warning — spec says "should" or "recommended", or the feature is deprecated.
186/// Info — advisory; not a spec violation but a known interoperability risk.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
188pub enum Severity {
189 Info,
190 Warning,
191 Error,
192}
193
194impl Severity {
195 pub fn as_str(&self) -> &'static str {
196 match self {
197 Severity::Error => "error",
198 Severity::Warning => "warning",
199 Severity::Info => "info",
200 }
201 }
202}
203
204/// A single validation finding.
205#[derive(Debug, Clone)]
206pub struct Issue {
207 /// Stable rule identifier, e.g. "VAST-2.0-root-version".
208 pub id: &'static str,
209 /// Effective severity after applying any caller overrides.
210 pub severity: Severity,
211 /// Human-readable message. Static string; no allocation on the hot path.
212 pub message: &'static str,
213 /// XPath-like location in the document, e.g. `/VAST/Ad\[0\]/InLine/AdSystem`.
214 /// None when the issue applies to the document as a whole.
215 pub path: Option<String>,
216 /// Short spec reference, e.g. "IAB VAST 4.1 §3.4.1".
217 pub spec_ref: &'static str,
218 /// 1-based line number of the element that triggered this issue.
219 /// None for document-level issues (e.g. parse errors, missing root).
220 pub line: Option<u32>,
221 /// 1-based column number (byte offset within the line) of the element.
222 /// None for document-level issues.
223 pub col: Option<u32>,
224}
225
226/// Counts of issues by severity.
227///
228/// Use [`Summary::is_valid`] to check whether the document passes validation.
229/// A document is valid when `errors == 0`, regardless of warning or info count.
230#[derive(Debug, Clone, Default)]
231pub struct Summary {
232 pub errors: usize,
233 pub warnings: usize,
234 pub infos: usize,
235}
236
237impl Summary {
238 pub fn is_valid(&self) -> bool {
239 self.errors == 0
240 }
241}
242
243/// The full result of validating a VAST document.
244///
245/// Contains the detected version, all issues found, and a summary with counts.
246/// The `issues` vector is ordered by document position (depth-first traversal).
247#[derive(Debug, Clone)]
248pub struct ValidationResult {
249 pub version: DetectedVersion,
250 pub issues: Vec<Issue>,
251 pub summary: Summary,
252}
253
254// ── Rule configuration ────────────────────────────────────────────────────────
255
256/// Per-rule severity override. Mirrors Severity but adds Off.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum RuleLevel {
259 Error,
260 Warning,
261 Info,
262 /// Rule does not run. Produces no Issue.
263 Off,
264}
265
266/// Context passed to validate_with_context. All fields have safe defaults.
267#[derive(Debug, Clone)]
268pub struct ValidationContext {
269 /// Current wrapper chain depth. 0 = this document is the root.
270 pub wrapper_depth: u8,
271 /// Maximum allowed wrapper depth. IAB VAST 4.x recommends 5.
272 pub max_wrapper_depth: u8,
273 /// Per-rule severity overrides keyed by rule ID.
274 /// None means "use all recommended defaults".
275 pub rule_overrides: Option<HashMap<&'static str, RuleLevel>>,
276}
277
278impl Default for ValidationContext {
279 fn default() -> Self {
280 Self {
281 wrapper_depth: 0,
282 max_wrapper_depth: 5,
283 rule_overrides: None,
284 }
285 }
286}
287
288impl ValidationContext {
289 /// Resolve the effective level for a rule, applying any override.
290 /// Returns None when the rule should be silenced (Off).
291 pub(crate) fn resolve(&self, rule_id: &'static str, default: Severity) -> Option<Severity> {
292 match &self.rule_overrides {
293 None => Some(default),
294 Some(map) => match map.get(rule_id) {
295 None => Some(default),
296 Some(RuleLevel::Off) => None,
297 Some(RuleLevel::Error) => Some(Severity::Error),
298 Some(RuleLevel::Warning) => Some(Severity::Warning),
299 Some(RuleLevel::Info) => Some(Severity::Info),
300 },
301 }
302 }
303}
304
305// ── Entry points ──────────────────────────────────────────────────────────────
306
307/// Validate a VAST XML string using default settings.
308///
309/// This is the main entry point for most callers. It runs the full rule set
310/// against the document and returns a [`ValidationResult`] containing every
311/// issue found, a detected version, and a summary.
312///
313/// # Example
314///
315/// ```rust
316/// let xml = r#"<VAST version="4.1">
317/// <Ad id="1">
318/// <InLine>
319/// <AdSystem>Example</AdSystem>
320/// <AdTitle>Test Ad</AdTitle>
321/// <AdServingId>abc123</AdServingId>
322/// <Impression>https://track.example.com/imp</Impression>
323/// <Creatives>
324/// <Creative>
325/// <UniversalAdId idRegistry="ad-id.org">UID-001</UniversalAdId>
326/// <Linear>
327/// <Duration>00:00:30</Duration>
328/// <MediaFiles>
329/// <MediaFile delivery="progressive" type="video/mp4"
330/// width="1920" height="1080">
331/// https://cdn.example.com/ad.mp4
332/// </MediaFile>
333/// </MediaFiles>
334/// </Linear>
335/// </Creative>
336/// </Creatives>
337/// </InLine>
338/// </Ad>
339/// </VAST>"#;
340///
341/// let result = vastlint_core::validate(xml);
342/// assert!(result.summary.is_valid());
343/// // Info-level advisories (e.g. missing Mezzanine for CTV) may be present
344/// // but the document has no errors or warnings that affect validity.
345/// assert_eq!(result.summary.errors, 0);
346/// ```
347pub fn validate(input: &str) -> ValidationResult {
348 validate_with_context(input, ValidationContext::default())
349}
350
351/// Validate a VAST XML string with caller-supplied context.
352///
353/// Use this when you need to declare wrapper chain depth or override the
354/// severity of specific rules. For simple validation, prefer [`validate`].
355///
356/// # Wrapper chain depth
357///
358/// When following a wrapper chain, pass the current depth so the
359/// [`crate::Severity::Error`] rule for `VAST-2.0-wrapper-depth` fires at the
360/// right level:
361///
362/// ```rust
363/// use vastlint_core::{ValidationContext, validate_with_context};
364///
365/// let ctx = ValidationContext {
366/// wrapper_depth: 3,
367/// max_wrapper_depth: 5,
368/// ..Default::default()
369/// };
370/// let result = validate_with_context("<VAST/>", ctx);
371/// ```
372///
373/// # Rule overrides
374///
375/// Suppress or downgrade individual rules by passing a rule override map.
376/// Rule IDs are the stable identifiers from the [`all_rules`] catalog.
377///
378/// ```rust
379/// use std::collections::HashMap;
380/// use vastlint_core::{RuleLevel, ValidationContext, validate_with_context};
381///
382/// let mut overrides = HashMap::new();
383/// // Silence the HTTP-vs-HTTPS advisory for internal tooling.
384/// overrides.insert("VAST-2.0-mediafile-https", RuleLevel::Off);
385/// // Treat a missing version attribute as a hard error.
386/// overrides.insert("VAST-2.0-root-version", RuleLevel::Error);
387///
388/// let ctx = ValidationContext {
389/// rule_overrides: Some(overrides),
390/// ..Default::default()
391/// };
392/// let result = validate_with_context("<VAST/>", ctx);
393/// ```
394pub fn validate_with_context(input: &str, context: ValidationContext) -> ValidationResult {
395 let doc = parse::parse(input);
396 let version = detect::detect_version(&doc);
397 let mut issues = Vec::new();
398 rules::run(&doc, &version, &context, &mut issues);
399 let summary = summarize::summarize(&issues);
400 ValidationResult {
401 version,
402 issues,
403 summary,
404 }
405}
406
407// ── Rule catalog ──────────────────────────────────────────────────────────────
408
409/// Metadata about a single rule, as exposed by the public catalog.
410pub struct RuleMeta {
411 pub id: &'static str,
412 pub default_severity: Severity,
413 pub description: &'static str,
414}
415
416/// Returns the full catalog of known rules in definition order.
417///
418/// Use this to power `vastlint rules` output or to validate config-file rule
419/// IDs before passing them into `ValidationContext.rule_overrides`.
420pub fn all_rules() -> &'static [RuleMeta] {
421 rules::CATALOG
422}