Skip to main content

prax_schema/
error.rs

1//! Error types for schema parsing and validation.
2
3// These warnings are false positives - the fields are used by derive macros
4#![allow(unused_assignments)]
5
6use miette::Diagnostic;
7use thiserror::Error;
8
9/// Result type for schema operations.
10pub type SchemaResult<T> = Result<T, SchemaError>;
11
12/// Errors that can occur during schema parsing and validation.
13#[derive(Error, Debug, Diagnostic)]
14pub enum SchemaError {
15    /// Error reading a file.
16    #[error("failed to read file: {path}")]
17    #[diagnostic(code(prax::schema::io_error))]
18    IoError {
19        path: String,
20        #[source]
21        source: std::io::Error,
22    },
23
24    /// Syntax error in the schema file.
25    #[error("syntax error in schema")]
26    #[diagnostic(code(prax::schema::syntax_error))]
27    SyntaxError {
28        #[source_code]
29        src: String,
30        #[label("error here")]
31        span: miette::SourceSpan,
32        message: String,
33    },
34
35    /// Invalid model definition.
36    #[error("invalid model `{name}`: {message}")]
37    #[diagnostic(code(prax::schema::invalid_model))]
38    InvalidModel { name: String, message: String },
39
40    /// Invalid field definition.
41    #[error("invalid field `{model}.{field}`: {message}")]
42    #[diagnostic(code(prax::schema::invalid_field))]
43    InvalidField {
44        model: String,
45        field: String,
46        message: String,
47    },
48
49    /// Invalid relation definition.
50    #[error("invalid relation `{model}.{field}`: {message}")]
51    #[diagnostic(code(prax::schema::invalid_relation))]
52    InvalidRelation {
53        model: String,
54        field: String,
55        message: String,
56    },
57
58    /// Duplicate definition.
59    #[error("duplicate {kind} `{name}`")]
60    #[diagnostic(code(prax::schema::duplicate))]
61    Duplicate { kind: String, name: String },
62
63    /// Unknown type reference.
64    #[error("unknown type `{type_name}` in `{model}.{field}`")]
65    #[diagnostic(code(prax::schema::unknown_type))]
66    UnknownType {
67        model: String,
68        field: String,
69        type_name: String,
70    },
71
72    /// Invalid attribute.
73    #[error("invalid attribute `@{attribute}`: {message}")]
74    #[diagnostic(code(prax::schema::invalid_attribute))]
75    InvalidAttribute { attribute: String, message: String },
76
77    /// Missing required attribute.
78    #[error("model `{model}` is missing required `@id` field")]
79    #[diagnostic(code(prax::schema::missing_id))]
80    MissingId { model: String },
81
82    /// Configuration error.
83    #[error("configuration error: {message}")]
84    #[diagnostic(code(prax::schema::config_error))]
85    ConfigError { message: String },
86
87    /// TOML parsing error.
88    #[error("failed to parse TOML")]
89    #[diagnostic(code(prax::schema::toml_error))]
90    TomlError {
91        #[source]
92        source: toml::de::Error,
93    },
94
95    /// Validation error with multiple issues.
96    #[error("schema validation failed with {count} error(s)")]
97    #[diagnostic(code(prax::schema::validation_failed))]
98    ValidationFailed {
99        count: usize,
100        #[related]
101        errors: Vec<SchemaError>,
102    },
103
104    /// A Vector field is missing a required @dim(N) attribute.
105    #[error("field '{field}' of type Vector is missing required @dim attribute")]
106    #[diagnostic(code(prax::schema::missing_vector_dimension))]
107    MissingVectorDimension {
108        /// Field name.
109        field: String,
110    },
111
112    /// A Vector field has an invalid @vectorType value.
113    #[error(
114        "invalid vector element type '{value}' (expected one of: float2, float4, float8, int1, int2, int4)"
115    )]
116    #[diagnostic(code(prax::schema::invalid_vector_type))]
117    InvalidVectorType {
118        /// Supplied type value.
119        value: String,
120    },
121
122    /// A Vector field has an invalid @metric value.
123    #[error("invalid vector metric '{value}' (expected one of: cosine, l2, inner)")]
124    #[diagnostic(code(prax::schema::invalid_vector_metric))]
125    InvalidVectorMetric {
126        /// Supplied metric value.
127        value: String,
128    },
129
130    /// A Vector field has an invalid @index value.
131    #[error("invalid vector index '{value}' (expected: hnsw)")]
132    #[diagnostic(code(prax::schema::invalid_vector_index))]
133    InvalidVectorIndex {
134        /// Supplied index value.
135        value: String,
136    },
137
138    /// Parse failure in a specific file within a multi-file schema directory.
139    #[error("parse error in source {}", .source.0)]
140    #[diagnostic(code(prax::schema::parse_in_file))]
141    ParseInFile {
142        source: crate::loader::SourceId,
143        #[source]
144        inner: Box<SchemaError>,
145    },
146
147    /// Same-named item declared in two source files.
148    #[error("duplicate {kind} `{name}` declared in two files")]
149    #[diagnostic(code(prax::schema::duplicate_across_files))]
150    DuplicateAcrossFiles {
151        kind: DuplicateKind,
152        name: String,
153        first: crate::loader::SourceLoc,
154        second: crate::loader::SourceLoc,
155    },
156
157    /// More than one `datasource` block across source files.
158    #[error("multiple datasource blocks declared (exactly one allowed across all files)")]
159    #[diagnostic(code(prax::schema::multiple_datasource))]
160    MultipleDatasource {
161        first: crate::loader::SourceLoc,
162        second: crate::loader::SourceLoc,
163    },
164
165    /// Schema directory contained no `*.prax` files.
166    #[error("schema directory `{}` contains no .prax files", .path.display())]
167    #[diagnostic(code(prax::schema::empty_directory))]
168    EmptySchemaDirectory { path: std::path::PathBuf },
169}
170
171/// The kind of top-level item involved in a cross-file duplicate-name error.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum DuplicateKind {
174    Model,
175    Enum,
176    Type,
177    View,
178    ServerGroup,
179    Policy,
180    Generator,
181    RawSql,
182    Procedure,
183}
184
185impl std::fmt::Display for DuplicateKind {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        let s = match self {
188            DuplicateKind::Model => "model",
189            DuplicateKind::Enum => "enum",
190            DuplicateKind::Type => "type",
191            DuplicateKind::View => "view",
192            DuplicateKind::ServerGroup => "serverGroup",
193            DuplicateKind::Policy => "policy",
194            DuplicateKind::Generator => "generator",
195            DuplicateKind::RawSql => "rawSql",
196            DuplicateKind::Procedure => "procedure",
197        };
198        f.write_str(s)
199    }
200}
201
202impl SchemaError {
203    /// Create a syntax error with source location.
204    pub fn syntax(
205        src: impl Into<String>,
206        offset: usize,
207        len: usize,
208        message: impl Into<String>,
209    ) -> Self {
210        Self::SyntaxError {
211            src: src.into(),
212            span: (offset, len).into(),
213            message: message.into(),
214        }
215    }
216
217    /// Create an invalid model error.
218    pub fn invalid_model(name: impl Into<String>, message: impl Into<String>) -> Self {
219        Self::InvalidModel {
220            name: name.into(),
221            message: message.into(),
222        }
223    }
224
225    /// Create an invalid field error.
226    pub fn invalid_field(
227        model: impl Into<String>,
228        field: impl Into<String>,
229        message: impl Into<String>,
230    ) -> Self {
231        Self::InvalidField {
232            model: model.into(),
233            field: field.into(),
234            message: message.into(),
235        }
236    }
237
238    /// Create an invalid relation error.
239    pub fn invalid_relation(
240        model: impl Into<String>,
241        field: impl Into<String>,
242        message: impl Into<String>,
243    ) -> Self {
244        Self::InvalidRelation {
245            model: model.into(),
246            field: field.into(),
247            message: message.into(),
248        }
249    }
250
251    /// Create a duplicate definition error.
252    pub fn duplicate(kind: impl Into<String>, name: impl Into<String>) -> Self {
253        Self::Duplicate {
254            kind: kind.into(),
255            name: name.into(),
256        }
257    }
258
259    /// Create an unknown type error.
260    pub fn unknown_type(
261        model: impl Into<String>,
262        field: impl Into<String>,
263        type_name: impl Into<String>,
264    ) -> Self {
265        Self::UnknownType {
266            model: model.into(),
267            field: field.into(),
268            type_name: type_name.into(),
269        }
270    }
271}
272
273#[cfg(test)]
274#[allow(unused_assignments)]
275mod tests {
276    use super::*;
277
278    #[test]
279    #[allow(clippy::unnecessary_literal_unwrap)]
280    fn test_schema_result_type() {
281        let ok_result: SchemaResult<i32> = Ok(42);
282        assert!(ok_result.is_ok());
283        assert_eq!(ok_result.unwrap(), 42);
284
285        let err_result: SchemaResult<i32> = Err(SchemaError::ConfigError {
286            message: "test".to_string(),
287        });
288        assert!(err_result.is_err());
289    }
290
291    // ==================== Error Constructor Tests ====================
292
293    #[test]
294    fn test_syntax_error() {
295        let err = SchemaError::syntax("model User { }", 6, 4, "unexpected token");
296
297        match err {
298            SchemaError::SyntaxError { src, span, message } => {
299                assert_eq!(src, "model User { }");
300                assert_eq!(span.offset(), 6);
301                assert_eq!(span.len(), 4);
302                assert_eq!(message, "unexpected token");
303            }
304            _ => panic!("Expected SyntaxError"),
305        }
306    }
307
308    #[test]
309    fn test_invalid_model_error() {
310        let err = SchemaError::invalid_model("User", "missing id field");
311
312        match err {
313            SchemaError::InvalidModel { name, message } => {
314                assert_eq!(name, "User");
315                assert_eq!(message, "missing id field");
316            }
317            _ => panic!("Expected InvalidModel"),
318        }
319    }
320
321    #[test]
322    fn test_invalid_field_error() {
323        let err = SchemaError::invalid_field("User", "email", "invalid type");
324
325        match err {
326            SchemaError::InvalidField {
327                model,
328                field,
329                message,
330            } => {
331                assert_eq!(model, "User");
332                assert_eq!(field, "email");
333                assert_eq!(message, "invalid type");
334            }
335            _ => panic!("Expected InvalidField"),
336        }
337    }
338
339    #[test]
340    fn test_invalid_relation_error() {
341        let err = SchemaError::invalid_relation("Post", "author", "missing foreign key");
342
343        match err {
344            SchemaError::InvalidRelation {
345                model,
346                field,
347                message,
348            } => {
349                assert_eq!(model, "Post");
350                assert_eq!(field, "author");
351                assert_eq!(message, "missing foreign key");
352            }
353            _ => panic!("Expected InvalidRelation"),
354        }
355    }
356
357    #[test]
358    fn test_duplicate_error() {
359        let err = SchemaError::duplicate("model", "User");
360
361        match err {
362            SchemaError::Duplicate { kind, name } => {
363                assert_eq!(kind, "model");
364                assert_eq!(name, "User");
365            }
366            _ => panic!("Expected Duplicate"),
367        }
368    }
369
370    #[test]
371    fn test_unknown_type_error() {
372        let err = SchemaError::unknown_type("Post", "category", "Category");
373
374        match err {
375            SchemaError::UnknownType {
376                model,
377                field,
378                type_name,
379            } => {
380                assert_eq!(model, "Post");
381                assert_eq!(field, "category");
382                assert_eq!(type_name, "Category");
383            }
384            _ => panic!("Expected UnknownType"),
385        }
386    }
387
388    // ==================== Error Display Tests ====================
389
390    #[test]
391    fn test_io_error_display() {
392        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
393        let err = SchemaError::IoError {
394            path: "schema.prax".to_string(),
395            source: io_err,
396        };
397
398        let display = format!("{}", err);
399        assert!(display.contains("schema.prax"));
400    }
401
402    #[test]
403    fn test_syntax_error_display() {
404        let err = SchemaError::syntax("model", 0, 5, "unexpected");
405        let display = format!("{}", err);
406        assert!(display.contains("syntax error"));
407    }
408
409    #[test]
410    fn test_invalid_model_display() {
411        let err = SchemaError::invalid_model("User", "test message");
412        let display = format!("{}", err);
413        assert!(display.contains("User"));
414        assert!(display.contains("test message"));
415    }
416
417    #[test]
418    fn test_invalid_field_display() {
419        let err = SchemaError::invalid_field("User", "email", "test");
420        let display = format!("{}", err);
421        assert!(display.contains("User.email"));
422    }
423
424    #[test]
425    fn test_invalid_relation_display() {
426        let err = SchemaError::invalid_relation("Post", "author", "test");
427        let display = format!("{}", err);
428        assert!(display.contains("Post.author"));
429    }
430
431    #[test]
432    fn test_duplicate_display() {
433        let err = SchemaError::duplicate("model", "User");
434        let display = format!("{}", err);
435        assert!(display.contains("duplicate"));
436        assert!(display.contains("model"));
437        assert!(display.contains("User"));
438    }
439
440    #[test]
441    fn test_unknown_type_display() {
442        let err = SchemaError::unknown_type("Post", "author", "UserType");
443        let display = format!("{}", err);
444        assert!(display.contains("UserType"));
445        assert!(display.contains("Post.author"));
446    }
447
448    #[test]
449    fn test_missing_id_display() {
450        let err = SchemaError::MissingId {
451            model: "User".to_string(),
452        };
453        let display = format!("{}", err);
454        assert!(display.contains("User"));
455        assert!(display.contains("@id"));
456    }
457
458    #[test]
459    fn test_config_error_display() {
460        let err = SchemaError::ConfigError {
461            message: "invalid URL".to_string(),
462        };
463        let display = format!("{}", err);
464        assert!(display.contains("invalid URL"));
465    }
466
467    #[test]
468    fn test_validation_failed_display() {
469        let err = SchemaError::ValidationFailed {
470            count: 3,
471            errors: vec![],
472        };
473        let display = format!("{}", err);
474        assert!(display.contains("3"));
475    }
476
477    // ==================== Error Debug Tests ====================
478
479    #[test]
480    fn test_error_debug() {
481        let err = SchemaError::invalid_model("User", "test");
482        let debug = format!("{:?}", err);
483        assert!(debug.contains("InvalidModel"));
484        assert!(debug.contains("User"));
485    }
486
487    // ==================== Error From Constructors Tests ====================
488
489    #[test]
490    fn test_syntax_from_strings() {
491        let src = String::from("content");
492        let msg = String::from("message");
493        let err = SchemaError::syntax(src, 0, 7, msg);
494
495        if let SchemaError::SyntaxError { src, message, .. } = err {
496            assert_eq!(src, "content");
497            assert_eq!(message, "message");
498        } else {
499            panic!("Expected SyntaxError");
500        }
501    }
502
503    #[test]
504    fn test_invalid_model_from_strings() {
505        let name = String::from("Model");
506        let msg = String::from("error");
507        let err = SchemaError::invalid_model(name, msg);
508
509        if let SchemaError::InvalidModel { name, message } = err {
510            assert_eq!(name, "Model");
511            assert_eq!(message, "error");
512        } else {
513            panic!("Expected InvalidModel");
514        }
515    }
516
517    #[test]
518    fn test_invalid_field_from_strings() {
519        let model = String::from("User");
520        let field = String::from("email");
521        let msg = String::from("error");
522        let err = SchemaError::invalid_field(model, field, msg);
523
524        if let SchemaError::InvalidField {
525            model,
526            field,
527            message,
528        } = err
529        {
530            assert_eq!(model, "User");
531            assert_eq!(field, "email");
532            assert_eq!(message, "error");
533        } else {
534            panic!("Expected InvalidField");
535        }
536    }
537
538    #[test]
539    fn duplicate_across_files_displays_name_and_kind() {
540        use crate::ast::Span;
541        use crate::loader::{SourceId, SourceLoc};
542
543        let err = SchemaError::DuplicateAcrossFiles {
544            kind: DuplicateKind::Model,
545            name: "User".to_string(),
546            first: SourceLoc::new(SourceId(0), Span::new(0, 10)),
547            second: SourceLoc::new(SourceId(1), Span::new(0, 10)),
548        };
549        let msg = format!("{err}");
550        assert!(msg.contains("duplicate model"), "got: {msg}");
551        assert!(msg.contains("User"), "got: {msg}");
552    }
553
554    #[test]
555    fn empty_directory_displays_path() {
556        let err = SchemaError::EmptySchemaDirectory {
557            path: std::path::PathBuf::from("/tmp/empty"),
558        };
559        assert!(format!("{err}").contains("/tmp/empty"));
560    }
561}