Skip to main content

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