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 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 validate_unique_page_routes(&doc.blocks, &mut diagnostics);
35
36 diagnostics
42}
43
44fn 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 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 _ => {}
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 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(), 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}