Skip to main content

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}