forgedb_validation/lib.rs
1//! ForgeDB Validation
2//!
3//! Schema and data validation for ForgeDB with support for constraints, HTTP validation,
4//! and error reporting.
5//!
6//! # Overview
7//!
8//! This crate provides comprehensive validation capabilities for ForgeDB, including:
9//!
10//! - **Schema validation** - Validate schema definitions for correctness
11//! - **Constraint validation** - Enforce field constraints (@unique, @email, @min, etc.)
12//! - **HTTP validation** - Validate HTTP endpoint definitions and status codes
13//! - **Error reporting** - Detailed validation errors with position information
14//!
15//! # Architecture
16//!
17//! The validation system is organized into several modules:
18//!
19//! - **Core validation** - Basic validation types and error reporting
20//! - **HTTP validation** - Validates HTTP-specific constructs (endpoints, status codes)
21//! - **Status code mapping** - Maps database operations to appropriate HTTP status codes
22//!
23//! ## Validation Pipeline
24//!
25//! 1. **Schema Parsing** - Parse schema into AST (handled by forgedb-parser)
26//! 2. **Schema Validation** - Validate schema structure and semantics
27//! 3. **Constraint Validation** - Check constraint parameters and applicability
28//! 4. **HTTP Validation** - Validate HTTP endpoints and status codes (if applicable)
29//!
30//! # Examples
31//!
32//! ## Schema Validation
33//!
34//! ```rust
35//! use forgedb_validation::{ValidationError, Position};
36//!
37//! // Create a validation error with position
38//! let error = ValidationError::new("Field 'email' must have @email constraint")
39//! .with_position(Position::new(5, 12))
40//! .with_suggestion("Add @email constraint: email: string @email");
41//!
42//! println!("Error at {}:{}: {}",
43//! error.position.unwrap().line,
44//! error.position.unwrap().column,
45//! error.message
46//! );
47//! ```
48//!
49//! ## HTTP Validation
50//!
51//! ```rust
52//! use forgedb_validation::HttpValidator;
53//!
54//! // HttpValidator is a unit struct — call methods directly (no constructor)
55//! let result = HttpValidator::validate_email("user@example.com");
56//! assert!(result.is_ok());
57//!
58//! let result = HttpValidator::validate_length("name", "hi", 1, 10);
59//! assert!(result.is_ok());
60//! ```
61//!
62//! ## Status Code Mapping
63//!
64//! ```rust
65//! use forgedb_validation::StatusCodeMapper;
66//!
67//! // StatusCodeMapper is a unit struct — call methods as associated functions
68//! let code = StatusCodeMapper::for_validation_error("not_found");
69//! assert_eq!(code, 404);
70//!
71//! assert!(StatusCodeMapper::is_success(200));
72//! assert!(StatusCodeMapper::is_client_error(404));
73//! assert!(StatusCodeMapper::is_server_error(500));
74//! ```
75//!
76//! # Public API
77//!
78//! ## Core Types
79//!
80//! - [`ValidationError`] - Validation error with position and suggestion
81//! - [`Position`] - Line and column position in source code
82//! - [`HttpValidator`] - HTTP-specific validation
83//! - [`StatusCodeMapper`] - Maps operations to HTTP status codes
84//!
85//! ## Key Methods
86//!
87//! - `ValidationError::new()` - Create a validation error
88//! - `ValidationError::with_position()` - Add position information
89//! - `ValidationError::with_suggestion()` - Add a suggestion for fixing the error
90//! - `HttpValidator::validate_endpoint_path()` - Validate HTTP endpoint path
91//! - `StatusCodeMapper::get_status_code()` - Get appropriate status code
92//!
93//! # Validation Rules
94//!
95//! ## Schema Validation
96//!
97//! - **Unique names**: Model and struct names must be unique
98//! - **Field types**: Field types must be valid ForgeDB types
99//! - **Primary keys**: Each model must have exactly one primary key
100//! - **Relations**: Relation targets must reference existing models
101//!
102//! ## Constraint Validation
103//!
104//! - **@unique**: Can be applied to any field type
105//! - **@email**: Only for string fields
106//! - **@min/@max**: Only for numeric fields
107//! - **@length**: Only for string fields
108//! - **@pattern**: Only for string fields (regex validation)
109//!
110//! ## HTTP Validation
111//!
112//! - **Endpoint paths**: Must start with '/', valid parameter syntax
113//! - **Status codes**: Must be valid HTTP status codes (200-599)
114//! - **HTTP methods**: Must be valid (GET, POST, PUT, DELETE, PATCH)
115//!
116//! # Error Reporting
117//!
118//! Validation errors include:
119//! - **Message**: Clear description of the error
120//! - **Position**: Line and column where error occurred
121//! - **Suggestion**: Helpful suggestion for fixing the error
122//!
123//! Example error output:
124//! ```text
125//! Error at line 5, column 12: Field 'email' must have @email constraint
126//! Suggestion: Add @email constraint: email: string @email
127//! ```
128//!
129//! # Related Crates
130//!
131//! - [`forgedb-parser`](../forgedb_parser) - Parses schemas before validation
132//!
133//! # See Also
134//!
135//! - [SPRINT2_VALIDATION.md](../../archive/sprint-summaries/SPRINT2_VALIDATION.md) - Schema validation
136//! - [SPRINT9_SUMMARY.md](../../archive/sprint-summaries/SPRINT9_SUMMARY.md) - HTTP validation
137
138pub mod http;
139pub mod status;
140
141use std::collections::HashSet;
142
143pub use http::{HttpValidationError, HttpValidator};
144pub use status::StatusCodeMapper;
145
146/// Represents a position in the source code
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct Position {
149 pub line: usize,
150 pub column: usize,
151}
152
153impl Position {
154 pub fn new(line: usize, column: usize) -> Self {
155 Position { line, column }
156 }
157}
158
159/// Validation error with position information
160#[derive(Debug, Clone, PartialEq)]
161pub struct ValidationError {
162 pub message: String,
163 pub position: Option<Position>,
164 pub suggestion: Option<String>,
165}
166
167impl ValidationError {
168 pub fn new(message: impl Into<String>) -> Self {
169 ValidationError {
170 message: message.into(),
171 position: None,
172 suggestion: None,
173 }
174 }
175
176 pub fn with_position(mut self, position: Position) -> Self {
177 self.position = Some(position);
178 self
179 }
180
181 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
182 self.suggestion = Some(suggestion.into());
183 self
184 }
185}
186
187impl std::fmt::Display for ValidationError {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 if let Some(pos) = self.position {
190 write!(
191 f,
192 "Error at line {}, column {}: {}",
193 pos.line, pos.column, self.message
194 )?;
195 } else {
196 write!(f, "Error: {}", self.message)?;
197 }
198
199 if let Some(ref suggestion) = self.suggestion {
200 write!(f, "\n Suggestion: {}", suggestion)?;
201 }
202
203 Ok(())
204 }
205}
206
207impl std::error::Error for ValidationError {}
208
209pub type ValidationResult<T> = Result<T, ValidationError>;
210
211/// Check if a string is in snake_case format
212pub fn is_snake_case(s: &str) -> bool {
213 if s.is_empty() {
214 return false;
215 }
216
217 // Must start with lowercase letter or underscore
218 if !s.chars().next().unwrap().is_lowercase() && !s.starts_with('_') {
219 return false;
220 }
221
222 // Can only contain lowercase letters, digits, and underscores
223 s.chars()
224 .all(|c| c.is_lowercase() || c.is_ascii_digit() || c == '_')
225}
226
227/// Check if a string is in PascalCase format
228pub fn is_pascal_case(s: &str) -> bool {
229 if s.is_empty() {
230 return false;
231 }
232
233 // Must start with uppercase letter
234 if !s.chars().next().unwrap().is_uppercase() {
235 return false;
236 }
237
238 // Can only contain letters and digits, no underscores
239 s.chars().all(|c| c.is_alphanumeric()) && !s.contains('_')
240}
241
242/// Convert a string to snake_case (for suggestions)
243pub fn to_snake_case(s: &str) -> String {
244 let mut result = String::new();
245 let chars: Vec<char> = s.chars().collect();
246
247 for i in 0..chars.len() {
248 let c = chars[i];
249
250 if c.is_uppercase() {
251 // Add underscore before uppercase if:
252 // - Not the first character
253 // - Previous char was lowercase or digit
254 // - OR next char is lowercase (e.g., "HTTPServer" -> "http_server")
255 if i > 0 {
256 let prev = chars[i - 1];
257 let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
258
259 if prev.is_lowercase()
260 || prev.is_ascii_digit()
261 || (prev.is_uppercase() && next_is_lower)
262 {
263 result.push('_');
264 }
265 }
266 result.push(c.to_lowercase().next().unwrap());
267 } else {
268 result.push(c);
269 }
270 }
271
272 result
273}
274
275/// Convert a string to PascalCase (for suggestions)
276pub fn to_pascal_case(s: &str) -> String {
277 let mut result = String::new();
278 let mut capitalize_next = true;
279
280 for c in s.chars() {
281 if c == '_' {
282 capitalize_next = true;
283 } else if capitalize_next {
284 result.push(c.to_uppercase().next().unwrap());
285 capitalize_next = false;
286 } else {
287 result.push(c);
288 }
289 }
290
291 result
292}
293
294/// Validate field name follows snake_case convention
295pub fn validate_field_name(name: &str, position: Option<Position>) -> ValidationResult<()> {
296 if !is_snake_case(name) {
297 let suggestion = to_snake_case(name);
298 let error = ValidationError::new(format!("Field name '{}' must be in snake_case", name))
299 .with_suggestion(format!("Consider using '{}'", suggestion));
300
301 if let Some(pos) = position {
302 return Err(error.with_position(pos));
303 }
304 return Err(error);
305 }
306 Ok(())
307}
308
309/// Validate model name follows PascalCase convention
310pub fn validate_model_name(name: &str, position: Option<Position>) -> ValidationResult<()> {
311 if !is_pascal_case(name) {
312 let suggestion = to_pascal_case(name);
313 let error = ValidationError::new(format!("Model name '{}' must be in PascalCase", name))
314 .with_suggestion(format!("Consider using '{}'", suggestion));
315
316 if let Some(pos) = position {
317 return Err(error.with_position(pos));
318 }
319 return Err(error);
320 }
321 Ok(())
322}
323
324/// Check for duplicate field names in a collection
325pub fn check_duplicate_fields(fields: &[(String, Option<Position>)]) -> ValidationResult<()> {
326 let mut seen = HashSet::new();
327
328 for (name, position) in fields {
329 if seen.contains(name) {
330 let error = ValidationError::new(format!("Duplicate field name '{}'", name));
331 if let Some(pos) = position {
332 return Err(error.with_position(*pos));
333 }
334 return Err(error);
335 }
336 seen.insert(name.clone());
337 }
338
339 Ok(())
340}
341
342/// Check for duplicate model names in a collection
343pub fn check_duplicate_models(models: &[(String, Option<Position>)]) -> ValidationResult<()> {
344 let mut seen = HashSet::new();
345
346 for (name, position) in models {
347 if seen.contains(name) {
348 let error = ValidationError::new(format!("Duplicate model name '{}'", name));
349 if let Some(pos) = position {
350 return Err(error.with_position(*pos));
351 }
352 return Err(error);
353 }
354 seen.insert(name.clone());
355 }
356
357 Ok(())
358}
359