Skip to main content

markdown_compiler/
validation.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use super::LogicalContentPath;
7
8#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
9#[serde(transparent)]
10pub struct FieldPath(String);
11
12impl FieldPath {
13    pub(crate) fn new(value: impl Into<String>) -> Self {
14        Self(value.into())
15    }
16
17    pub fn as_str(&self) -> &str {
18        &self.0
19    }
20
21    fn sort_rank(&self) -> u16 {
22        let value = self.as_str();
23        match value {
24            "$path" => 0,
25            "$document" => 1,
26            "$frontmatter" => 2,
27            "site" => 10,
28            "site.title" => 11,
29            "site.base_url" => 12,
30            "site.description" => 13,
31            "site.favicon" => 14,
32            "site.image" => 15,
33            "author" => 20,
34            "author.name" => 21,
35            "assets" => 30,
36            "assets.allowed_https_origins" => 31,
37            "tips.enabled" => 50,
38            "id" => 100,
39            "title" => 110,
40            "slug" => 120,
41            "authored_at" => 130,
42            "updated_at" => 140,
43            "description" => 150,
44            "image" => 160,
45            "tags" => 170,
46            "aliases" => 180,
47            "draft" => 190,
48            "tips" => 200,
49            "published_at" => 220,
50            _ if value.starts_with("assets.allowed_https_origins[") => 32,
51            _ if value.starts_with("tags[") => 171,
52            _ if value.starts_with("aliases[") => 181,
53            _ => 1_000,
54        }
55    }
56}
57
58impl fmt::Display for FieldPath {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter.write_str(self.as_str())
61    }
62}
63
64#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ContentValidationCode {
67    ContentPlatformUnsupported,
68    ContentRootUnavailable,
69    PublicationFileMissing,
70    ContentNamespaceInvalid,
71    ContentEntryUnreadable,
72    UnsupportedFilenameEncoding,
73    InvalidLogicalContentPath,
74    UnexpectedPostEntry,
75    ContentSymlinkUnsupported,
76    UnsupportedContentEntryKind,
77    ContentTextInvalidUtf8,
78    AuthoredSvgUnsupported,
79    ContentFileTooLarge,
80    ContentTreeTooLarge,
81    ContentEntryLimitExceeded,
82    ContentDepthLimitExceeded,
83    ContentPathTooLong,
84    DuplicateLogicalAssetPath,
85    LogicalPathCaseCollision,
86    ContentEntryChanged,
87    PublicationTomlInvalid,
88    FrontmatterOpeningDelimiterMissing,
89    FrontmatterOpeningDelimiterMalformed,
90    FrontmatterClosingDelimiterMissing,
91    FrontmatterClosingDelimiterMalformed,
92    FrontmatterTomlInvalid,
93    RequiredFieldMissing,
94    InvalidFieldType,
95    UnknownField,
96    PublishedAtUnsupported,
97    TextEmpty,
98    TextContainsControl,
99    InvalidBaseUrl,
100    InvalidPostId,
101    InvalidPostSlug,
102    InvalidPostTag,
103    InvalidPostAlias,
104    DatetimeOffsetRequired,
105    DatetimeInvalid,
106    UpdatedAtBeforeAuthoredAt,
107    DuplicateTag,
108    AliasMatchesSlug,
109    DuplicatePostId,
110    DuplicatePostSlug,
111    DuplicatePostAlias,
112    DuplicatePostRoute,
113    DraftDirectoryConflict,
114    PostCollectionPathMismatch,
115    InternalValidationInvariant,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
119pub struct ValidationLocation {
120    pub path: LogicalContentPath,
121    pub field: FieldPath,
122}
123
124impl ValidationLocation {
125    pub(crate) const fn new(path: LogicalContentPath, field: FieldPath) -> Self {
126        Self { path, field }
127    }
128}
129
130#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize)]
131#[error("{path}: {field}: {message}")]
132pub struct ContentValidationError {
133    pub path: LogicalContentPath,
134    pub field: FieldPath,
135    pub code: ContentValidationCode,
136    pub message: String,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub related: Option<ValidationLocation>,
139}
140
141impl ContentValidationError {
142    pub(crate) fn new(
143        path: LogicalContentPath,
144        field: impl Into<String>,
145        code: ContentValidationCode,
146        message: impl Into<String>,
147    ) -> Self {
148        Self {
149            path,
150            field: FieldPath::new(field),
151            code,
152            message: message.into(),
153            related: None,
154        }
155    }
156
157    pub(crate) fn with_related(mut self, related: ValidationLocation) -> Self {
158        self.related = Some(related);
159        self
160    }
161
162    fn sort_key(
163        &self,
164    ) -> (
165        &str,
166        u16,
167        &str,
168        ContentValidationCode,
169        Option<(&str, &str)>,
170        &str,
171    ) {
172        (
173            self.path.as_str(),
174            self.field.sort_rank(),
175            self.field.as_str(),
176            self.code,
177            self.related
178                .as_ref()
179                .map(|related| (related.path.as_str(), related.field.as_str())),
180            self.message.as_str(),
181        )
182    }
183}
184
185#[derive(Clone, Debug, Eq, Error, PartialEq)]
186#[error("content validation failed with {} error(s)", .0.len())]
187pub struct ContentValidationErrors(Vec<ContentValidationError>);
188
189impl ContentValidationErrors {
190    pub fn errors(&self) -> &[ContentValidationError] {
191        &self.0
192    }
193
194    pub fn into_errors(self) -> Vec<ContentValidationError> {
195        self.0
196    }
197}
198
199#[derive(Default)]
200pub(crate) struct DiagnosticCollector {
201    errors: Vec<ContentValidationError>,
202}
203
204impl DiagnosticCollector {
205    pub(crate) fn push(&mut self, error: ContentValidationError) {
206        self.errors.push(error);
207    }
208
209    pub(crate) fn len(&self) -> usize {
210        self.errors.len()
211    }
212
213    pub(crate) fn is_empty(&self) -> bool {
214        self.errors.is_empty()
215    }
216
217    pub(crate) fn finish(mut self) -> ContentValidationErrors {
218        if self.errors.is_empty() {
219            self.errors.push(ContentValidationError::new(
220                LogicalContentPath::new("<content-validation>"),
221                "$document",
222                ContentValidationCode::InternalValidationInvariant,
223                "an empty diagnostic collection was finalized as an error",
224            ));
225        }
226        self.errors
227            .sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
228        self.errors.dedup();
229        ContentValidationErrors(self.errors)
230    }
231}