1use std::collections::HashMap;
9use std::error::Error;
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 ArtifactKind, Engine, EngineError, ExpansionState, Severity, ValidationOptions,
16 ValidationRequest, ValidationSummary,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct DocumentExtractor {
22 pub id: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub version: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29pub struct DocumentArtifactInput {
30 #[serde(default = "default_url_kind")]
31 pub artifact_kind: ArtifactKind,
32 #[serde(alias = "raw_artifact")]
33 pub artifact: String,
34 #[serde(default)]
35 pub claimed_vendor: Option<String>,
36 #[serde(default)]
37 pub expansion_state: ExpansionState,
38 #[serde(default)]
39 pub occurrences: Vec<ArtifactOccurrence>,
40}
41
42fn default_url_kind() -> ArtifactKind {
43 ArtifactKind::Url
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ArtifactOccurrence {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub occurrence_id: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub source_kind: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub path: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub line: Option<u32>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub column: Option<u32>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub context_label: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65pub struct DocumentRequest {
66 #[serde(default = "default_document_kind")]
67 pub document_kind: String,
68 #[serde(default)]
69 pub extractor: Option<DocumentExtractor>,
70 #[serde(default)]
71 pub artifacts: Vec<DocumentArtifactInput>,
72}
73
74fn default_document_kind() -> String {
75 "list".to_string()
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct FindingCounts {
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub artifacts_total: Option<usize>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub unique_artifacts: Option<usize>,
85 pub errors: usize,
86 pub warnings: usize,
87 pub infos: usize,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct AggregatedArtifact {
93 pub artifact_id: String,
94 pub dedupe_key: String,
95 pub artifact_kind: ArtifactKind,
96 pub raw_artifact: String,
97 pub normalized_artifact: String,
98 pub ok: bool,
99 pub summary: FindingCounts,
100 pub reports: Vec<crate::ValidationReport>,
101 pub occurrences: Vec<ArtifactOccurrence>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct DocumentReport {
107 pub document_kind: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub extractor: Option<DocumentExtractor>,
110 pub summary: FindingCounts,
111 pub artifacts: Vec<AggregatedArtifact>,
112}
113
114impl DocumentReport {
115 pub fn is_ok(&self) -> bool {
116 self.summary.errors == 0
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum DocumentError {
123 UnsupportedKind { index: usize, kind: ArtifactKind },
124 Engine { index: usize, error: EngineError },
125}
126
127impl fmt::Display for DocumentError {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Self::UnsupportedKind { index, kind } => write!(
131 f,
132 "artifact {index}: {} is not a validation kind. Extract tracking URLs from the snippet, then pixellint validate url. Pixellint does not parse HTML, JavaScript, or GTM containers.",
133 kind_label(*kind)
134 ),
135 Self::Engine { index, error } => write!(f, "artifact {index}: {error}"),
136 }
137 }
138}
139
140impl Error for DocumentError {}
141
142fn kind_label(kind: ArtifactKind) -> &'static str {
143 match kind {
144 ArtifactKind::Url => "url",
145 ArtifactKind::HtmlSnippet => "html",
146 ArtifactKind::JavaScriptSnippet => "js",
147 ArtifactKind::GtmTemplate => "gtm",
148 ArtifactKind::NetworkRequest => "request",
149 ArtifactKind::VastTracker => "vast",
150 ArtifactKind::ServerPostback => "postback",
151 ArtifactKind::JsonPayload => "json",
152 ArtifactKind::Unknown => "unknown",
153 }
154}
155
156fn kind_rejected(kind: ArtifactKind) -> bool {
157 matches!(
158 kind,
159 ArtifactKind::HtmlSnippet | ArtifactKind::JavaScriptSnippet | ArtifactKind::GtmTemplate
160 )
161}
162
163fn dedupe_key(kind: ArtifactKind, normalized: &str) -> String {
164 format!("{}\n{normalized}", kind_label(kind))
165}
166
167fn counts_from(summary: &ValidationSummary) -> (usize, usize, usize) {
168 let mut errors = 0;
169 let mut warnings = 0;
170 let mut infos = 0;
171 for report in &summary.reports {
172 for violation in &report.violations {
173 match violation.severity {
174 Severity::Error => errors += 1,
175 Severity::Warning => warnings += 1,
176 Severity::Info => infos += 1,
177 }
178 }
179 }
180 (errors, warnings, infos)
181}
182
183impl Engine {
184 pub fn validate_many(
187 &self,
188 request: &DocumentRequest,
189 options: &ValidationOptions,
190 ) -> Result<DocumentReport, DocumentError> {
191 let mut order: Vec<String> = Vec::new();
192 let mut groups: HashMap<String, Group> = HashMap::new();
193
194 for (index, artifact) in request.artifacts.iter().enumerate() {
195 if kind_rejected(artifact.artifact_kind) {
196 return Err(DocumentError::UnsupportedKind {
197 index,
198 kind: artifact.artifact_kind,
199 });
200 }
201
202 let normalized = artifact.artifact.trim().to_string();
203 let key = dedupe_key(artifact.artifact_kind, &normalized);
204 let mut occurrences = artifact.occurrences.clone();
205 if occurrences.is_empty() {
206 occurrences.push(ArtifactOccurrence {
207 occurrence_id: None,
208 source_kind: None,
209 path: None,
210 line: None,
211 column: None,
212 context_label: None,
213 });
214 }
215 if let Some(group) = groups.get_mut(&key) {
216 group.occurrences.extend(occurrences);
217 continue;
218 }
219
220 order.push(key.clone());
221 groups.insert(
222 key,
223 Group {
224 first_index: index,
225 artifact_kind: artifact.artifact_kind,
226 raw_artifact: artifact.artifact.clone(),
227 normalized,
228 claimed_vendor: artifact.claimed_vendor.clone(),
229 expansion_state: artifact.expansion_state,
230 occurrences,
231 },
232 );
233 }
234
235 let mut artifacts = Vec::with_capacity(order.len());
236 let mut errors = 0;
237 let mut warnings = 0;
238 let mut infos = 0;
239
240 for (artifact_number, key) in order.iter().enumerate() {
241 let group = groups.get_mut(key).expect("group exists for ordered key");
242 let validation = ValidationRequest {
243 artifact_kind: group.artifact_kind,
244 artifact: group.normalized.clone(),
245 claimed_vendor: group.claimed_vendor.clone(),
246 expansion_state: group.expansion_state,
247 };
248 let summary =
249 self.validate(&validation, options)
250 .map_err(|error| DocumentError::Engine {
251 index: group.first_index,
252 error,
253 })?;
254
255 let (artifact_errors, artifact_warnings, artifact_infos) = counts_from(&summary);
256 errors += artifact_errors;
257 warnings += artifact_warnings;
258 infos += artifact_infos;
259
260 let occurrences = std::mem::take(&mut group.occurrences);
261
262 artifacts.push(AggregatedArtifact {
263 artifact_id: format!("artifact-{}", artifact_number + 1),
264 dedupe_key: key.clone(),
265 artifact_kind: group.artifact_kind,
266 raw_artifact: group.raw_artifact.clone(),
267 normalized_artifact: group.normalized.clone(),
268 ok: summary.is_ok(),
269 summary: FindingCounts {
270 artifacts_total: None,
271 unique_artifacts: None,
272 errors: artifact_errors,
273 warnings: artifact_warnings,
274 infos: artifact_infos,
275 },
276 reports: summary.reports,
277 occurrences,
278 });
279 }
280
281 let mut occ = 1;
283 for artifact in &mut artifacts {
284 for occurrence in &mut artifact.occurrences {
285 occurrence.occurrence_id = Some(format!("occ-{occ}"));
286 occ += 1;
287 }
288 }
289
290 Ok(DocumentReport {
291 document_kind: if request.document_kind.trim().is_empty() {
292 "list".to_string()
293 } else {
294 request.document_kind.clone()
295 },
296 extractor: request.extractor.clone(),
297 summary: FindingCounts {
298 artifacts_total: Some(request.artifacts.len()),
299 unique_artifacts: Some(artifacts.len()),
300 errors,
301 warnings,
302 infos,
303 },
304 artifacts,
305 })
306 }
307}
308
309struct Group {
310 first_index: usize,
311 artifact_kind: ArtifactKind,
312 raw_artifact: String,
313 normalized: String,
314 claimed_vendor: Option<String>,
315 expansion_state: ExpansionState,
316 occurrences: Vec<ArtifactOccurrence>,
317}
318
319pub fn document_request_from_json(raw: &str) -> Result<DocumentRequest, String> {
322 let value: serde_json::Value =
323 serde_json::from_str(raw).map_err(|error| format!("invalid document JSON: {error}"))?;
324 document_request_from_value(value)
325}
326
327pub fn document_request_from_value(value: serde_json::Value) -> Result<DocumentRequest, String> {
329 if let Some(items) = value.as_array() {
330 let mut artifacts = Vec::with_capacity(items.len());
331 for (index, item) in items.iter().enumerate() {
332 if let Some(url) = item.as_str() {
333 artifacts.push(DocumentArtifactInput {
334 artifact_kind: ArtifactKind::Url,
335 artifact: url.to_string(),
336 claimed_vendor: None,
337 expansion_state: ExpansionState::Unknown,
338 occurrences: Vec::new(),
339 });
340 continue;
341 }
342 artifacts.push(
343 serde_json::from_value(item.clone()).map_err(|error| {
344 format!("invalid document JSON at artifact {index}: {error}")
345 })?,
346 );
347 }
348 return Ok(DocumentRequest {
349 document_kind: "list".to_string(),
350 extractor: None,
351 artifacts,
352 });
353 }
354
355 serde_json::from_value(value).map_err(|error| format!("invalid document JSON: {error}"))
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::Engine;
362
363 fn engine() -> Engine {
364 Engine::default()
365 }
366
367 fn options() -> ValidationOptions {
368 ValidationOptions::default()
369 }
370
371 #[test]
372 fn duplicate_urls_validate_once_and_keep_occurrences() {
373 let request = document_request_from_json(
374 r#"{
375 "document_kind": "vast",
376 "extractor": { "id": "vastlint", "version": "0.4.16" },
377 "artifacts": [
378 {
379 "artifact": "https://example.com/pixel?id=1#frag",
380 "occurrences": [{ "source_kind": "xpath", "path": "/VAST/Ad[1]/InLine/Impression[1]" }]
381 },
382 {
383 "raw_artifact": "https://example.com/pixel?id=1#frag",
384 "occurrences": [{ "source_kind": "xpath", "path": "/VAST/Ad[1]/InLine/Tracking[3]" }]
385 }
386 ]
387 }"#,
388 )
389 .unwrap();
390
391 let report = engine().validate_many(&request, &options()).unwrap();
392 assert_eq!(report.document_kind, "vast");
393 assert_eq!(report.extractor.as_ref().unwrap().id, "vastlint");
394 assert_eq!(report.summary.artifacts_total, Some(2));
395 assert_eq!(report.summary.unique_artifacts, Some(1));
396 assert_eq!(report.artifacts[0].occurrences.len(), 2);
397 assert_eq!(
398 report.artifacts[0].occurrences[0].path.as_deref(),
399 Some("/VAST/Ad[1]/InLine/Impression[1]")
400 );
401 assert!(
402 report.artifacts[0]
403 .reports
404 .iter()
405 .flat_map(|item| &item.violations)
406 .any(|violation| violation.code == "core.url.fragment_ignored")
407 );
408 assert_eq!(report.summary.warnings, 1);
409 assert!(report.is_ok());
410 }
411
412 #[test]
413 fn a_url_array_is_a_list_of_url_artifacts() {
414 let request = document_request_from_json(
415 r#"["https://www.facebook.com/tr?id=1234567890123456&ev=PageView", "https://www.facebook.com/tr?ev=PageView"]"#,
416 )
417 .unwrap();
418 assert_eq!(request.document_kind, "list");
419 let report = engine().validate_many(&request, &options()).unwrap();
420 assert_eq!(report.summary.unique_artifacts, Some(2));
421 assert!(!report.is_ok());
422 assert_eq!(report.summary.errors, 1);
423 }
424
425 #[test]
426 fn a_url_array_keeps_one_occurrence_per_row() {
427 let request = document_request_from_json(
428 r#"["https://example.com/pixel?id=1#frag","https://example.com/pixel?id=1#frag"]"#,
429 )
430 .unwrap();
431 let report = engine().validate_many(&request, &options()).unwrap();
432 assert_eq!(report.summary.artifacts_total, Some(2));
433 assert_eq!(report.summary.unique_artifacts, Some(1));
434 assert_eq!(report.artifacts[0].occurrences.len(), 2);
435 }
436
437 #[test]
438 fn html_extracted_items_are_rejected() {
439 let request = document_request_from_json(
440 r#"[{"artifact_kind":"html","artifact":"<script src=https://example.com/px.js></script>"}]"#,
441 )
442 .unwrap();
443 let error = engine().validate_many(&request, &options()).unwrap_err();
444 assert!(
445 error.to_string().contains("html is not a validation kind"),
446 "{error}"
447 );
448 }
449}