Skip to main content

surf_parse/
validate.rs

1//! Schema validation for SurfDoc documents.
2//!
3//! Checks required attributes, front matter rules, and block-level constraints.
4//! Returns a list of `Diagnostic` items (non-fatal).
5
6use crate::error::{Diagnostic, Severity};
7use crate::types::{Block, SurfDoc};
8
9/// Validate a parsed `SurfDoc` and return any diagnostics.
10///
11/// This function checks front matter completeness, required block attributes,
12/// and block content constraints. It never modifies the document.
13pub fn validate(doc: &SurfDoc) -> Vec<Diagnostic> {
14    let mut diagnostics = Vec::new();
15
16    // Front matter validation
17    validate_front_matter(doc, &mut diagnostics);
18
19    // Per-block validation
20    for block in &doc.blocks {
21        validate_block(block, &mut diagnostics);
22    }
23
24    // Validate ::app children
25    for block in &doc.blocks {
26        if let Block::App { children, .. } = block {
27            for child in children {
28                validate_block(child, &mut diagnostics);
29            }
30        }
31    }
32
33    // Cross-block validation: duplicate page routes
34    validate_unique_page_routes(&doc.blocks, &mut diagnostics);
35
36    // NOTE (0.10.0 open-core split): cross-model reference checking (V303)
37    // and marketplace field-type semantics (V340-V343) moved to the private
38    // surf-appcompile crate (validate_app_doc) — they are compile-to-app
39    // rules, not document format rules.
40
41    diagnostics
42}
43
44/// Check for duplicate `::page[route=...]` values within a document.
45fn validate_unique_page_routes(blocks: &[Block], diagnostics: &mut Vec<Diagnostic>) {
46    let mut seen: Vec<(&str, &crate::types::Span)> = Vec::new();
47    for block in blocks {
48        if let Block::Page { route, span, .. } = block {
49            if let Some((_, first_span)) = seen.iter().find(|(r, _)| *r == route.as_str()) {
50                diagnostics.push(Diagnostic {
51                    severity: Severity::Error,
52                    message: format!(
53                        "Duplicate page route \"{}\": first defined at line {}",
54                        route, first_span.start_line
55                    ),
56                    span: Some(*span),
57                    code: Some("V141".into()),
58                    fix: None,
59                });
60            } else {
61                seen.push((route.as_str(), span));
62            }
63        }
64    }
65}
66
67fn validate_front_matter(doc: &SurfDoc, diagnostics: &mut Vec<Diagnostic>) {
68    match &doc.front_matter {
69        None => {
70            diagnostics.push(Diagnostic {
71                severity: Severity::Warning,
72                message: "Missing front matter: no title specified".into(),
73                span: None,
74                code: Some("V001".into()),
75                fix: None,
76            });
77            diagnostics.push(Diagnostic {
78                severity: Severity::Warning,
79                message: "Missing front matter: no doc_type specified".into(),
80                span: None,
81                code: Some("V002".into()),
82                fix: None,
83            });
84        }
85        Some(fm) => {
86            if fm.title.is_none() {
87                diagnostics.push(Diagnostic {
88                    severity: Severity::Warning,
89                    message: "Missing front matter field: title".into(),
90                    span: None,
91                    code: Some("V001".into()),
92                    fix: None,
93                });
94            }
95            if fm.doc_type.is_none() {
96                diagnostics.push(Diagnostic {
97                    severity: Severity::Warning,
98                    message: "Missing front matter field: doc_type".into(),
99                    span: None,
100                    code: Some("V002".into()),
101                    fix: None,
102                });
103            }
104        }
105    }
106}
107
108fn validate_block(block: &Block, diagnostics: &mut Vec<Diagnostic>) {
109    match block {
110        Block::Metric {
111            label,
112            value,
113            span,
114            ..
115        } => {
116            if label.is_empty() {
117                diagnostics.push(Diagnostic {
118                    severity: Severity::Error,
119                    message: "Metric block is missing required attribute: label".into(),
120                    span: Some(*span),
121                    code: Some("V010".into()),
122                    fix: None,
123                });
124            }
125            if value.is_empty() {
126                diagnostics.push(Diagnostic {
127                    severity: Severity::Error,
128                    message: "Metric block is missing required attribute: value".into(),
129                    span: Some(*span),
130                    code: Some("V011".into()),
131                    fix: None,
132                });
133            }
134        }
135
136        Block::Figure { src, span, .. } => {
137            if src.is_empty() {
138                diagnostics.push(Diagnostic {
139                    severity: Severity::Error,
140                    message: "Figure block is missing required attribute: src".into(),
141                    span: Some(*span),
142                    code: Some("V020".into()),
143                    fix: None,
144                });
145            }
146        }
147
148        Block::Data {
149            headers,
150            rows,
151            span,
152            ..
153        } => {
154            if !headers.is_empty() && rows.is_empty() {
155                diagnostics.push(Diagnostic {
156                    severity: Severity::Warning,
157                    message: "Data block has headers but zero data rows".into(),
158                    span: Some(*span),
159                    code: Some("V030".into()),
160                    fix: None,
161                });
162            }
163        }
164
165        Block::Callout {
166            content, span, ..
167        } => {
168            if content.trim().is_empty() {
169                diagnostics.push(Diagnostic {
170                    severity: Severity::Warning,
171                    message: "Callout block has empty content".into(),
172                    span: Some(*span),
173                    code: Some("V040".into()),
174                    fix: None,
175                });
176            }
177        }
178
179        Block::Code {
180            content, span, ..
181        } => {
182            if content.trim().is_empty() {
183                diagnostics.push(Diagnostic {
184                    severity: Severity::Warning,
185                    message: "Code block has empty content".into(),
186                    span: Some(*span),
187                    code: Some("V050".into()),
188                    fix: None,
189                });
190            }
191        }
192
193        Block::Decision {
194            content, span, ..
195        } => {
196            if content.trim().is_empty() {
197                diagnostics.push(Diagnostic {
198                    severity: Severity::Warning,
199                    message: "Decision block has empty body".into(),
200                    span: Some(*span),
201                    code: Some("V060".into()),
202                    fix: None,
203                });
204            }
205        }
206
207        Block::Tabs { tabs, span, .. } => {
208            if tabs.is_empty() {
209                diagnostics.push(Diagnostic {
210                    severity: Severity::Warning,
211                    message: "Tabs block has no tab panels".into(),
212                    span: Some(*span),
213                    code: Some("V070".into()),
214                    fix: None,
215                });
216            }
217        }
218
219        Block::Quote {
220            content, span, ..
221        } => {
222            if content.trim().is_empty() {
223                diagnostics.push(Diagnostic {
224                    severity: Severity::Warning,
225                    message: "Quote block has empty content".into(),
226                    span: Some(*span),
227                    code: Some("V080".into()),
228                    fix: None,
229                });
230            }
231        }
232
233        Block::Cta {
234            label,
235            href,
236            span,
237            ..
238        } => {
239            if label.is_empty() {
240                diagnostics.push(Diagnostic {
241                    severity: Severity::Error,
242                    message: "Cta block is missing required attribute: label".into(),
243                    span: Some(*span),
244                    code: Some("V090".into()),
245                    fix: None,
246                });
247            }
248            if href.is_empty() {
249                diagnostics.push(Diagnostic {
250                    severity: Severity::Error,
251                    message: "Cta block is missing required attribute: href".into(),
252                    span: Some(*span),
253                    code: Some("V091".into()),
254                    fix: None,
255                });
256            }
257        }
258
259        Block::HeroImage { src, span, .. } => {
260            if src.is_empty() {
261                diagnostics.push(Diagnostic {
262                    severity: Severity::Error,
263                    message: "HeroImage block is missing required attribute: src".into(),
264                    span: Some(*span),
265                    code: Some("V100".into()),
266                    fix: None,
267                });
268            }
269        }
270
271        Block::Testimonial {
272            content, span, ..
273        } => {
274            if content.trim().is_empty() {
275                diagnostics.push(Diagnostic {
276                    severity: Severity::Warning,
277                    message: "Testimonial block has empty content".into(),
278                    span: Some(*span),
279                    code: Some("V110".into()),
280                    fix: None,
281                });
282            }
283        }
284
285        Block::Faq { items, span, .. } => {
286            if items.is_empty() {
287                diagnostics.push(Diagnostic {
288                    severity: Severity::Warning,
289                    message: "Faq block has no question/answer items".into(),
290                    span: Some(*span),
291                    code: Some("V120".into()),
292                    fix: None,
293                });
294            }
295        }
296
297        Block::PricingTable {
298            headers,
299            rows,
300            span,
301            ..
302        } => {
303            if headers.is_empty() {
304                diagnostics.push(Diagnostic {
305                    severity: Severity::Warning,
306                    message: "PricingTable block has no headers (tier names)".into(),
307                    span: Some(*span),
308                    code: Some("V130".into()),
309                    fix: None,
310                });
311            }
312            if !headers.is_empty() && rows.is_empty() {
313                diagnostics.push(Diagnostic {
314                    severity: Severity::Warning,
315                    message: "PricingTable block has headers but zero feature rows".into(),
316                    span: Some(*span),
317                    code: Some("V131".into()),
318                    fix: None,
319                });
320            }
321        }
322
323        Block::Page { route, span, .. } => {
324            if route.is_empty() {
325                diagnostics.push(Diagnostic {
326                    severity: Severity::Error,
327                    message: "Page block is missing required attribute: route".into(),
328                    span: Some(*span),
329                    code: Some("V140".into()),
330                    fix: None,
331                });
332            }
333        }
334
335        Block::Nav { items, span, .. } => {
336            if items.is_empty() {
337                diagnostics.push(Diagnostic {
338                    severity: Severity::Warning,
339                    message: "Nav block has no navigation items".into(),
340                    span: Some(*span),
341                    code: Some("V150".into()),
342                    fix: None,
343                });
344            }
345        }
346
347        Block::App { name, span, .. } => {
348            if name.is_empty() {
349                diagnostics.push(Diagnostic {
350                    severity: Severity::Error,
351                    message: "App block is missing required attribute: name".into(),
352                    span: Some(*span),
353                    code: Some("V200".into()),
354                    fix: None,
355                });
356            }
357        }
358
359        Block::Deploy { env, span, .. } => {
360            if env.is_none() {
361                diagnostics.push(Diagnostic {
362                    severity: Severity::Error,
363                    message: "Deploy block is missing required attribute: env".into(),
364                    span: Some(*span),
365                    code: Some("V201".into()),
366                    fix: None,
367                });
368            } else if let Some(e) = env {
369                if !["develop", "staging", "production"].contains(&e.as_str()) {
370                    diagnostics.push(Diagnostic {
371                        severity: Severity::Warning,
372                        message: format!("Deploy env \"{}\" is not one of: develop, staging, production", e),
373                        span: Some(*span),
374                        code: Some("V202".into()),
375                        fix: None,
376                    });
377                }
378            }
379        }
380
381        Block::InfraEnv { tier, span, .. } => {
382            if tier.is_none() {
383                diagnostics.push(Diagnostic {
384                    severity: Severity::Warning,
385                    message: "Env block is missing tier attribute".into(),
386                    span: Some(*span),
387                    code: Some("V203".into()),
388                    fix: None,
389                });
390            } else if let Some(t) = tier {
391                if !["required", "recommended", "optional", "defaults"].contains(&t.as_str()) {
392                    diagnostics.push(Diagnostic {
393                        severity: Severity::Warning,
394                        message: format!("Env tier \"{}\" is not one of: required, recommended, optional, defaults", t),
395                        span: Some(*span),
396                        code: Some("V204".into()),
397                        fix: None,
398                    });
399                }
400            }
401        }
402
403        Block::Health { path, span, .. } => {
404            if path.is_none() {
405                diagnostics.push(Diagnostic {
406                    severity: Severity::Error,
407                    message: "Health block is missing required attribute: path".into(),
408                    span: Some(*span),
409                    code: Some("V205".into()),
410                    fix: None,
411                });
412            }
413        }
414
415        Block::Smoke { checks, span, .. } => {
416            for (i, check) in checks.iter().enumerate() {
417                if !["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].contains(&check.method.as_str()) {
418                    diagnostics.push(Diagnostic {
419                        severity: Severity::Warning,
420                        message: format!("Smoke check {} has unrecognized HTTP method: {}", i + 1, check.method),
421                        span: Some(*span),
422                        code: Some("V206".into()),
423                        fix: None,
424                    });
425                }
426            }
427        }
428
429        Block::Concurrency { hard_limit, soft_limit, span, .. } => {
430            if let (Some(hard), Some(soft)) = (hard_limit, soft_limit) {
431                if hard < soft {
432                    diagnostics.push(Diagnostic {
433                        severity: Severity::Warning,
434                        message: format!("Concurrency hard_limit ({}) should be >= soft_limit ({})", hard, soft),
435                        span: Some(*span),
436                        code: Some("V207".into()),
437                        fix: None,
438                    });
439                }
440            }
441        }
442
443        Block::Volumes { entries, span, .. } => {
444            for entry in entries {
445                if entry.name.is_empty() || entry.mount.is_empty() {
446                    diagnostics.push(Diagnostic {
447                        severity: Severity::Warning,
448                        message: "Volume entry must have both name and mount path".into(),
449                        span: Some(*span),
450                        code: Some("V208".into()),
451                        fix: None,
452                    });
453                }
454            }
455        }
456
457        Block::Model { name, fields, span, .. } => {
458            if name.is_empty() {
459                diagnostics.push(Diagnostic {
460                    severity: Severity::Error,
461                    message: "Model block is missing required attribute: name".into(),
462                    span: Some(*span),
463                    code: Some("V300".into()),
464                    fix: None,
465                });
466            }
467            if fields.is_empty() {
468                diagnostics.push(Diagnostic {
469                    severity: Severity::Warning,
470                    message: format!("Model \"{}\" has no fields defined", name),
471                    span: Some(*span),
472                    code: Some("V301".into()),
473                    fix: None,
474                });
475            }
476            // Check for duplicate field names
477            let mut seen_fields: Vec<&str> = Vec::new();
478            for field in fields {
479                if seen_fields.contains(&field.name.as_str()) {
480                    diagnostics.push(Diagnostic {
481                        severity: Severity::Error,
482                        message: format!("Model \"{}\" has duplicate field name: {}", name, field.name),
483                        span: Some(*span),
484                        code: Some("V302".into()),
485                        fix: None,
486                    });
487                } else {
488                    seen_fields.push(&field.name);
489                }
490            }
491
492        }
493
494        Block::Route { path, span, .. } => {
495            if path.is_empty() {
496                diagnostics.push(Diagnostic {
497                    severity: Severity::Error,
498                    message: "Route block is missing required attribute: path".into(),
499                    span: Some(*span),
500                    code: Some("V310".into()),
501                    fix: None,
502                });
503            } else if !path.starts_with('/') {
504                diagnostics.push(Diagnostic {
505                    severity: Severity::Warning,
506                    message: format!("Route path \"{}\" should start with /", path),
507                    span: Some(*span),
508                    code: Some("V311".into()),
509                    fix: None,
510                });
511            }
512        }
513
514        Block::Auth { roles, span, .. } => {
515            if roles.is_empty() {
516                diagnostics.push(Diagnostic {
517                    severity: Severity::Warning,
518                    message: "Auth block has no roles defined".into(),
519                    span: Some(*span),
520                    code: Some("V320".into()),
521                    fix: None,
522                });
523            }
524        }
525
526        Block::Binding { source, target, span, .. } => {
527            if source.is_empty() {
528                diagnostics.push(Diagnostic {
529                    severity: Severity::Error,
530                    message: "Binding block is missing required attribute: source".into(),
531                    span: Some(*span),
532                    code: Some("V330".into()),
533                    fix: None,
534                });
535            }
536            if target.is_empty() {
537                diagnostics.push(Diagnostic {
538                    severity: Severity::Error,
539                    message: "Binding block is missing required attribute: target".into(),
540                    span: Some(*span),
541                    code: Some("V331".into()),
542                    fix: None,
543                });
544            }
545        }
546
547        Block::Details { .. } => {}
548        Block::Divider { .. } => {}
549
550        // Markdown, Tasks, Summary, Columns, Style, Site, Unknown — no required-field validation
551        _ => {}
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::types::*;
559
560    fn span() -> Span {
561        Span {
562            start_line: 1,
563            end_line: 1,
564            start_offset: 0,
565            end_offset: 0,
566        }
567    }
568
569    #[test]
570    fn validate_empty_doc() {
571        let doc = SurfDoc {
572            front_matter: None,
573            blocks: vec![],
574            source: String::new(),
575        };
576        let diags = validate(&doc);
577        // Should warn about missing title and doc_type
578        assert!(
579            diags.iter().any(|d| d.message.contains("title")),
580            "Should warn about missing title"
581        );
582        assert!(
583            diags.iter().any(|d| d.message.contains("doc_type")),
584            "Should warn about missing doc_type"
585        );
586    }
587
588    #[test]
589    fn validate_complete_doc() {
590        let doc = SurfDoc {
591            front_matter: Some(FrontMatter {
592                title: Some("Complete Doc".into()),
593                doc_type: Some(DocType::Doc),
594                ..FrontMatter::default()
595            }),
596            blocks: vec![Block::Markdown {
597                content: "Hello".into(),
598                span: span(),
599            }],
600            source: String::new(),
601        };
602        let diags = validate(&doc);
603        assert!(
604            diags.is_empty(),
605            "Complete doc should have no diagnostics, got: {diags:?}"
606        );
607    }
608
609    #[test]
610    fn validate_missing_metric_label() {
611        let doc = SurfDoc {
612            front_matter: Some(FrontMatter {
613                title: Some("Test".into()),
614                doc_type: Some(DocType::Report),
615                ..FrontMatter::default()
616            }),
617            blocks: vec![Block::Metric {
618                label: String::new(),
619                value: "$2K".into(),
620                trend: None,
621                unit: None,
622                span: span(),
623            }],
624            source: String::new(),
625        };
626        let diags = validate(&doc);
627        let metric_diags: Vec<_> = diags
628            .iter()
629            .filter(|d| d.message.contains("label"))
630            .collect();
631        assert_eq!(metric_diags.len(), 1);
632        assert_eq!(metric_diags[0].severity, Severity::Error);
633    }
634
635    #[test]
636    fn validate_missing_figure_src() {
637        let doc = SurfDoc {
638            front_matter: Some(FrontMatter {
639                title: Some("Test".into()),
640                doc_type: Some(DocType::Doc),
641                ..FrontMatter::default()
642            }),
643            blocks: vec![Block::Figure {
644                src: String::new(),
645                caption: Some("Photo".into()),
646                alt: None,
647                width: None,
648                span: span(),
649            }],
650            source: String::new(),
651        };
652        let diags = validate(&doc);
653        let figure_diags: Vec<_> = diags
654            .iter()
655            .filter(|d| d.message.contains("src"))
656            .collect();
657        assert_eq!(figure_diags.len(), 1);
658        assert_eq!(figure_diags[0].severity, Severity::Error);
659    }
660
661    #[test]
662    fn validate_duplicate_page_routes() {
663        let doc = SurfDoc {
664            front_matter: Some(FrontMatter {
665                title: Some("Test".into()),
666                doc_type: Some(DocType::Doc),
667                ..FrontMatter::default()
668            }),
669            blocks: vec![
670                Block::Page {
671                    route: "/".into(),
672                    title: Some("Home v1".into()),
673                    layout: None,
674                    sidebar: false,
675                    content: String::new(),
676                    children: vec![],
677                    span: Span { start_line: 1, end_line: 3, start_offset: 0, end_offset: 30 },
678                },
679                Block::Page {
680                    route: "/about".into(),
681                    title: Some("About".into()),
682                    layout: None,
683                    sidebar: false,
684                    content: String::new(),
685                    children: vec![],
686                    span: Span { start_line: 4, end_line: 6, start_offset: 31, end_offset: 60 },
687                },
688                Block::Page {
689                    route: "/".into(),
690                    title: Some("Home v2".into()),
691                    layout: None,
692                    sidebar: false,
693                    content: String::new(),
694                    children: vec![],
695                    span: Span { start_line: 7, end_line: 9, start_offset: 61, end_offset: 90 },
696                },
697            ],
698            source: String::new(),
699        };
700        let diags = validate(&doc);
701        let dup_diags: Vec<_> = diags
702            .iter()
703            .filter(|d| d.code.as_deref() == Some("V141"))
704            .collect();
705        assert_eq!(dup_diags.len(), 1, "Expected exactly 1 duplicate route diagnostic");
706        assert!(dup_diags[0].message.contains("/"), "Should mention the duplicate route");
707        assert_eq!(dup_diags[0].severity, Severity::Error);
708    }
709
710    #[test]
711    fn validate_unique_page_routes_no_false_positive() {
712        let doc = SurfDoc {
713            front_matter: Some(FrontMatter {
714                title: Some("Test".into()),
715                doc_type: Some(DocType::Doc),
716                ..FrontMatter::default()
717            }),
718            blocks: vec![
719                Block::Page {
720                    route: "/".into(),
721                    title: Some("Home".into()),
722                    layout: None,
723                    sidebar: false,
724                    content: String::new(),
725                    children: vec![],
726                    span: span(),
727                },
728                Block::Page {
729                    route: "/about".into(),
730                    title: Some("About".into()),
731                    layout: None,
732                    sidebar: false,
733                    content: String::new(),
734                    children: vec![],
735                    span: span(),
736                },
737                Block::Page {
738                    route: "/contact".into(),
739                    title: Some("Contact".into()),
740                    layout: None,
741                    sidebar: false,
742                    content: String::new(),
743                    children: vec![],
744                    span: span(),
745                },
746            ],
747            source: String::new(),
748        };
749        let diags = validate(&doc);
750        let dup_diags: Vec<_> = diags
751            .iter()
752            .filter(|d| d.code.as_deref() == Some("V141"))
753            .collect();
754        assert!(dup_diags.is_empty(), "No duplicate route diagnostics expected");
755    }
756
757    #[test]
758    fn validate_empty_code() {
759        let doc = SurfDoc {
760            front_matter: Some(FrontMatter {
761                title: Some("Test".into()),
762                doc_type: Some(DocType::Doc),
763                ..FrontMatter::default()
764            }),
765            blocks: vec![Block::Code {
766                lang: Some("rust".into()),
767                file: None,
768                highlight: vec![],
769                content: "   ".into(), // whitespace-only
770                span: span(),
771            }],
772            source: String::new(),
773        };
774        let diags = validate(&doc);
775        let code_diags: Vec<_> = diags
776            .iter()
777            .filter(|d| d.message.contains("Code block"))
778            .collect();
779        assert_eq!(code_diags.len(), 1);
780        assert_eq!(code_diags[0].severity, Severity::Warning);
781    }
782
783}