vastlint_core/lib.rs
1//! # vastlint-core
2//!
3//! A zero-I/O VAST XML validation library. Takes a VAST, VMAP, or DAAST XML
4//! string and returns a structured [`ValidationResult`] listing every issue
5//! found, the detected document type and VAST version, and a summary of
6//! error/warning/info counts. The document type is decided by the root
7//! element: `<vmap:VMAP>` runs the VMAP 1.0 rules (including full VAST
8//! validation of inline `<vmap:VASTAdData>` ad data) and `<DAAST>` runs the
9//! DAAST 1.0 rules.
10//!
11//! The entire public surface is two functions and a handful of types:
12//!
13//! - [`validate`] -- validate with default settings (most callers want this)
14//! - [`validate_with_context`] -- validate with rule overrides or wrapper depth
15//! - [`fix`] -- fix deterministic issues and return repaired XML
16//! - [`fix_with_context`] -- fix with rule overrides or wrapper depth
17//! - [`inspect_document`] -- extract creative and wrapper metadata from one VAST XML document
18//! - [`all_rules`] -- list the full rule catalog
19//!
20//! # Performance — allocator recommendation
21//!
22//! `vastlint-core` builds an owned document tree on every call (one heap
23//! allocation per XML element, attribute, and text node). Under concurrent
24//! load the system allocator becomes a bottleneck because all threads compete
25//! for a shared free-list lock.
26//!
27//! Switching to [`mimalloc`](https://docs.rs/mimalloc) in your **binary**
28//! crate eliminates this contention and gives dramatically better throughput
29//! at high concurrency, especially for larger documents:
30//!
31//! ```toml
32//! # Cargo.toml (your binary, not a library crate)
33//! [dependencies]
34//! mimalloc = { version = "0.1", default-features = false }
35//! ```
36//!
37//! ```rust,ignore
38//! // src/main.rs
39//! use mimalloc::MiMalloc;
40//! #[global_allocator]
41//! static GLOBAL: MiMalloc = MiMalloc;
42//! ```
43//!
44//! Measured on Apple M4 (10 threads, production-realistic VAST tags):
45//!
46//! | Allocator | 17 KB tag | 44 KB tag |
47//! |---|---|---|
48//! | system (default) | 1,847 tags/s · 541 µs | 328 tags/s · 3,048 µs |
49//! | mimalloc | 15,760 tags/s · 63 µs | 2,635 tags/s · 380 µs |
50//!
51//! **mimalloc: ~8× throughput improvement on multi-threaded workloads.**
52//!
53//! > ⚠️ Do **not** set a global allocator in a library crate — it would
54//! > override the allocator for any host process that links you (Go, Python,
55//! > Ruby runtimes, etc.), which can cause heap corruption.
56//!
57//! # Quick start
58//!
59//! ```rust
60//! let xml = r#"<VAST version="2.0">
61//! <Ad><InLine>
62//! <AdSystem>Demo</AdSystem>
63//! <AdTitle>Ad</AdTitle>
64//! <Impression>https://t.example.com/imp</Impression>
65//! <Creatives>
66//! <Creative>
67//! <Linear>
68//! <Duration>00:00:15</Duration>
69//! <MediaFiles>
70//! <MediaFile delivery="progressive" type="video/mp4"
71//! width="640" height="360">
72//! https://cdn.example.com/ad.mp4
73//! </MediaFile>
74//! </MediaFiles>
75//! </Linear>
76//! </Creative>
77//! </Creatives>
78//! </InLine></Ad>
79//! </VAST>"#;
80//!
81//! let result = vastlint_core::validate(xml);
82//! assert_eq!(result.summary.errors, 0);
83//! ```
84//!
85//! # Design constraints
86//!
87//! The library has no I/O, no logging, no global state, and no async runtime.
88//! It can be embedded in a CLI, HTTP server, WASM module, or FFI binding
89//! without pulling in any platform-specific dependencies.
90//!
91//! Three crate dependencies: `quick-xml` (XML parsing), `url` (RFC 3986),
92//! and `phf` (compile-time hash maps).
93
94mod detect;
95mod fix;
96mod inspect;
97mod parse;
98mod rules;
99mod summarize;
100
101pub use fix::{fix, fix_with_context, AppliedFix, FixResult};
102pub use inspect::{inspect_document, InspectAdType, InspectDocumentMeta, InspectMediaFile};
103
104use std::collections::HashMap;
105
106// ── Public types ─────────────────────────────────────────────────────────────
107
108/// The VAST version as declared in the `version` attribute or inferred from
109/// document structure.
110///
111/// Covers all versions published by IAB Tech Lab: 2.0 through 4.3, plus the
112/// 4.4 working draft.
113///
114/// 4.4 is not a published spec. `vast_4.4.xsd` landed in the IAB VAST repo on
115/// 2026-07-17 alongside the CTV Ad Portfolio work and its own annotation reads
116/// "DRAFT for working group discussion". vastlint recognises the version so
117/// that tags declaring it validate rather than falling through as unknown, but
118/// 4.4-only findings are reported at warning or info severity. See
119/// `specs/vast_4.4_reference.md`.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum VastVersion {
122 V2_0,
123 V3_0,
124 V4_0,
125 V4_1,
126 V4_2,
127 V4_3,
128 V4_4,
129}
130
131impl VastVersion {
132 pub fn as_str(&self) -> &'static str {
133 match self {
134 VastVersion::V2_0 => "2.0",
135 VastVersion::V3_0 => "3.0",
136 VastVersion::V4_0 => "4.0",
137 VastVersion::V4_1 => "4.1",
138 VastVersion::V4_2 => "4.2",
139 VastVersion::V4_3 => "4.3",
140 VastVersion::V4_4 => "4.4",
141 }
142 }
143
144 /// Returns true if this version is 4.x or later.
145 pub fn is_v4(&self) -> bool {
146 matches!(
147 self,
148 VastVersion::V4_0
149 | VastVersion::V4_1
150 | VastVersion::V4_2
151 | VastVersion::V4_3
152 | VastVersion::V4_4
153 )
154 }
155
156 /// Returns true if this version is a working draft rather than a published
157 /// IAB Tech Lab specification.
158 ///
159 /// Only 4.4 qualifies today. Callers use this to soften severities: a
160 /// construct that only the draft schema blesses should not be reported as
161 /// an error against a spec that may still change.
162 pub fn is_draft(&self) -> bool {
163 matches!(self, VastVersion::V4_4)
164 }
165
166 /// Returns true if this version is at least the given version.
167 pub fn at_least(&self, other: &VastVersion) -> bool {
168 self.ordinal() >= other.ordinal()
169 }
170
171 fn ordinal(&self) -> u8 {
172 match self {
173 VastVersion::V2_0 => 0,
174 VastVersion::V3_0 => 1,
175 VastVersion::V4_0 => 2,
176 VastVersion::V4_1 => 3,
177 VastVersion::V4_2 => 4,
178 VastVersion::V4_3 => 5,
179 VastVersion::V4_4 => 6,
180 }
181 }
182}
183
184/// The kind of IAB ad document that was validated.
185///
186/// vastlint dispatches on the root element: `<VAST>` documents run the VAST
187/// rule chain, `<vmap:VMAP>` documents run the VMAP 1.0 rules (including full
188/// VAST validation of any inline ad data), and `<DAAST>` documents run the
189/// DAAST 1.0 rules. Any other root is treated as (invalid) VAST.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum DocumentType {
192 Vast,
193 Vmap,
194 Daast,
195}
196
197impl DocumentType {
198 pub fn as_str(&self) -> &'static str {
199 match self {
200 DocumentType::Vast => "VAST",
201 DocumentType::Vmap => "VMAP",
202 DocumentType::Daast => "DAAST",
203 }
204 }
205}
206
207/// How the version was determined.
208///
209/// Version detection is a two-pass process: first the `version` attribute on
210/// the root `<VAST>` element is read (declared), then the document structure
211/// is scanned for version-specific elements (inferred). When both are
212/// available, consistency is checked and a mismatch produces a warning.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum DetectedVersion {
215 /// Version attribute was present and recognised.
216 Declared(VastVersion),
217 /// Version attribute was absent or unrecognised; version inferred from
218 /// document structure.
219 Inferred(VastVersion),
220 /// Both declared and inferred — may or may not agree.
221 DeclaredAndInferred {
222 declared: VastVersion,
223 inferred: VastVersion,
224 consistent: bool,
225 },
226 /// Could not determine version.
227 Unknown,
228}
229
230impl DetectedVersion {
231 /// Returns the best available version, preferring the declared value.
232 pub fn best(&self) -> Option<&VastVersion> {
233 match self {
234 DetectedVersion::Declared(v) => Some(v),
235 DetectedVersion::Inferred(v) => Some(v),
236 DetectedVersion::DeclaredAndInferred { declared, .. } => Some(declared),
237 DetectedVersion::Unknown => None,
238 }
239 }
240}
241
242/// Issue severity, based strictly on spec language.
243///
244/// Error — spec says "must" or "required": the tag will likely fail to serve.
245/// Warning — spec says "should" or "recommended", or the feature is deprecated.
246/// Info — advisory; not a spec violation but a known interoperability risk.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
248pub enum Severity {
249 Info,
250 Warning,
251 Error,
252}
253
254impl Severity {
255 pub fn as_str(&self) -> &'static str {
256 match self {
257 Severity::Error => "error",
258 Severity::Warning => "warning",
259 Severity::Info => "info",
260 }
261 }
262}
263
264/// A single validation finding.
265#[derive(Debug, Clone)]
266pub struct Issue {
267 /// Stable rule identifier, e.g. "VAST-2.0-root-version".
268 pub id: &'static str,
269 /// Effective severity after applying any caller overrides.
270 pub severity: Severity,
271 /// Human-readable message. Static string; no allocation on the hot path.
272 pub message: &'static str,
273 /// XPath-like location in the document, e.g. `/VAST/Ad\[0\]/InLine/AdSystem`.
274 /// None when the issue applies to the document as a whole.
275 pub path: Option<String>,
276 /// Short spec reference, e.g. "IAB VAST 4.1 §3.4.1".
277 pub spec_ref: &'static str,
278 /// 1-based line number of the element that triggered this issue.
279 /// None for document-level issues (e.g. parse errors, missing root).
280 pub line: Option<u32>,
281 /// 1-based column number (byte offset within the line) of the element.
282 /// None for document-level issues.
283 pub col: Option<u32>,
284}
285
286/// Counts of issues by severity.
287///
288/// Use [`Summary::is_valid`] to check whether the document passes validation.
289/// A document is valid when `errors == 0`, regardless of warning or info count.
290#[derive(Debug, Clone, Default)]
291pub struct Summary {
292 pub errors: usize,
293 pub warnings: usize,
294 pub infos: usize,
295}
296
297impl Summary {
298 pub fn is_valid(&self) -> bool {
299 self.errors == 0
300 }
301}
302
303/// The full result of validating a VAST document.
304///
305/// Contains the detected version, all issues found, and a summary with counts.
306/// The `issues` vector is ordered by document position (depth-first traversal).
307#[derive(Debug, Clone)]
308pub struct ValidationResult {
309 /// The kind of document that was validated (VAST, VMAP, or DAAST),
310 /// decided by the root element.
311 pub document_type: DocumentType,
312 /// The detected VAST version. Always `Unknown` for VMAP and DAAST
313 /// documents — their version attributes are checked by their own rules.
314 pub version: DetectedVersion,
315 pub issues: Vec<Issue>,
316 pub summary: Summary,
317}
318
319// ── Rule configuration ────────────────────────────────────────────────────────
320
321/// Per-rule severity override. Mirrors Severity but adds Off.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum RuleLevel {
324 Error,
325 Warning,
326 Info,
327 /// Rule does not run. Produces no Issue.
328 Off,
329}
330
331/// Context passed to validate_with_context. All fields have safe defaults.
332#[derive(Debug, Clone)]
333pub struct ValidationContext {
334 /// Current wrapper chain depth. 0 = this document is the root.
335 pub wrapper_depth: u8,
336 /// Maximum allowed wrapper depth. IAB VAST 4.x recommends 5.
337 pub max_wrapper_depth: u8,
338 /// Per-rule severity overrides keyed by rule ID.
339 /// None means "use all recommended defaults".
340 pub rule_overrides: Option<HashMap<&'static str, RuleLevel>>,
341 /// Override the VAST version used for validation, ignoring the version
342 /// attribute declared in the XML. None = auto-detect from the document
343 /// (default). Useful for validating templates or tags where the version
344 /// attribute is absent or incorrect.
345 pub forced_version: Option<VastVersion>,
346}
347
348impl Default for ValidationContext {
349 fn default() -> Self {
350 Self {
351 wrapper_depth: 0,
352 max_wrapper_depth: 5,
353 rule_overrides: None,
354 forced_version: None,
355 }
356 }
357}
358
359impl ValidationContext {
360 /// Resolve the effective level for a rule, applying any override.
361 /// Returns None when the rule should be silenced (Off).
362 pub(crate) fn resolve(&self, rule_id: &'static str, default: Severity) -> Option<Severity> {
363 match &self.rule_overrides {
364 None => Some(default),
365 Some(map) => match map.get(rule_id) {
366 None => Some(default),
367 Some(RuleLevel::Off) => None,
368 Some(RuleLevel::Error) => Some(Severity::Error),
369 Some(RuleLevel::Warning) => Some(Severity::Warning),
370 Some(RuleLevel::Info) => Some(Severity::Info),
371 },
372 }
373 }
374}
375
376// ── Entry points ──────────────────────────────────────────────────────────────
377
378/// Validate a VAST XML string using default settings.
379///
380/// This is the main entry point for most callers. It runs the full rule set
381/// against the document and returns a [`ValidationResult`] containing every
382/// issue found, a detected version, and a summary.
383///
384/// # Example
385///
386/// ```rust
387/// let xml = r#"<VAST version="4.1">
388/// <Ad id="1">
389/// <InLine>
390/// <AdSystem>Example</AdSystem>
391/// <AdTitle>Test Ad</AdTitle>
392/// <AdServingId>abc123</AdServingId>
393/// <Impression>https://track.example.com/imp</Impression>
394/// <Creatives>
395/// <Creative>
396/// <UniversalAdId idRegistry="ad-id.org">UID-001</UniversalAdId>
397/// <Linear>
398/// <Duration>00:00:30</Duration>
399/// <MediaFiles>
400/// <MediaFile delivery="progressive" type="video/mp4"
401/// width="1920" height="1080">
402/// https://cdn.example.com/ad.mp4
403/// </MediaFile>
404/// </MediaFiles>
405/// </Linear>
406/// </Creative>
407/// </Creatives>
408/// </InLine>
409/// </Ad>
410/// </VAST>"#;
411///
412/// let result = vastlint_core::validate(xml);
413/// assert!(result.summary.is_valid());
414/// // Info-level advisories (e.g. missing Mezzanine for CTV) may be present
415/// // but the document has no errors or warnings that affect validity.
416/// assert_eq!(result.summary.errors, 0);
417/// ```
418pub fn validate(input: &str) -> ValidationResult {
419 validate_with_context(input, ValidationContext::default())
420}
421
422/// Validate a VAST XML string with caller-supplied context.
423///
424/// Use this when you need to declare wrapper chain depth or override the
425/// severity of specific rules. For simple validation, prefer [`validate`].
426///
427/// # Wrapper chain depth
428///
429/// When following a wrapper chain, pass the current depth so the
430/// [`crate::Severity::Error`] rule for `VAST-2.0-wrapper-depth` fires at the
431/// right level:
432///
433/// ```rust
434/// use vastlint_core::{ValidationContext, validate_with_context};
435///
436/// let ctx = ValidationContext {
437/// wrapper_depth: 3,
438/// max_wrapper_depth: 5,
439/// ..Default::default()
440/// };
441/// let result = validate_with_context("<VAST/>", ctx);
442/// ```
443///
444/// # Rule overrides
445///
446/// Suppress or downgrade individual rules by passing a rule override map.
447/// Rule IDs are the stable identifiers from the [`all_rules`] catalog.
448///
449/// ```rust
450/// use std::collections::HashMap;
451/// use vastlint_core::{RuleLevel, ValidationContext, validate_with_context};
452///
453/// let mut overrides = HashMap::new();
454/// // Silence the HTTP-vs-HTTPS advisory for internal tooling.
455/// overrides.insert("VAST-2.0-mediafile-https", RuleLevel::Off);
456/// // Treat a missing version attribute as a hard error.
457/// overrides.insert("VAST-2.0-root-version", RuleLevel::Error);
458///
459/// let ctx = ValidationContext {
460/// rule_overrides: Some(overrides),
461/// ..Default::default()
462/// };
463/// let result = validate_with_context("<VAST/>", ctx);
464/// ```
465pub fn validate_with_context(input: &str, context: ValidationContext) -> ValidationResult {
466 let doc = parse::parse(input);
467 let document_type = detect::detect_document_type(&doc);
468 let version = match document_type {
469 // version attributes on VMAP/DAAST roots are validated by their own
470 // rule chains; DetectedVersion only describes VAST versions.
471 DocumentType::Vmap | DocumentType::Daast => DetectedVersion::Unknown,
472 DocumentType::Vast => match context.forced_version {
473 Some(v) => DetectedVersion::Declared(v),
474 None => detect::detect_version(&doc),
475 },
476 };
477 let mut issues = Vec::new();
478 rules::run(&doc, &version, &context, &mut issues);
479 let summary = summarize::summarize(&issues);
480 ValidationResult {
481 document_type,
482 version,
483 issues,
484 summary,
485 }
486}
487
488// ── Test helpers (integration tests only) ────────────────────────────────────
489
490/// Re-exports the internal parser for integration tests that need to verify
491/// the repaired XML round-trips without parse errors.
492#[doc(hidden)]
493pub fn _test_parse(xml: &str) -> parse::VastDocument {
494 parse::parse(xml)
495}
496
497/// The external standard or authority that a rule is derived from.
498///
499/// Mirrors the standards listed in the README. Use this to filter the catalog
500/// by authority level — e.g. alert hard on [`RuleSource::VastSpec`] violations
501/// while only logging [`RuleSource::Inferred`] advisories.
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum RuleSource {
504 /// IAB Tech Lab VAST spec normative prose (explicit §-references)
505 VastSpec,
506 /// IAB Tech Lab VAST published XSD schemas (structural and enum constraints)
507 VastXsd,
508 /// W3C XML 1.0 well-formedness
509 Xml,
510 /// RFC 3986 URI syntax
511 Rfc3986,
512 /// IANA Media Types registry
513 IanaMediaTypes,
514 /// ISO 4217 currency codes
515 Iso4217,
516 /// Ad-ID registry format
517 AdId,
518 /// vastlint heuristic — no single external spec authority
519 Inferred,
520 /// IAB Tech Lab SIMID spec normative prose
521 SimidSpec,
522 /// IAB Tech Lab VMAP 1.0.1 spec normative prose
523 VmapSpec,
524 /// IAB Tech Lab DAAST spec normative prose
525 DaastSpec,
526 /// IAB Tech Lab DAAST published XSD schema
527 DaastXsd,
528 /// Industry best practice derived from real-world ad serving patterns;
529 /// violation has a direct revenue or measurement impact.
530 IndustryBestPractice,
531 /// IAB Tech Lab CTV Ad Portfolio signaling guidance (finalised 2026-07-22).
532 /// Distinct from [`RuleSource::VastXsd`] because the guidance is a published
533 /// final standard while the accompanying `vast_4.4.xsd` is still a
534 /// working-group draft.
535 CtvAdPortfolio,
536}
537
538impl RuleSource {
539 /// Short stable string identifier, suitable for JSON output and display.
540 pub fn as_str(self) -> &'static str {
541 match self {
542 RuleSource::VastSpec => "VAST spec",
543 RuleSource::VastXsd => "VAST XSD",
544 RuleSource::Xml => "W3C XML 1.0",
545 RuleSource::Rfc3986 => "RFC 3986",
546 RuleSource::IanaMediaTypes => "IANA Media Types",
547 RuleSource::Iso4217 => "ISO 4217",
548 RuleSource::AdId => "Ad-ID",
549 RuleSource::Inferred => "inferred",
550 RuleSource::SimidSpec => "IAB SIMID",
551 RuleSource::VmapSpec => "IAB VMAP",
552 RuleSource::DaastSpec => "IAB DAAST",
553 RuleSource::DaastXsd => "DAAST XSD",
554 RuleSource::IndustryBestPractice => "revenue impact",
555 RuleSource::CtvAdPortfolio => "IAB CTV Ad Portfolio",
556 }
557 }
558}
559
560/// Metadata about a single rule, as exposed by the public catalog.
561///
562/// Marked `#[non_exhaustive]` so that adding fields in future minor releases
563/// does not break downstream code that reads (but never constructs) `RuleMeta`.
564#[non_exhaustive]
565pub struct RuleMeta {
566 pub id: &'static str,
567 pub default_severity: Severity,
568 pub description: &'static str,
569 /// The external standard this rule is derived from.
570 pub source: RuleSource,
571}
572
573impl RuleMeta {
574 /// Returns `true` when violating this rule has a direct revenue or
575 /// measurement impact — lost impressions, broken tracking, zero fill.
576 ///
577 /// This covers both rules whose [`source`](RuleMeta::source) is
578 /// [`RuleSource::IndustryBestPractice`] and rules whose source is an IAB
579 /// spec standard but whose real-world consequence is measurable revenue
580 /// loss (missing `<Impression>`, dead wrapper redirect, etc.).
581 pub fn revenue_impact(&self) -> bool {
582 matches!(
583 self.id,
584 // IndustryBestPractice-sourced rules
585 "VAST-2.0-mediafile-https"
586 | "VAST-2.0-tracking-https"
587 | "VAST-2.0-duplicate-impression"
588 | "VAST-4.1-mezzanine-recommended"
589 | "VAST-4.1-vpaid-in-interactive-context"
590 | "VAST-2.0-linear-tracking-quartiles"
591 // VastSpec-sourced rules with direct revenue consequence
592 | "VAST-2.0-inline-impression"
593 | "VAST-2.0-wrapper-impression"
594 | "VAST-2.0-wrapper-vastadtaguri"
595 | "VAST-2.0-url-empty"
596 | "VAST-4.1-vpaid-apiframework"
597 | "VAST-2.0-flash-mediafile"
598 )
599 }
600}
601
602/// Returns the full catalog of known rules in definition order.
603///
604/// Use this to power `vastlint rules` output or to validate config-file rule
605/// IDs before passing them into `ValidationContext.rule_overrides`.
606pub fn all_rules() -> &'static [RuleMeta] {
607 rules::CATALOG
608}