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