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