Skip to main content

markdown_compiler/
parser.rs

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
75/// Validate one Markdown post without requiring its diagnostic label to be a
76/// managed-tree path.
77///
78/// The supplied collection still controls draft semantics. Intrinsic metadata
79/// checks and conflicts among the post's own slug and aliases are enforced,
80/// while logical placement, collection-directory, and filename checks are
81/// deliberately skipped.
82pub 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
116/// Validate one Markdown post from raw bytes using the production default post
117/// byte limit and UTF-8 contract.
118pub 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}