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}