1use crate::error::{Diagnostic, Severity};
7use crate::types::{Block, SurfDoc};
8
9pub fn validate(doc: &SurfDoc) -> Vec<Diagnostic> {
14 let mut diagnostics = Vec::new();
15
16 validate_front_matter(doc, &mut diagnostics);
18
19 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 _ => {}
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 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(), 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}