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    diagnostics
25}
26
27fn validate_front_matter(doc: &SurfDoc, diagnostics: &mut Vec<Diagnostic>) {
28    match &doc.front_matter {
29        None => {
30            diagnostics.push(Diagnostic {
31                severity: Severity::Warning,
32                message: "Missing front matter: no title specified".into(),
33                span: None,
34                code: Some("V001".into()),
35            });
36            diagnostics.push(Diagnostic {
37                severity: Severity::Warning,
38                message: "Missing front matter: no doc_type specified".into(),
39                span: None,
40                code: Some("V002".into()),
41            });
42        }
43        Some(fm) => {
44            if fm.title.is_none() {
45                diagnostics.push(Diagnostic {
46                    severity: Severity::Warning,
47                    message: "Missing front matter field: title".into(),
48                    span: None,
49                    code: Some("V001".into()),
50                });
51            }
52            if fm.doc_type.is_none() {
53                diagnostics.push(Diagnostic {
54                    severity: Severity::Warning,
55                    message: "Missing front matter field: doc_type".into(),
56                    span: None,
57                    code: Some("V002".into()),
58                });
59            }
60        }
61    }
62}
63
64fn validate_block(block: &Block, diagnostics: &mut Vec<Diagnostic>) {
65    match block {
66        Block::Metric {
67            label,
68            value,
69            span,
70            ..
71        } => {
72            if label.is_empty() {
73                diagnostics.push(Diagnostic {
74                    severity: Severity::Error,
75                    message: "Metric block is missing required attribute: label".into(),
76                    span: Some(*span),
77                    code: Some("V010".into()),
78                });
79            }
80            if value.is_empty() {
81                diagnostics.push(Diagnostic {
82                    severity: Severity::Error,
83                    message: "Metric block is missing required attribute: value".into(),
84                    span: Some(*span),
85                    code: Some("V011".into()),
86                });
87            }
88        }
89
90        Block::Figure { src, span, .. } => {
91            if src.is_empty() {
92                diagnostics.push(Diagnostic {
93                    severity: Severity::Error,
94                    message: "Figure block is missing required attribute: src".into(),
95                    span: Some(*span),
96                    code: Some("V020".into()),
97                });
98            }
99        }
100
101        Block::Data {
102            headers,
103            rows,
104            span,
105            ..
106        } => {
107            if !headers.is_empty() && rows.is_empty() {
108                diagnostics.push(Diagnostic {
109                    severity: Severity::Warning,
110                    message: "Data block has headers but zero data rows".into(),
111                    span: Some(*span),
112                    code: Some("V030".into()),
113                });
114            }
115        }
116
117        Block::Callout {
118            content, span, ..
119        } => {
120            if content.trim().is_empty() {
121                diagnostics.push(Diagnostic {
122                    severity: Severity::Warning,
123                    message: "Callout block has empty content".into(),
124                    span: Some(*span),
125                    code: Some("V040".into()),
126                });
127            }
128        }
129
130        Block::Code {
131            content, span, ..
132        } => {
133            if content.trim().is_empty() {
134                diagnostics.push(Diagnostic {
135                    severity: Severity::Warning,
136                    message: "Code block has empty content".into(),
137                    span: Some(*span),
138                    code: Some("V050".into()),
139                });
140            }
141        }
142
143        Block::Decision {
144            content, span, ..
145        } => {
146            if content.trim().is_empty() {
147                diagnostics.push(Diagnostic {
148                    severity: Severity::Warning,
149                    message: "Decision block has empty body".into(),
150                    span: Some(*span),
151                    code: Some("V060".into()),
152                });
153            }
154        }
155
156        Block::Tabs { tabs, span, .. } => {
157            if tabs.is_empty() {
158                diagnostics.push(Diagnostic {
159                    severity: Severity::Warning,
160                    message: "Tabs block has no tab panels".into(),
161                    span: Some(*span),
162                    code: Some("V070".into()),
163                });
164            }
165        }
166
167        Block::Quote {
168            content, span, ..
169        } => {
170            if content.trim().is_empty() {
171                diagnostics.push(Diagnostic {
172                    severity: Severity::Warning,
173                    message: "Quote block has empty content".into(),
174                    span: Some(*span),
175                    code: Some("V080".into()),
176                });
177            }
178        }
179
180        Block::Cta {
181            label,
182            href,
183            span,
184            ..
185        } => {
186            if label.is_empty() {
187                diagnostics.push(Diagnostic {
188                    severity: Severity::Error,
189                    message: "Cta block is missing required attribute: label".into(),
190                    span: Some(*span),
191                    code: Some("V090".into()),
192                });
193            }
194            if href.is_empty() {
195                diagnostics.push(Diagnostic {
196                    severity: Severity::Error,
197                    message: "Cta block is missing required attribute: href".into(),
198                    span: Some(*span),
199                    code: Some("V091".into()),
200                });
201            }
202        }
203
204        Block::HeroImage { src, span, .. } => {
205            if src.is_empty() {
206                diagnostics.push(Diagnostic {
207                    severity: Severity::Error,
208                    message: "HeroImage block is missing required attribute: src".into(),
209                    span: Some(*span),
210                    code: Some("V100".into()),
211                });
212            }
213        }
214
215        Block::Testimonial {
216            content, span, ..
217        } => {
218            if content.trim().is_empty() {
219                diagnostics.push(Diagnostic {
220                    severity: Severity::Warning,
221                    message: "Testimonial block has empty content".into(),
222                    span: Some(*span),
223                    code: Some("V110".into()),
224                });
225            }
226        }
227
228        Block::Faq { items, span, .. } => {
229            if items.is_empty() {
230                diagnostics.push(Diagnostic {
231                    severity: Severity::Warning,
232                    message: "Faq block has no question/answer items".into(),
233                    span: Some(*span),
234                    code: Some("V120".into()),
235                });
236            }
237        }
238
239        Block::PricingTable {
240            headers,
241            rows,
242            span,
243            ..
244        } => {
245            if headers.is_empty() {
246                diagnostics.push(Diagnostic {
247                    severity: Severity::Warning,
248                    message: "PricingTable block has no headers (tier names)".into(),
249                    span: Some(*span),
250                    code: Some("V130".into()),
251                });
252            }
253            if !headers.is_empty() && rows.is_empty() {
254                diagnostics.push(Diagnostic {
255                    severity: Severity::Warning,
256                    message: "PricingTable block has headers but zero feature rows".into(),
257                    span: Some(*span),
258                    code: Some("V131".into()),
259                });
260            }
261        }
262
263        Block::Page { route, span, .. } => {
264            if route.is_empty() {
265                diagnostics.push(Diagnostic {
266                    severity: Severity::Error,
267                    message: "Page block is missing required attribute: route".into(),
268                    span: Some(*span),
269                    code: Some("V140".into()),
270                });
271            }
272        }
273
274        // Markdown, Tasks, Summary, Columns, Style, Site, Unknown — no required-field validation
275        _ => {}
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::types::*;
283
284    fn span() -> Span {
285        Span {
286            start_line: 1,
287            end_line: 1,
288            start_offset: 0,
289            end_offset: 0,
290        }
291    }
292
293    #[test]
294    fn validate_empty_doc() {
295        let doc = SurfDoc {
296            front_matter: None,
297            blocks: vec![],
298            source: String::new(),
299        };
300        let diags = validate(&doc);
301        // Should warn about missing title and doc_type
302        assert!(
303            diags.iter().any(|d| d.message.contains("title")),
304            "Should warn about missing title"
305        );
306        assert!(
307            diags.iter().any(|d| d.message.contains("doc_type")),
308            "Should warn about missing doc_type"
309        );
310    }
311
312    #[test]
313    fn validate_complete_doc() {
314        let doc = SurfDoc {
315            front_matter: Some(FrontMatter {
316                title: Some("Complete Doc".into()),
317                doc_type: Some(DocType::Doc),
318                ..FrontMatter::default()
319            }),
320            blocks: vec![Block::Markdown {
321                content: "Hello".into(),
322                span: span(),
323            }],
324            source: String::new(),
325        };
326        let diags = validate(&doc);
327        assert!(
328            diags.is_empty(),
329            "Complete doc should have no diagnostics, got: {diags:?}"
330        );
331    }
332
333    #[test]
334    fn validate_missing_metric_label() {
335        let doc = SurfDoc {
336            front_matter: Some(FrontMatter {
337                title: Some("Test".into()),
338                doc_type: Some(DocType::Report),
339                ..FrontMatter::default()
340            }),
341            blocks: vec![Block::Metric {
342                label: String::new(),
343                value: "$2K".into(),
344                trend: None,
345                unit: None,
346                span: span(),
347            }],
348            source: String::new(),
349        };
350        let diags = validate(&doc);
351        let metric_diags: Vec<_> = diags
352            .iter()
353            .filter(|d| d.message.contains("label"))
354            .collect();
355        assert_eq!(metric_diags.len(), 1);
356        assert_eq!(metric_diags[0].severity, Severity::Error);
357    }
358
359    #[test]
360    fn validate_missing_figure_src() {
361        let doc = SurfDoc {
362            front_matter: Some(FrontMatter {
363                title: Some("Test".into()),
364                doc_type: Some(DocType::Doc),
365                ..FrontMatter::default()
366            }),
367            blocks: vec![Block::Figure {
368                src: String::new(),
369                caption: Some("Photo".into()),
370                alt: None,
371                width: None,
372                span: span(),
373            }],
374            source: String::new(),
375        };
376        let diags = validate(&doc);
377        let figure_diags: Vec<_> = diags
378            .iter()
379            .filter(|d| d.message.contains("src"))
380            .collect();
381        assert_eq!(figure_diags.len(), 1);
382        assert_eq!(figure_diags[0].severity, Severity::Error);
383    }
384
385    #[test]
386    fn validate_empty_code() {
387        let doc = SurfDoc {
388            front_matter: Some(FrontMatter {
389                title: Some("Test".into()),
390                doc_type: Some(DocType::Doc),
391                ..FrontMatter::default()
392            }),
393            blocks: vec![Block::Code {
394                lang: Some("rust".into()),
395                file: None,
396                highlight: vec![],
397                content: "   ".into(), // whitespace-only
398                span: span(),
399            }],
400            source: String::new(),
401        };
402        let diags = validate(&doc);
403        let code_diags: Vec<_> = diags
404            .iter()
405            .filter(|d| d.message.contains("Code block"))
406            .collect();
407        assert_eq!(code_diags.len(), 1);
408        assert_eq!(code_diags[0].severity, Severity::Warning);
409    }
410}