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//! # 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}