1use std::collections::{BTreeMap, btree_map::Entry};
2
3use time::{OffsetDateTime, format_description::well_known::Rfc3339};
4use toml::{Table, Value};
5
6use crate::content::{
7 AuthorName, AuthorSettings, DefaultPostTipPolicy, DraftStatus, MarkdownSource, PlainTextError,
8 PostAlias, PostDescription, PostDocument, PostId, PostMetadata, PostSlug, PostTag,
9 PostTipPolicy, PostTitle, PublicationAssetSettings, PublicationBaseUrl, PublicationSettings,
10 RouteConflict, RouteKind, SiteDescription, SiteSettings, SiteTitle, UnresolvedAssetReference,
11 UnresolvedHttpsOrigin, ValidatedContent, classify_route_conflict,
12 resolve_draft_status as resolve_authored_draft_status, timestamps_are_ordered,
13};
14
15use super::{
16 ContentValidationCode, ContentValidationError, ContentValidationErrors, DiagnosticCollector,
17 LogicalContentPath, PostCollection, PostSource, PublicationSource, ValidationLocation,
18};
19
20pub fn validate_content<'source>(
21 publication: PublicationSource<'source>,
22 posts: impl IntoIterator<Item = PostSource<'source>>,
23) -> Result<ValidatedContent, ContentValidationErrors> {
24 let mut diagnostics = DiagnosticCollector::default();
25 let publication = parse_publication(publication, &mut diagnostics);
26
27 let mut post_sources: Vec<_> = posts.into_iter().collect();
28 post_sources.sort_by(|left, right| left.path.cmp(&right.path));
29 let post_candidates: Vec<_> = post_sources
30 .into_iter()
31 .enumerate()
32 .map(|(source_index, source)| parse_post(source_index, source, &mut diagnostics))
33 .collect();
34
35 validate_post_identities(&post_candidates, &mut diagnostics);
36 validate_post_routes(&post_candidates, &mut diagnostics);
37
38 let had_no_reported_errors = diagnostics.is_empty();
39 if had_no_reported_errors && publication.settings.is_none() {
40 diagnostics.push(invariant_error(
41 publication_path(&publication),
42 "validated publication settings were not constructed",
43 ));
44 }
45 if had_no_reported_errors {
46 for post in &post_candidates {
47 if post.document.is_none() {
48 diagnostics.push(invariant_error(
49 post.path.clone(),
50 "validated post document was not constructed",
51 ));
52 }
53 }
54 }
55
56 if !diagnostics.is_empty() {
57 return Err(diagnostics.finish());
58 }
59
60 let publication_path = publication.path.clone();
61 let publication = match publication.settings {
62 Some(publication) => publication,
63 None => return Err(single_invariant_error(publication_path)),
64 };
65 let mut posts = Vec::with_capacity(post_candidates.len());
66 for candidate in post_candidates {
67 match candidate.document {
68 Some(document) => posts.push(document),
69 None => return Err(single_invariant_error(candidate.path)),
70 }
71 }
72 Ok(ValidatedContent::new(publication, posts))
73}
74
75pub fn validate_post_document(
83 path_label: impl Into<String>,
84 contents: &str,
85 collection: PostCollection,
86) -> Result<PostDocument, ContentValidationErrors> {
87 let mut diagnostics = DiagnosticCollector::default();
88 let candidate = parse_post_with_placement(
89 0,
90 PostSource {
91 path: LogicalContentPath::new(path_label),
92 contents,
93 collection,
94 },
95 &mut diagnostics,
96 false,
97 );
98
99 validate_post_identities(std::slice::from_ref(&candidate), &mut diagnostics);
100 validate_post_routes(std::slice::from_ref(&candidate), &mut diagnostics);
101
102 if diagnostics.is_empty() && candidate.document.is_none() {
103 diagnostics.push(invariant_error(
104 candidate.path.clone(),
105 "validated post document was not constructed",
106 ));
107 }
108 if !diagnostics.is_empty() {
109 return Err(diagnostics.finish());
110 }
111 candidate
112 .document
113 .ok_or_else(|| single_invariant_error(candidate.path))
114}
115
116pub fn validate_post_document_bytes(
119 path_label: impl Into<String>,
120 bytes: &[u8],
121 collection: PostCollection,
122) -> Result<PostDocument, ContentValidationErrors> {
123 let path_label = path_label.into();
124 if bytes.len() > crate::tree::DEFAULT_POST_BYTES as usize {
125 return Err(single_document_error(
126 &path_label,
127 ContentValidationCode::ContentFileTooLarge,
128 "managed content file exceeds its configured byte limit",
129 ));
130 }
131 let contents = std::str::from_utf8(bytes).map_err(|_| {
132 single_document_error(
133 &path_label,
134 ContentValidationCode::ContentTextInvalidUtf8,
135 "publication and post source files must contain UTF-8 text",
136 )
137 })?;
138 validate_post_document(path_label, contents, collection)
139}
140
141fn single_document_error(
142 path: &str,
143 code: ContentValidationCode,
144 message: &'static str,
145) -> ContentValidationErrors {
146 let mut diagnostics = DiagnosticCollector::default();
147 diagnostics.push(ContentValidationError::new(
148 LogicalContentPath::new(path),
149 "$document",
150 code,
151 message,
152 ));
153 diagnostics.finish()
154}
155
156struct PublicationCandidate {
157 path: LogicalContentPath,
158 settings: Option<PublicationSettings>,
159}
160
161fn publication_path(publication: &PublicationCandidate) -> LogicalContentPath {
162 publication.path.clone()
163}
164
165fn parse_publication(
166 source: PublicationSource<'_>,
167 diagnostics: &mut DiagnosticCollector,
168) -> PublicationCandidate {
169 let start_error_count = diagnostics.len();
170 let path = source.path.clone();
171 let mut table = match source.contents.parse::<Table>() {
172 Ok(table) => table,
173 Err(error) => {
174 diagnostics.push(ContentValidationError::new(
175 path,
176 "$document",
177 ContentValidationCode::PublicationTomlInvalid,
178 format!("publication TOML is invalid: {error}"),
179 ));
180 return PublicationCandidate {
181 path: source.path,
182 settings: None,
183 };
184 }
185 };
186
187 let site =
188 take_required_table(&mut table, "site", "site", &path, diagnostics).and_then(|mut site| {
189 let title = take_required_string(&mut site, "title", "site.title", &path, diagnostics)
190 .and_then(|value| {
191 parse_plain_text(value, SiteTitle::new, "site.title", &path, diagnostics)
192 });
193 let base_url =
194 take_required_string(&mut site, "base_url", "site.base_url", &path, diagnostics)
195 .and_then(|value| match PublicationBaseUrl::parse(&value) {
196 Ok(value) => Some(value),
197 Err(error) => {
198 diagnostics.push(ContentValidationError::new(
199 path.clone(),
200 "site.base_url",
201 ContentValidationCode::InvalidBaseUrl,
202 error.to_string(),
203 ));
204 None
205 }
206 });
207 let description = take_required_string(
208 &mut site,
209 "description",
210 "site.description",
211 &path,
212 diagnostics,
213 )
214 .and_then(|value| {
215 parse_plain_text(
216 value,
217 SiteDescription::new,
218 "site.description",
219 &path,
220 diagnostics,
221 )
222 });
223 let favicon =
224 take_optional_string(&mut site, "favicon", "site.favicon", &path, diagnostics)
225 .and_then(|value| {
226 parse_plain_text(
227 value,
228 UnresolvedAssetReference::new,
229 "site.favicon",
230 &path,
231 diagnostics,
232 )
233 });
234 let image = take_optional_string(&mut site, "image", "site.image", &path, diagnostics)
235 .and_then(|value| {
236 parse_plain_text(
237 value,
238 UnresolvedAssetReference::new,
239 "site.image",
240 &path,
241 diagnostics,
242 )
243 });
244 reject_unknown_fields(site, "site", &path, diagnostics);
245 Some(SiteSettings::new(
246 title?,
247 base_url?,
248 description?,
249 favicon,
250 image,
251 ))
252 });
253
254 let author = take_required_table(&mut table, "author", "author", &path, diagnostics).and_then(
255 |mut author| {
256 let name = take_required_string(&mut author, "name", "author.name", &path, diagnostics)
257 .and_then(|value| {
258 parse_plain_text(value, AuthorName::new, "author.name", &path, diagnostics)
259 });
260 reject_unknown_fields(author, "author", &path, diagnostics);
261 name.map(AuthorSettings::new)
262 },
263 );
264
265 let assets = match take_optional_table(&mut table, "assets", "assets", &path, diagnostics) {
266 OptionalField::Missing => Some(PublicationAssetSettings::default()),
267 OptionalField::Invalid => None,
268 OptionalField::Valid(mut assets) => {
269 let origins = take_optional_string_array(
270 &mut assets,
271 "allowed_https_origins",
272 "assets.allowed_https_origins",
273 &path,
274 diagnostics,
275 )
276 .into_iter()
277 .filter_map(|(index, value)| {
278 parse_plain_text(
279 value,
280 UnresolvedHttpsOrigin::new,
281 &indexed_field("assets.allowed_https_origins", index),
282 &path,
283 diagnostics,
284 )
285 })
286 .collect();
287 reject_unknown_fields(assets, "assets", &path, diagnostics);
288 Some(PublicationAssetSettings {
289 allowed_https_origins: origins,
290 })
291 }
292 };
293
294 let tips = parse_publication_tips(&mut table, &path, diagnostics);
295 reject_unknown_fields(table, "", &path, diagnostics);
296
297 let settings = if diagnostics.len() == start_error_count {
298 match (site, author, assets, tips) {
299 (Some(site), Some(author), Some(assets), Some(tips)) => {
300 Some(PublicationSettings::new(site, author, assets, tips))
301 }
302 _ => {
303 diagnostics.push(invariant_error(
304 path.clone(),
305 "publication validation succeeded without all required typed fields",
306 ));
307 None
308 }
309 }
310 } else {
311 None
312 };
313
314 PublicationCandidate { path, settings }
315}
316
317fn parse_publication_tips(
318 table: &mut Table,
319 path: &LogicalContentPath,
320 diagnostics: &mut DiagnosticCollector,
321) -> Option<DefaultPostTipPolicy> {
322 let mut tips = match take_optional_table(table, "tips", "tips", path, diagnostics) {
323 OptionalField::Missing => return Some(DefaultPostTipPolicy::Disabled),
324 OptionalField::Invalid => return None,
325 OptionalField::Valid(value) => value,
326 };
327 let enabled = take_optional_bool(&mut tips, "enabled", "tips.enabled", path, diagnostics);
328 reject_unknown_fields(tips, "tips", path, diagnostics);
329
330 let enabled = match enabled {
331 OptionalField::Missing => false,
332 OptionalField::Valid(value) => value,
333 OptionalField::Invalid => return None,
334 };
335 Some(match enabled {
336 true => DefaultPostTipPolicy::Enabled,
337 false => DefaultPostTipPolicy::Disabled,
338 })
339}
340
341struct PostCandidate {
342 source_index: usize,
343 path: LogicalContentPath,
344 document: Option<PostDocument>,
345 id: Option<PostId>,
346 slug: Option<PostSlug>,
347 aliases: Vec<(usize, PostAlias)>,
348}
349
350fn parse_post(
351 source_index: usize,
352 source: PostSource<'_>,
353 diagnostics: &mut DiagnosticCollector,
354) -> PostCandidate {
355 parse_post_with_placement(source_index, source, diagnostics, true)
356}
357
358fn parse_post_with_placement(
359 source_index: usize,
360 source: PostSource<'_>,
361 diagnostics: &mut DiagnosticCollector,
362 validate_placement: bool,
363) -> PostCandidate {
364 let start_error_count = diagnostics.len();
365 let path = source.path.clone();
366
367 if validate_placement {
368 validate_post_source_path(&source, &path, diagnostics);
369 }
370 let Some((mut table, markdown)) = parse_post_frontmatter(source.contents, &path, diagnostics)
371 else {
372 return PostCandidate {
373 source_index,
374 path,
375 document: None,
376 id: None,
377 slug: None,
378 aliases: Vec::new(),
379 };
380 };
381
382 if table.remove("published_at").is_some() {
383 diagnostics.push(ContentValidationError::new(
384 path.clone(),
385 "published_at",
386 ContentValidationCode::PublishedAtUnsupported,
387 "published_at is SQLite-owned policy and is not allowed in frontmatter",
388 ));
389 }
390
391 let id = take_required_string(&mut table, "id", "id", &path, diagnostics).and_then(|value| {
392 match PostId::parse(&value) {
393 Ok(value) => Some(value),
394 Err(error) => {
395 diagnostics.push(ContentValidationError::new(
396 path.clone(),
397 "id",
398 ContentValidationCode::InvalidPostId,
399 error.to_string(),
400 ));
401 None
402 }
403 }
404 });
405 let title = take_required_string(&mut table, "title", "title", &path, diagnostics)
406 .and_then(|value| parse_plain_text(value, PostTitle::new, "title", &path, diagnostics));
407 let slug =
408 take_required_string(&mut table, "slug", "slug", &path, diagnostics).and_then(|value| {
409 match PostSlug::parse(value) {
410 Ok(value) => Some(value),
411 Err(error) => {
412 diagnostics.push(ContentValidationError::new(
413 path.clone(),
414 "slug",
415 ContentValidationCode::InvalidPostSlug,
416 error.to_string(),
417 ));
418 None
419 }
420 }
421 });
422 let authored_at =
423 take_required_datetime(&mut table, "authored_at", "authored_at", &path, diagnostics);
424 let updated_at =
425 take_optional_datetime(&mut table, "updated_at", "updated_at", &path, diagnostics);
426 if let (Some(authored_at), OptionalField::Valid(updated_at)) = (authored_at, updated_at)
427 && !timestamps_are_ordered(authored_at, updated_at)
428 {
429 diagnostics.push(ContentValidationError::new(
430 path.clone(),
431 "updated_at",
432 ContentValidationCode::UpdatedAtBeforeAuthoredAt,
433 "updated_at must not be earlier than authored_at",
434 ));
435 }
436 let updated_at_value = updated_at.into_option();
437 let description =
438 take_required_string(&mut table, "description", "description", &path, diagnostics)
439 .and_then(|value| {
440 parse_plain_text(
441 value,
442 PostDescription::new,
443 "description",
444 &path,
445 diagnostics,
446 )
447 });
448 let image =
449 take_optional_string(&mut table, "image", "image", &path, diagnostics).and_then(|value| {
450 parse_plain_text(
451 value,
452 UnresolvedAssetReference::new,
453 "image",
454 &path,
455 diagnostics,
456 )
457 });
458 let tags = parse_post_tags(&mut table, &path, diagnostics);
459 let aliases: Vec<_> =
460 take_optional_string_array(&mut table, "aliases", "aliases", &path, diagnostics)
461 .into_iter()
462 .filter_map(|(index, value)| match PostAlias::parse(value) {
463 Ok(alias) => Some((index, alias)),
464 Err(error) => {
465 diagnostics.push(ContentValidationError::new(
466 path.clone(),
467 indexed_field("aliases", index),
468 ContentValidationCode::InvalidPostAlias,
469 error.to_string(),
470 ));
471 None
472 }
473 })
474 .collect();
475 let authored_draft = take_optional_bool(&mut table, "draft", "draft", &path, diagnostics);
476 let draft = resolve_draft_status(source.collection, authored_draft, &path, diagnostics);
477 let tips = match take_optional_bool(&mut table, "tips", "tips", &path, diagnostics) {
478 OptionalField::Missing => Some(PostTipPolicy::InheritPublication),
479 OptionalField::Valid(true) => Some(PostTipPolicy::Enabled),
480 OptionalField::Valid(false) => Some(PostTipPolicy::Disabled),
481 OptionalField::Invalid => None,
482 };
483 reject_unknown_fields(table, "", &path, diagnostics);
484
485 let document = if diagnostics.len() == start_error_count {
486 match (
487 id.clone(),
488 title,
489 slug.clone(),
490 authored_at,
491 description,
492 tips,
493 ) {
494 (
495 Some(id),
496 Some(title),
497 Some(slug),
498 Some(authored_at),
499 Some(description),
500 Some(tips),
501 ) => Some(PostDocument::new(
502 path.clone(),
503 PostMetadata {
504 id,
505 title,
506 slug,
507 authored_at,
508 updated_at: updated_at_value,
509 description,
510 image,
511 tags,
512 aliases: aliases.iter().map(|(_, alias)| alias.clone()).collect(),
513 draft,
514 tips,
515 },
516 MarkdownSource::new(markdown),
517 )),
518 _ => {
519 diagnostics.push(invariant_error(
520 path.clone(),
521 "post validation succeeded without all required typed fields",
522 ));
523 None
524 }
525 }
526 } else {
527 None
528 };
529
530 PostCandidate {
531 source_index,
532 path,
533 document,
534 id,
535 slug,
536 aliases,
537 }
538}
539
540fn validate_post_source_path(
541 source: &PostSource<'_>,
542 path: &LogicalContentPath,
543 diagnostics: &mut DiagnosticCollector,
544) {
545 if super::path::PortableLogicalPath::parse(path.as_str(), usize::MAX).is_err() {
546 diagnostics.push(ContentValidationError::new(
547 path.clone(),
548 "$path",
549 ContentValidationCode::InvalidLogicalContentPath,
550 "post source path must use portable logical-path components",
551 ));
552 }
553 if !source.collection.contains_path(path.as_str()) {
554 diagnostics.push(ContentValidationError::new(
555 path.clone(),
556 "$path",
557 ContentValidationCode::PostCollectionPathMismatch,
558 format!(
559 "post source collection requires a path below {}/",
560 source.collection.directory()
561 ),
562 ));
563 }
564 if !path
565 .as_str()
566 .rsplit('/')
567 .next()
568 .is_some_and(|name| name.ends_with(".md"))
569 {
570 diagnostics.push(ContentValidationError::new(
571 path.clone(),
572 "$path",
573 ContentValidationCode::UnexpectedPostEntry,
574 "post source path must use the exact lowercase .md suffix",
575 ));
576 }
577}
578
579fn parse_post_frontmatter<'source>(
580 contents: &'source str,
581 path: &LogicalContentPath,
582 diagnostics: &mut DiagnosticCollector,
583) -> Option<(Table, &'source str)> {
584 let (frontmatter, markdown) = split_frontmatter(contents, path, diagnostics)?;
585 let table = match frontmatter.parse::<Table>() {
586 Ok(table) => table,
587 Err(error) => {
588 diagnostics.push(ContentValidationError::new(
589 path.clone(),
590 "$frontmatter",
591 ContentValidationCode::FrontmatterTomlInvalid,
592 format!("frontmatter TOML is invalid: {error}"),
593 ));
594 return None;
595 }
596 };
597 Some((table, markdown))
598}
599
600fn parse_post_tags(
601 table: &mut Table,
602 path: &LogicalContentPath,
603 diagnostics: &mut DiagnosticCollector,
604) -> Vec<PostTag> {
605 let mut first_tag_indexes = BTreeMap::new();
606 take_optional_string_array(table, "tags", "tags", path, diagnostics)
607 .into_iter()
608 .filter_map(|(index, value)| match PostTag::parse(value) {
609 Ok(tag) => {
610 if let Some(first_index) = first_tag_indexes.get(&tag).copied() {
611 diagnostics.push(
612 ContentValidationError::new(
613 path.clone(),
614 indexed_field("tags", index),
615 ContentValidationCode::DuplicateTag,
616 "tag duplicates an earlier normalized tag",
617 )
618 .with_related(ValidationLocation::new(
619 path.clone(),
620 super::FieldPath::new(indexed_field("tags", first_index)),
621 )),
622 );
623 } else {
624 first_tag_indexes.insert(tag.clone(), index);
625 }
626 Some(tag)
627 }
628 Err(error) => {
629 diagnostics.push(ContentValidationError::new(
630 path.clone(),
631 indexed_field("tags", index),
632 ContentValidationCode::InvalidPostTag,
633 error.to_string(),
634 ));
635 None
636 }
637 })
638 .collect()
639}
640
641fn resolve_draft_status(
642 collection: PostCollection,
643 authored: OptionalField<bool>,
644 path: &LogicalContentPath,
645 diagnostics: &mut DiagnosticCollector,
646) -> DraftStatus {
647 let authored = match authored {
648 OptionalField::Valid(authored) => Some(authored),
649 OptionalField::Missing | OptionalField::Invalid => None,
650 };
651 let resolution = resolve_authored_draft_status(collection, authored);
652 if resolution.conflicts_with_collection {
653 diagnostics.push(ContentValidationError::new(
654 path.clone(),
655 "draft",
656 ContentValidationCode::DraftDirectoryConflict,
657 "a post in drafts/ cannot set draft to false",
658 ));
659 }
660 resolution.status
661}
662
663fn split_frontmatter<'source>(
664 contents: &'source str,
665 path: &LogicalContentPath,
666 diagnostics: &mut DiagnosticCollector,
667) -> Option<(&'source str, &'source str)> {
668 let (opening, frontmatter_start) = next_line(contents, 0);
669 if opening != "+++" {
670 let (code, message) = if looks_like_delimiter(opening) {
671 (
672 ContentValidationCode::FrontmatterOpeningDelimiterMalformed,
673 "opening frontmatter delimiter must be exactly +++",
674 )
675 } else {
676 (
677 ContentValidationCode::FrontmatterOpeningDelimiterMissing,
678 "post must begin with a +++ frontmatter delimiter",
679 )
680 };
681 diagnostics.push(ContentValidationError::new(
682 path.clone(),
683 "$frontmatter",
684 code,
685 message,
686 ));
687 return None;
688 }
689
690 let mut cursor = frontmatter_start;
691 while cursor < contents.len() {
692 let line_start = cursor;
693 let (line, next) = next_line(contents, cursor);
694 if line == "+++" {
695 return Some((&contents[frontmatter_start..line_start], &contents[next..]));
696 }
697 if looks_like_delimiter(line) {
698 diagnostics.push(ContentValidationError::new(
699 path.clone(),
700 "$frontmatter",
701 ContentValidationCode::FrontmatterClosingDelimiterMalformed,
702 "closing frontmatter delimiter must be exactly +++",
703 ));
704 return None;
705 }
706 cursor = next;
707 }
708
709 diagnostics.push(ContentValidationError::new(
710 path.clone(),
711 "$frontmatter",
712 ContentValidationCode::FrontmatterClosingDelimiterMissing,
713 "frontmatter has no closing +++ delimiter",
714 ));
715 None
716}
717
718fn next_line(contents: &str, start: usize) -> (&str, usize) {
719 let remaining = &contents[start..];
720 let next = remaining
721 .find('\n')
722 .map_or(contents.len(), |relative| start + relative + 1);
723 let mut line_end = next;
724 if line_end > start && contents.as_bytes()[line_end - 1] == b'\n' {
725 line_end -= 1;
726 }
727 if line_end > start && contents.as_bytes()[line_end - 1] == b'\r' {
728 line_end -= 1;
729 }
730 (&contents[start..line_end], next)
731}
732
733fn looks_like_delimiter(line: &str) -> bool {
734 let trimmed = line.trim();
735 trimmed.starts_with("+++") || (trimmed.len() >= 3 && trimmed.bytes().all(|byte| byte == b'+'))
736}
737
738fn validate_post_identities(posts: &[PostCandidate], diagnostics: &mut DiagnosticCollector) {
739 let mut identities: BTreeMap<&PostId, &PostCandidate> = BTreeMap::new();
740 for post in posts {
741 let Some(id) = &post.id else { continue };
742 let anchor = match identities.entry(id) {
743 Entry::Vacant(entry) => {
744 entry.insert(post);
745 continue;
746 }
747 Entry::Occupied(entry) => *entry.get(),
748 };
749 diagnostics.push(
750 ContentValidationError::new(
751 post.path.clone(),
752 "id",
753 ContentValidationCode::DuplicatePostId,
754 "post ID duplicates an earlier post",
755 )
756 .with_related(ValidationLocation::new(
757 anchor.path.clone(),
758 super::FieldPath::new("id"),
759 )),
760 );
761 }
762}
763
764struct RouteLocation<'candidate> {
765 post: &'candidate PostCandidate,
766 field: String,
767 kind: RouteKind,
768}
769
770fn validate_post_routes(posts: &[PostCandidate], diagnostics: &mut DiagnosticCollector) {
771 let mut routes: BTreeMap<&str, Vec<RouteLocation<'_>>> = BTreeMap::new();
772 for post in posts {
773 if let Some(slug) = &post.slug {
774 routes
775 .entry(slug.as_str())
776 .or_default()
777 .push(RouteLocation {
778 post,
779 field: "slug".to_owned(),
780 kind: RouteKind::Canonical,
781 });
782 }
783 for (index, alias) in &post.aliases {
784 routes
785 .entry(alias.as_str())
786 .or_default()
787 .push(RouteLocation {
788 post,
789 field: indexed_field("aliases", *index),
790 kind: RouteKind::Alias,
791 });
792 }
793 }
794
795 for mut duplicates in routes.into_values().filter(|values| values.len() > 1) {
796 duplicates.sort_by(|left, right| {
797 (
798 left.post.path.as_str(),
799 left.post.source_index,
800 left.kind,
801 left.field.as_str(),
802 )
803 .cmp(&(
804 right.post.path.as_str(),
805 right.post.source_index,
806 right.kind,
807 right.field.as_str(),
808 ))
809 });
810 let anchor = &duplicates[0];
811 for duplicate in duplicates.iter().skip(1) {
812 let (code, message) = match classify_route_conflict(
813 anchor.kind,
814 duplicate.kind,
815 anchor.post.source_index == duplicate.post.source_index,
816 ) {
817 RouteConflict::DuplicateSlug => (
818 ContentValidationCode::DuplicatePostSlug,
819 "canonical slug duplicates an earlier post slug",
820 ),
821 RouteConflict::DuplicateAlias => (
822 ContentValidationCode::DuplicatePostAlias,
823 "alias duplicates an earlier alias",
824 ),
825 RouteConflict::AliasMatchesSlug => (
826 ContentValidationCode::AliasMatchesSlug,
827 "alias matches its post's canonical slug",
828 ),
829 RouteConflict::DuplicateRoute => (
830 ContentValidationCode::DuplicatePostRoute,
831 "post route conflicts with an earlier canonical slug or alias",
832 ),
833 };
834 diagnostics.push(
835 ContentValidationError::new(
836 duplicate.post.path.clone(),
837 duplicate.field.clone(),
838 code,
839 message,
840 )
841 .with_related(ValidationLocation::new(
842 anchor.post.path.clone(),
843 super::FieldPath::new(anchor.field.clone()),
844 )),
845 );
846 }
847 }
848}
849
850#[derive(Clone, Copy)]
851enum OptionalField<Value> {
852 Missing,
853 Valid(Value),
854 Invalid,
855}
856
857impl<Value> OptionalField<Value> {
858 fn into_option(self) -> Option<Value> {
859 match self {
860 Self::Valid(value) => Some(value),
861 Self::Missing | Self::Invalid => None,
862 }
863 }
864}
865
866fn take_required_table(
867 table: &mut Table,
868 key: &str,
869 field: &str,
870 path: &LogicalContentPath,
871 diagnostics: &mut DiagnosticCollector,
872) -> Option<Table> {
873 match table.remove(key) {
874 Some(Value::Table(value)) => Some(value),
875 Some(value) => {
876 invalid_type(path, field, "table", &value, diagnostics);
877 None
878 }
879 None => {
880 required_field(
881 path,
882 field,
883 diagnostics,
884 ContentValidationCode::RequiredFieldMissing,
885 );
886 None
887 }
888 }
889}
890
891fn take_optional_table(
892 table: &mut Table,
893 key: &str,
894 field: &str,
895 path: &LogicalContentPath,
896 diagnostics: &mut DiagnosticCollector,
897) -> OptionalField<Table> {
898 match table.remove(key) {
899 Some(Value::Table(value)) => OptionalField::Valid(value),
900 Some(value) => {
901 invalid_type(path, field, "table", &value, diagnostics);
902 OptionalField::Invalid
903 }
904 None => OptionalField::Missing,
905 }
906}
907
908fn take_required_string(
909 table: &mut Table,
910 key: &str,
911 field: &str,
912 path: &LogicalContentPath,
913 diagnostics: &mut DiagnosticCollector,
914) -> Option<String> {
915 match table.remove(key) {
916 Some(Value::String(value)) => Some(value),
917 Some(value) => {
918 invalid_type(path, field, "string", &value, diagnostics);
919 None
920 }
921 None => {
922 required_field(
923 path,
924 field,
925 diagnostics,
926 ContentValidationCode::RequiredFieldMissing,
927 );
928 None
929 }
930 }
931}
932
933fn take_optional_string(
934 table: &mut Table,
935 key: &str,
936 field: &str,
937 path: &LogicalContentPath,
938 diagnostics: &mut DiagnosticCollector,
939) -> Option<String> {
940 match table.remove(key) {
941 Some(Value::String(value)) => Some(value),
942 Some(value) => {
943 invalid_type(path, field, "string", &value, diagnostics);
944 None
945 }
946 None => None,
947 }
948}
949
950fn take_optional_string_array(
951 table: &mut Table,
952 key: &str,
953 field: &str,
954 path: &LogicalContentPath,
955 diagnostics: &mut DiagnosticCollector,
956) -> Vec<(usize, String)> {
957 match table.remove(key) {
958 Some(Value::Array(values)) => values
959 .into_iter()
960 .enumerate()
961 .filter_map(|(index, value)| match value {
962 Value::String(value) => Some((index, value)),
963 value => {
964 invalid_type(
965 path,
966 &indexed_field(field, index),
967 "string",
968 &value,
969 diagnostics,
970 );
971 None
972 }
973 })
974 .collect(),
975 Some(value) => {
976 invalid_type(path, field, "array", &value, diagnostics);
977 Vec::new()
978 }
979 None => Vec::new(),
980 }
981}
982
983fn take_optional_bool(
984 table: &mut Table,
985 key: &str,
986 field: &str,
987 path: &LogicalContentPath,
988 diagnostics: &mut DiagnosticCollector,
989) -> OptionalField<bool> {
990 match table.remove(key) {
991 Some(Value::Boolean(value)) => OptionalField::Valid(value),
992 Some(value) => {
993 invalid_type(path, field, "boolean", &value, diagnostics);
994 OptionalField::Invalid
995 }
996 None => OptionalField::Missing,
997 }
998}
999
1000fn take_required_datetime(
1001 table: &mut Table,
1002 key: &str,
1003 field: &str,
1004 path: &LogicalContentPath,
1005 diagnostics: &mut DiagnosticCollector,
1006) -> Option<OffsetDateTime> {
1007 match table.remove(key) {
1008 Some(value) => parse_datetime_value(value, field, path, diagnostics),
1009 None => {
1010 required_field(
1011 path,
1012 field,
1013 diagnostics,
1014 ContentValidationCode::RequiredFieldMissing,
1015 );
1016 None
1017 }
1018 }
1019}
1020
1021fn take_optional_datetime(
1022 table: &mut Table,
1023 key: &str,
1024 field: &str,
1025 path: &LogicalContentPath,
1026 diagnostics: &mut DiagnosticCollector,
1027) -> OptionalField<OffsetDateTime> {
1028 match table.remove(key) {
1029 Some(value) => parse_datetime_value(value, field, path, diagnostics)
1030 .map_or(OptionalField::Invalid, OptionalField::Valid),
1031 None => OptionalField::Missing,
1032 }
1033}
1034
1035fn parse_datetime_value(
1036 value: Value,
1037 field: &str,
1038 path: &LogicalContentPath,
1039 diagnostics: &mut DiagnosticCollector,
1040) -> Option<OffsetDateTime> {
1041 let Value::Datetime(datetime) = value else {
1042 invalid_type(path, field, "TOML offset datetime", &value, diagnostics);
1043 return None;
1044 };
1045 if datetime.date.is_none() || datetime.time.is_none() || datetime.offset.is_none() {
1046 diagnostics.push(ContentValidationError::new(
1047 path.clone(),
1048 field,
1049 ContentValidationCode::DatetimeOffsetRequired,
1050 "timestamp must include a date, time, and UTC offset",
1051 ));
1052 return None;
1053 }
1054 match OffsetDateTime::parse(&datetime.to_string(), &Rfc3339) {
1055 Ok(value) => Some(value),
1056 Err(_) => {
1057 diagnostics.push(ContentValidationError::new(
1058 path.clone(),
1059 field,
1060 ContentValidationCode::DatetimeInvalid,
1061 "timestamp is not a supported RFC 3339 offset datetime",
1062 ));
1063 None
1064 }
1065 }
1066}
1067
1068fn parse_plain_text<Value>(
1069 raw: String,
1070 constructor: impl FnOnce(String) -> Result<Value, PlainTextError>,
1071 field: &str,
1072 path: &LogicalContentPath,
1073 diagnostics: &mut DiagnosticCollector,
1074) -> Option<Value> {
1075 match constructor(raw) {
1076 Ok(value) => Some(value),
1077 Err(PlainTextError::Empty) => {
1078 diagnostics.push(ContentValidationError::new(
1079 path.clone(),
1080 field,
1081 ContentValidationCode::TextEmpty,
1082 "text value must not be empty",
1083 ));
1084 None
1085 }
1086 Err(PlainTextError::ContainsControl) => {
1087 diagnostics.push(ContentValidationError::new(
1088 path.clone(),
1089 field,
1090 ContentValidationCode::TextContainsControl,
1091 "text value must not contain control characters or newlines",
1092 ));
1093 None
1094 }
1095 }
1096}
1097
1098fn reject_unknown_fields(
1099 table: Table,
1100 prefix: &str,
1101 path: &LogicalContentPath,
1102 diagnostics: &mut DiagnosticCollector,
1103) {
1104 let mut keys: Vec<_> = table.into_iter().map(|(key, _)| key).collect();
1105 keys.sort();
1106 for key in keys {
1107 let field = if prefix.is_empty() {
1108 key
1109 } else {
1110 format!("{prefix}.{key}")
1111 };
1112 diagnostics.push(ContentValidationError::new(
1113 path.clone(),
1114 field,
1115 ContentValidationCode::UnknownField,
1116 "field is not part of the v1 content contract",
1117 ));
1118 }
1119}
1120
1121fn required_field(
1122 path: &LogicalContentPath,
1123 field: &str,
1124 diagnostics: &mut DiagnosticCollector,
1125 code: ContentValidationCode,
1126) {
1127 diagnostics.push(ContentValidationError::new(
1128 path.clone(),
1129 field,
1130 code,
1131 "required field is missing",
1132 ));
1133}
1134
1135fn invalid_type(
1136 path: &LogicalContentPath,
1137 field: &str,
1138 expected: &str,
1139 actual: &Value,
1140 diagnostics: &mut DiagnosticCollector,
1141) {
1142 diagnostics.push(ContentValidationError::new(
1143 path.clone(),
1144 field,
1145 ContentValidationCode::InvalidFieldType,
1146 format!("expected {expected}, found {}", value_kind(actual)),
1147 ));
1148}
1149
1150fn value_kind(value: &Value) -> &'static str {
1151 match value {
1152 Value::String(_) => "string",
1153 Value::Integer(_) => "integer",
1154 Value::Float(_) => "float",
1155 Value::Boolean(_) => "boolean",
1156 Value::Datetime(_) => "datetime",
1157 Value::Array(_) => "array",
1158 Value::Table(_) => "table",
1159 }
1160}
1161
1162fn indexed_field(field: &str, index: usize) -> String {
1163 format!("{field}[{index}]")
1164}
1165
1166fn invariant_error(path: LogicalContentPath, message: &str) -> ContentValidationError {
1167 ContentValidationError::new(
1168 path,
1169 "$document",
1170 ContentValidationCode::InternalValidationInvariant,
1171 message,
1172 )
1173}
1174
1175fn single_invariant_error(path: LogicalContentPath) -> ContentValidationErrors {
1176 let mut diagnostics = DiagnosticCollector::default();
1177 diagnostics.push(invariant_error(
1178 path,
1179 "content validation invariant failed while producing the final model",
1180 ));
1181 diagnostics.finish()
1182}