Skip to main content

pixellint_core/
document.rs

1//! Document-level results for a caller that already extracted artifacts.
2//!
3//! Pixellint core stays single-artifact. This wrapper dedupes extracted URLs,
4//! runs [`Engine::validate`] once per unique value, and returns the shape in
5//! [`docs/MULTI_ARTIFACT_SCHEMA.md`](../../../docs/MULTI_ARTIFACT_SCHEMA.md).
6//! Extraction, VAST XML, HTML, and GTM stay in the caller.
7
8use 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/// A caller that extracted the artifacts, when the caller names itself.
20#[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/// One extracted artifact plus the places it appeared.
28#[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/// One place an extracted artifact appeared in the source document.
47#[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/// Extracted artifacts from one document. The caller already pulled URLs out.
64#[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/// Counts for a document or for one unique artifact.
79#[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/// One unique artifact after dedupe, with every occurrence attached.
91#[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/// Document-level result. `validate` is unchanged; this wraps it.
105#[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/// Why a document-level run could not finish.
121#[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    /// Validates every extracted artifact. Identical values (same kind and
185    /// trimmed text) run once. Occurrences stay on the unique result.
186    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        // Number occurrences globally in document order so ids stay unique.
282        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
319/// Parses a document request from JSON. A JSON array of URL strings is a
320/// `list` of `url` artifacts. Objects use the documented wrapper shape.
321pub 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
327/// Same as [`document_request_from_json`] for an already-parsed value.
328pub 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}