Skip to main content

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}