use super::emit;
use crate::parse::{Node, VastDocument};
use crate::{DetectedVersion, Issue, Severity, ValidationContext, VastVersion};
const ADCOM_SIGNALS: [&str; 4] = ["plcmt", "pos", "playbackmethod", "attr"];
const PLCMT_MAX: i64 = 9;
const PLAYBACKMETHOD_MAX: i64 = 11;
const POS_MAX: i64 = 17;
const MOTION_ATTRS: std::ops::RangeInclusive<i64> = 21..=23;
pub fn check(
doc: &VastDocument,
version: &DetectedVersion,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
let Some(vast) = doc.vast_root() else { return };
let Some(v) = version.best() else { return };
if !v.is_v4() {
return;
}
if matches!(v, VastVersion::V4_4) {
emit(
ctx,
issues,
"VAST-4.4-version-attribute",
Severity::Info,
"Document declares VAST 4.4, which is a working-group draft rather than a published spec. IAB's own CTV Ad Portfolio examples declare 4.2",
Some("/VAST@version".to_string()),
"IAB vast_4.4.xsd (draft annotation)",
Some(vast),
);
}
for (ad_idx, ad) in vast.children_named("Ad").enumerate() {
let ad_path = format!("/VAST/Ad[{}]", ad_idx);
for container in ["InLine", "Wrapper"] {
let Some(node) = ad.child(container) else {
continue;
};
let node_path = format!("{}/{}", ad_path, container);
if let Some(extensions) = node.child("Extensions") {
check_adcom_extensions(
extensions,
&format!("{}/Extensions", node_path),
ctx,
issues,
);
}
let Some(creatives) = node.child("Creatives") else {
continue;
};
for (ci, creative) in creatives.children_named("Creative").enumerate() {
let creative_path = format!("{}/Creatives/Creative[{}]", node_path, ci);
check_creative(creative, &creative_path, ctx, issues);
}
}
}
}
fn check_creative(creative: &Node, path: &str, ctx: &ValidationContext, issues: &mut Vec<Issue>) {
if let Some(nl_ads) = creative.child("NonLinearAds") {
let nl_ads_path = format!("{}/NonLinearAds", path);
for (i, nl) in nl_ads.children_named("NonLinear").enumerate() {
check_non_linear(
nl,
&format!("{}/NonLinear[{}]", nl_ads_path, i),
ctx,
issues,
);
}
}
if let Some(creative_exts) = creative.child("CreativeExtensions") {
let exts_path = format!("{}/CreativeExtensions", path);
for (i, ext) in creative_exts
.children_named("CreativeExtension")
.enumerate()
{
check_qr_creative_extension(
ext,
&format!("{}/CreativeExtension[{}]", exts_path, i),
ctx,
issues,
);
}
}
}
fn check_non_linear(nl: &Node, path: &str, ctx: &ValidationContext, issues: &mut Vec<Issue>) {
let Some(media_files) = nl.child("MediaFiles") else {
return;
};
let has_media_file = media_files.has_child("MediaFile");
let has_interactive = media_files.has_child("InteractiveCreativeFile");
let has_classic_resource = nl.has_child("StaticResource")
|| nl.has_child("IFrameResource")
|| nl.has_child("HTMLResource");
if has_interactive && !has_media_file && !has_classic_resource {
emit(
ctx,
issues,
"VAST-4.4-nonlinear-no-renderable-asset",
Severity::Warning,
"<NonLinear> carries an <InteractiveCreativeFile> but no renderable fallback: players without SIMID support have nothing to render and will fire the error URI",
Some(format!("{}/MediaFiles", path)),
"IAB CTV Ad Portfolio §Secure Interactive Ad Units (Fallback Media)",
Some(media_files),
);
}
if !has_media_file && !has_interactive && !has_classic_resource {
emit(
ctx,
issues,
"VAST-4.4-nonlinear-mediafiles-empty",
Severity::Error,
"<NonLinear> has a <MediaFiles> container with no <MediaFile> or <InteractiveCreativeFile> and no static resource; the ad has no asset to render",
Some(format!("{}/MediaFiles", path)),
"IAB CTV Ad Portfolio §Signaling the Five Non-Linear CTV Formats",
Some(media_files),
);
}
for iframe in nl.children_named("IFrameResource") {
if iframe
.attr("apiFramework")
.is_some_and(|f| f.eq_ignore_ascii_case("SIMID"))
{
emit(
ctx,
issues,
"VAST-4.4-nonlinear-simid-iframe",
Severity::Info,
"<IFrameResource apiFramework=\"SIMID\"> is the superseded pattern. CTV Ad Portfolio NonLinear ads should declare SIMID as <InteractiveCreativeFile apiFramework=\"SIMID\"> inside <MediaFiles>",
Some(format!("{}/IFrameResource", path)),
"IAB CTV Ad Portfolio §Secure Interactive Ad Units",
Some(iframe),
);
}
}
let has_video_media_file = media_files.children_named("MediaFile").any(|mf| {
mf.attr("type")
.is_some_and(|t| t.trim().to_ascii_lowercase().starts_with("video/"))
});
if has_video_media_file && !nl.has_child("Duration") {
emit(
ctx,
issues,
"VAST-4.4-nonlinear-video-no-duration",
Severity::Warning,
"<NonLinear> delivers a video <MediaFile> but declares no <Duration>: quartile and overlayViewDuration tracking cannot fire without it",
Some(path.to_string()),
"IAB CTV Ad Portfolio §Handling Duration",
Some(nl),
);
}
}
fn check_adcom_extensions(
extensions: &Node,
path: &str,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
for (i, ext) in extensions.children_named("Extension").enumerate() {
let ext_path = format!("{}/Extension[{}]", path, i);
let is_adcom = ext
.attr("ext")
.is_some_and(|e| e.eq_ignore_ascii_case("adcom"))
|| ADCOM_SIGNALS
.iter()
.any(|s| ext.children_named(s).next().is_some());
if !is_adcom {
continue;
}
let declared_type = ext.attr("type").map(str::trim);
if let Some(t) = declared_type {
if !ADCOM_SIGNALS.contains(&t) {
emit(
ctx,
issues,
"VAST-4.4-adcom-extension-unknown-signal",
Severity::Warning,
"<Extension ext=\"adcom\"> declares a type that is not an AdCOM signal. Expected plcmt, pos, playbackmethod or attr",
Some(format!("{}@type", ext_path)),
"IAB CTV Ad Portfolio §Purpose of VAST ext",
Some(ext),
);
}
}
for signal in ADCOM_SIGNALS {
for payload in ext.children_named(signal) {
let payload_path = format!("{}/{}", ext_path, signal);
if let Some(t) = declared_type {
if ADCOM_SIGNALS.contains(&t) && t != signal {
emit(
ctx,
issues,
"VAST-4.4-adcom-extension-type-mismatch",
Severity::Warning,
"<Extension> declares one AdCOM signal in its type attribute but carries a different one as its payload; downstream stitchers key off type",
Some(payload_path.clone()),
"IAB CTV Ad Portfolio §Purpose of VAST ext",
Some(payload),
);
}
}
check_adcom_value(signal, payload, &payload_path, ctx, issues);
}
}
}
}
fn check_adcom_value(
signal: &str,
payload: &Node,
path: &str,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
let raw = payload.text.trim();
let Ok(value) = raw.parse::<i64>() else {
emit(
ctx,
issues,
"VAST-4.4-adcom-signal-not-integer",
Severity::Error,
"AdCOM signal payload in <Extension> is not an integer. plcmt, pos, playbackmethod and attr are all numeric enumerations",
Some(path.to_string()),
"IAB AdCOM 1.0 enumerated lists",
Some(payload),
);
return;
};
match signal {
"plcmt" => {
if !(1..=PLCMT_MAX).contains(&value) {
emit(
ctx,
issues,
"VAST-4.4-adcom-plcmt-value",
Severity::Warning,
"AdCOM plcmt outside the known Plcmt Subtypes (Video) range 1-9. CTV Ad Portfolio uses 5 Pause, 6 Screensaver, 7 Overlay, 8 Squeezeback, 9 In-Scene",
Some(path.to_string()),
"IAB AdCOM List: Plcmt Subtypes - Video",
Some(payload),
);
}
}
"playbackmethod" => {
if !(1..=PLAYBACKMETHOD_MAX).contains(&value) {
emit(
ctx,
issues,
"VAST-4.4-adcom-playbackmethod-value",
Severity::Warning,
"AdCOM playbackmethod outside the known Playback Methods range 1-11. CTV Ad Portfolio adds 8/9 for Pause and 10/11 for Screensaver",
Some(path.to_string()),
"IAB AdCOM List: Playback Methods",
Some(payload),
);
}
}
"pos" => {
if !(0..=POS_MAX).contains(&value) {
emit(
ctx,
issues,
"VAST-4.4-adcom-pos-value",
Severity::Warning,
"AdCOM pos outside the known Placement Positions range 0-17",
Some(path.to_string()),
"IAB AdCOM List: Placement Positions",
Some(payload),
);
}
}
"attr" if !MOTION_ATTRS.contains(&value) => emit(
ctx,
issues,
"VAST-4.4-adcom-attr-not-motion",
Severity::Info,
"AdCOM attr round-tripped into VAST is not one of the CTV Ad Portfolio motion attributes (21 Static Visual, 22 Limited Motion, 23 Full-Motion Video); publishers validate the rendered experience against these",
Some(path.to_string()),
"IAB CTV Ad Portfolio §Declaring Creative Experience with battr and attr",
Some(payload),
),
_ => {}
}
}
fn check_qr_creative_extension(
ext: &Node,
path: &str,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
let position = ext.child("QrCodePosition");
let size = ext.child("QrCodeSize");
let scan_url = ext.child("QrCodeScanUrl");
if position.is_none() && size.is_none() && scan_url.is_none() {
return;
}
if let Some(pos) = position {
for attr in ["xPosition", "yPosition"] {
match pos.attr(attr) {
None => emit(
ctx,
issues,
"VAST-4.4-qrcode-position-attrs",
Severity::Error,
"<QrCodePosition> requires both xPosition and yPosition",
Some(format!("{}/QrCodePosition@{}", path, attr)),
"IAB vast_4.4.xsd vastQrCodePosition_type",
Some(pos),
),
Some(value) if !is_percent(value) => emit(
ctx,
issues,
"VAST-4.4-qrcode-position-percent",
Severity::Error,
"<QrCodePosition> coordinates must be percentages. Unlike <Icon>, bare pixel values are not valid here",
Some(format!("{}/QrCodePosition@{}", path, attr)),
"IAB vast_4.4.xsd vastPercent_type",
Some(pos),
),
Some(_) => {}
}
}
}
if let Some(sz) = size {
match sz.attr("size") {
None => emit(
ctx,
issues,
"VAST-4.4-qrcode-size-attr",
Severity::Error,
"<QrCodeSize> requires a size attribute",
Some(format!("{}/QrCodeSize", path)),
"IAB vast_4.4.xsd vastQrCodeSize_type",
Some(sz),
),
Some(value) if !is_percent(value) => emit(
ctx,
issues,
"VAST-4.4-qrcode-size-percent",
Severity::Error,
"<QrCodeSize> size must be a percentage",
Some(format!("{}/QrCodeSize@size", path)),
"IAB vast_4.4.xsd vastPercent_type",
Some(sz),
),
Some(_) => {}
}
}
if scan_url.is_none() && (position.is_some() || size.is_some()) {
emit(
ctx,
issues,
"VAST-4.4-qrcode-missing-scan-url",
Severity::Warning,
"<CreativeExtension> declares QR code geometry but no <QrCodeScanUrl>: the platform has position and size for a destination it does not know",
Some(path.to_string()),
"IAB CTV Ad Portfolio §QR Code Signaling",
Some(ext),
);
}
}
fn is_percent(value: &str) -> bool {
let v = value.trim();
let Some(number) = v.strip_suffix('%') else {
return false;
};
if number.is_empty() {
return false;
}
match number.split_once('.') {
None => number.bytes().all(|b| b.is_ascii_digit()),
Some((int, frac)) => {
!int.is_empty()
&& !frac.is_empty()
&& int.bytes().all(|b| b.is_ascii_digit())
&& frac.bytes().all(|b| b.is_ascii_digit())
}
}
}
#[cfg(test)]
mod tests {
use super::is_percent;
#[test]
fn percent_accepts_spec_forms() {
assert!(is_percent("0%"));
assert!(is_percent("15%"));
assert!(is_percent("100%"));
assert!(is_percent("12.5%"));
assert!(is_percent(" 70% "));
}
#[test]
fn percent_rejects_pixels_and_junk() {
assert!(!is_percent("120"));
assert!(!is_percent("120px"));
assert!(!is_percent("%"));
assert!(!is_percent("12.%"));
assert!(!is_percent(".5%"));
assert!(!is_percent("-5%"));
assert!(!is_percent(""));
}
}