slices_lexicon/
lib.rs

1//! # AT Protocol Lexicon Validator
2//!
3//! This crate provides validation for AT Protocol Lexicon schema documents and data records.
4//! Lexicon is a schema definition language used to describe atproto records, HTTP endpoints (XRPC),
5//! and event stream messages.
6//!
7//! ## Features
8//!
9//! - **Schema validation**: Validate lexicon documents against the lexicon specification
10//! - **Data validation**: Validate data records against their lexicon schemas
11//! - **Cross-reference checking**: Ensure all references between lexicons can be resolved
12//! - **Format validation**: Support for AT Protocol string formats (DIDs, handles, NSIDs, etc.)
13//! - **WebAssembly support**: Optional WASM bindings for browser/JS usage
14//!
15//! ## Basic Usage
16//!
17//! ```rust
18//! use slices_lexicon::{validate, ValidationError};
19//! use serde_json::json;
20//!
21//! let lexicons = vec![
22//!     json!({
23//!         "lexicon": 1,
24//!         "id": "com.example.post",
25//!         "defs": {
26//!             "main": {
27//!                 "type": "record",
28//!                 "key": "tid",
29//!                 "record": {
30//!                     "type": "object",
31//!                     "required": ["text"],
32//!                     "properties": {
33//!                         "text": { "type": "string", "maxLength": 300 }
34//!                     }
35//!                 }
36//!             }
37//!         }
38//!     })
39//! ];
40//!
41//! match validate(lexicons) {
42//!     Ok(_) => println!("All lexicons are valid!"),
43//!     Err(errors) => {
44//!         for (lexicon_id, error_list) in errors {
45//!             println!("Errors in {}: {:?}", lexicon_id, error_list);
46//!         }
47//!     }
48//! }
49//! ```
50//!
51//! ## Advanced Usage with Validation Context
52//!
53//! ```rust
54//! use slices_lexicon::validation::{ValidationContext, primary::RecordValidator, Validator};
55//! use serde_json::json;
56//!
57//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
58//! let lexicons = vec![
59//!     json!({
60//!         "lexicon": 1,
61//!         "id": "com.example.post",
62//!         "defs": {
63//!             "main": {
64//!                 "type": "record",
65//!                 "key": "tid",
66//!                 "record": {
67//!                     "type": "object",
68//!                     "required": ["text"],
69//!                     "properties": {
70//!                         "text": { "type": "string" }
71//!                     }
72//!                 }
73//!             }
74//!         }
75//!     })
76//! ];
77//!
78//! let ctx = ValidationContext::builder()
79//!     .with_lexicons(lexicons)?
80//!     .build()?;
81//!
82//! let validator = RecordValidator;
83//! let record_def = json!({
84//!     "type": "record",
85//!     "key": "tid",
86//!     "record": {
87//!         "type": "object",
88//!         "required": ["text"],
89//!         "properties": {
90//!             "text": { "type": "string" }
91//!         }
92//!     }
93//! });
94//! validator.validate(&record_def, &ctx)?;
95//! # Ok(())
96//! # }
97//! ```
98
99pub mod errors;
100pub mod types;
101pub mod validation;
102
103use std::collections::HashMap;
104use serde_json::Value;
105
106// Import all validators for data validation
107use crate::validation::{
108    primitive::{
109        string::StringValidator, integer::IntegerValidator, boolean::BooleanValidator,
110        bytes::BytesValidator, blob::BlobValidator, cid_link::CidLinkValidator,
111        null::NullValidator,
112    },
113    field::{
114        object::ObjectValidator, array::ArrayValidator, union::UnionValidator,
115        reference::RefValidator,
116    },
117    meta::{
118        token::TokenValidator, unknown::UnknownValidator,
119    },
120    primary::{
121        record::RecordValidator, query::QueryValidator, procedure::ProcedureValidator,
122        subscription::SubscriptionValidator,
123    },
124};
125
126pub use errors::ValidationError;
127pub use types::{LexiconDoc, StringFormat};
128pub use validation::{ValidationContext, ValidationContextBuilder, Validator};
129
130/// Validates a set of lexicon documents
131///
132/// This is the primary entry point for validating lexicon schemas. It performs
133/// comprehensive validation including:
134/// - Structural validation of each lexicon document
135/// - Type checking of all definitions
136/// - Cross-reference resolution checking
137/// - Format validation
138///
139/// # Arguments
140///
141/// * `lexicons` - Vector of JSON lexicon documents to validate
142///
143/// # Returns
144///
145/// * `Ok(())` if all lexicons are valid
146/// * `Err(HashMap<String, Vec<String>>)` mapping lexicon IDs to error messages
147///
148/// # Examples
149///
150/// ```rust
151/// use slices_lexicon::validate;
152/// use serde_json::json;
153///
154/// let lexicons = vec![
155///     json!({
156///         "lexicon": 1,
157///         "id": "com.example.post",
158///         "defs": {
159///             "main": {
160///                 "type": "record",
161///                 "key": "tid",
162///                 "record": {
163///                     "type": "object",
164///                     "required": ["text"],
165///                     "properties": {
166///                         "text": { "type": "string" }
167///                     }
168///                 }
169///             }
170///         }
171///     })
172/// ];
173///
174/// match validate(lexicons) {
175///     Ok(_) => println!("Valid!"),
176///     Err(errors) => {
177///         for (id, errs) in errors {
178///             println!("{}: {:?}", id, errs);
179///         }
180///     }
181/// }
182/// ```
183///
184/// # Errors
185///
186/// This function returns an error map where:
187/// - Keys are lexicon IDs (or "parse_error" for JSON parsing issues)
188/// - Values are vectors of human-readable error messages
189///
190/// Common error types include:
191/// - Missing required fields (`lexicon`, `id`, `defs`)
192/// - Invalid lexicon version (only version 1 is supported)
193/// - Invalid NSID format
194/// - Type mismatches in definitions
195/// - Missing references
196/// - Constraint violations
197pub fn validate(lexicons: Vec<Value>) -> Result<(), HashMap<String, Vec<String>>> {
198    use validation::{ValidationContext, primary, field, primitive, meta};
199
200    let mut all_errors = HashMap::new();
201
202    // Create validation context
203    let ctx = match ValidationContext::builder().with_lexicons(lexicons).and_then(|b| b.build()) {
204        Ok(context) => context,
205        Err(_) => {
206            let mut errors = HashMap::new();
207            errors.insert("context".to_string(), vec!["Failed to build validation context".to_string()]);
208            return Err(errors);
209        }
210    };
211
212    // Validate each lexicon
213    for (lexicon_id, lexicon_doc) in &ctx.lexicons {
214        let mut lexicon_errors = Vec::new();
215
216        // Validate each definition in the lexicon
217        if let Some(defs) = lexicon_doc.defs.as_object() {
218            for (def_name, def_value) in defs {
219                let def_context = ctx.with_current_lexicon(lexicon_id)
220                    .with_path(&format!("{}#{}", lexicon_id, def_name));
221
222                if let Some(type_str) = def_value.get("type").and_then(|t| t.as_str()) {
223                    let validation_result = match type_str {
224                        // Primary types
225                        "record" => primary::record::RecordValidator.validate(def_value, &def_context),
226                        "query" => primary::query::QueryValidator.validate(def_value, &def_context),
227                        "procedure" => primary::procedure::ProcedureValidator.validate(def_value, &def_context),
228                        "subscription" => primary::subscription::SubscriptionValidator.validate(def_value, &def_context),
229
230                        // Field types
231                        "object" => field::object::ObjectValidator.validate(def_value, &def_context),
232                        "array" => field::array::ArrayValidator.validate(def_value, &def_context),
233                        "union" => field::union::UnionValidator.validate(def_value, &def_context),
234                        "ref" => field::reference::RefValidator.validate(def_value, &def_context),
235                        // TODO: Implement params validator
236                        // "params" => field::params::ParamsValidator.validate(def_value, &def_context),
237
238                        // Primitive types
239                        "string" => primitive::string::StringValidator.validate(def_value, &def_context),
240                        "integer" => primitive::integer::IntegerValidator.validate(def_value, &def_context),
241                        "boolean" => primitive::boolean::BooleanValidator.validate(def_value, &def_context),
242                        "bytes" => primitive::bytes::BytesValidator.validate(def_value, &def_context),
243                        "blob" => primitive::blob::BlobValidator.validate(def_value, &def_context),
244                        "cid-link" => primitive::cid_link::CidLinkValidator.validate(def_value, &def_context),
245                        "null" => primitive::null::NullValidator.validate(def_value, &def_context),
246
247                        // Meta types
248                        "token" => meta::token::TokenValidator.validate(def_value, &def_context),
249                        "unknown" => meta::unknown::UnknownValidator.validate(def_value, &def_context),
250
251                        _ => Err(ValidationError::InvalidSchema(format!("Unknown type: {}", type_str))),
252                    };
253
254                    if let Err(error) = validation_result {
255                        lexicon_errors.push(error.to_string());
256                    }
257                } else {
258                    lexicon_errors.push(format!("Definition '{}' missing 'type' field", def_name));
259                }
260            }
261        }
262
263        if !lexicon_errors.is_empty() {
264            all_errors.insert(lexicon_id.clone(), lexicon_errors);
265        }
266    }
267
268    if all_errors.is_empty() {
269        Ok(())
270    } else {
271        Err(all_errors)
272    }
273}
274
275/// Validates a single data record against its lexicon schema
276///
277/// This function validates runtime data against a previously loaded lexicon.
278/// It's useful for validating records before storing them or API responses.
279///
280/// # Arguments
281///
282/// * `lexicons` - Vector of lexicon documents (must include the collection's lexicon)
283/// * `collection` - The collection name (NSID) that defines the record schema
284/// * `record` - The data record to validate
285///
286/// # Returns
287///
288/// * `Ok(())` if the record is valid
289/// * `Err(ValidationError)` with detailed error information
290///
291/// # Examples
292///
293/// ```rust
294/// use slices_lexicon::validate_record;
295/// use serde_json::json;
296///
297/// let lexicons = vec![/* ... */];
298/// let record = json!({
299///     "$type": "com.example.post",
300///     "text": "Hello, world!",
301///     "createdAt": "2024-01-01T12:00:00Z"
302/// });
303///
304/// match validate_record(lexicons, "com.example.post", record) {
305///     Ok(_) => println!("Record is valid!"),
306///     Err(e) => println!("Validation error: {}", e),
307/// }
308/// ```
309///
310/// # Errors
311///
312/// Returns a [`ValidationError`] if:
313/// - The lexicon for the collection is not found
314/// - The record doesn't match the schema (missing fields, wrong types, etc.)
315/// - Constraints are violated (string length, integer range, etc.)
316/// - Format validation fails (invalid DIDs, handles, etc.)
317pub fn validate_record(
318    lexicons: Vec<Value>,
319    collection: &str,
320    record: Value
321) -> Result<(), ValidationError> {
322    // Build validation context with lexicons
323    let ctx = ValidationContext::builder()
324        .with_lexicons(lexicons)
325        .map_err(|_| ValidationError::InvalidSchema("Failed to build validation context".to_string()))?
326        .build()
327        .map_err(|_| ValidationError::InvalidSchema("Failed to build validation context".to_string()))?;
328
329    // Find the lexicon for this collection
330    let lexicon = ctx.get_lexicon(collection)
331        .ok_or_else(|| ValidationError::LexiconNotFound(collection.to_string()))?;
332
333    // Get the main definition (records are always in 'main')
334    let main_def = lexicon.defs.as_object()
335        .and_then(|defs| defs.get("main"))
336        .ok_or_else(|| ValidationError::InvalidSchema(format!(
337            "Lexicon '{}' missing main definition", collection
338        )))?;
339
340    // Verify it's a record type
341    let type_str = main_def.get("type")
342        .and_then(|t| t.as_str())
343        .ok_or_else(|| ValidationError::InvalidSchema(format!(
344            "Lexicon '{}' main definition missing type", collection
345        )))?;
346
347    if type_str != "record" {
348        return Err(ValidationError::InvalidSchema(format!(
349            "Lexicon '{}' main definition is not a record type", collection
350        )));
351    }
352
353    // Get the record schema
354    let record_schema = main_def.get("record")
355        .ok_or_else(|| ValidationError::InvalidSchema(format!(
356            "Record definition '{}' missing 'record' field", collection
357        )))?;
358
359    // Validate the record data against the schema
360    validate_data_against_schema(&record, record_schema, &ctx.with_current_lexicon(collection).with_path(collection))
361}
362
363/// Internal function to validate data against a schema
364fn validate_data_against_schema(data: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
365    use crate::validation::Validator;
366
367    if let Some(type_str) = schema.get("type").and_then(|t| t.as_str()) {
368        match type_str {
369            // Primitive types
370            "string" => StringValidator.validate_data(data, schema, ctx),
371            "integer" => IntegerValidator.validate_data(data, schema, ctx),
372            "boolean" => BooleanValidator.validate_data(data, schema, ctx),
373            "bytes" => BytesValidator.validate_data(data, schema, ctx),
374            "blob" => BlobValidator.validate_data(data, schema, ctx),
375            "cid-link" => CidLinkValidator.validate_data(data, schema, ctx),
376            "null" => NullValidator.validate_data(data, schema, ctx),
377
378            // Field types
379            "object" => ObjectValidator.validate_data(data, schema, ctx),
380            "array" => ArrayValidator.validate_data(data, schema, ctx),
381            "union" => UnionValidator.validate_data(data, schema, ctx),
382            "ref" => RefValidator.validate_data(data, schema, ctx),
383
384            // Meta types
385            "token" => TokenValidator.validate_data(data, schema, ctx),
386            "unknown" => UnknownValidator.validate_data(data, schema, ctx),
387
388            // Primary types
389            "record" => RecordValidator.validate_data(data, schema, ctx),
390            "query" => QueryValidator.validate_data(data, schema, ctx),
391            "procedure" => ProcedureValidator.validate_data(data, schema, ctx),
392            "subscription" => SubscriptionValidator.validate_data(data, schema, ctx),
393
394            // Unknown type
395            _ => Err(ValidationError::InvalidSchema(format!(
396                "Unknown schema type '{}' at '{}'", type_str, ctx.path()
397            ))),
398        }
399    } else {
400        Err(ValidationError::InvalidSchema(format!(
401            "Schema missing type field at '{}'", ctx.path()
402        )))
403    }
404}
405
406/// Utility function to check if a string is a valid NSID (Namespaced Identifier)
407///
408/// NSIDs are used to identify lexicons, collections, and other AT Protocol concepts.
409/// They follow a reverse-domain-name format like `com.example.collection`.
410///
411/// # Arguments
412///
413/// * `nsid` - The string to validate
414///
415/// # Returns
416///
417/// * `true` if the string is a valid NSID
418/// * `false` otherwise
419///
420/// # Examples
421///
422/// ```rust
423/// use slices_lexicon::is_valid_nsid;
424///
425/// assert!(is_valid_nsid("com.example.post"));
426/// assert!(is_valid_nsid("app.bsky.feed.post"));
427/// assert!(!is_valid_nsid("invalid"));
428/// assert!(!is_valid_nsid("com.example"));  // needs at least 3 segments
429/// ```
430pub fn is_valid_nsid(nsid: &str) -> bool {
431    use regex::Regex;
432    // NSID must have at least 3 segments (domain.subdomain.collection)
433    let nsid_regex = Regex::new(
434        r"^[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?){2,}$",
435    )
436    .unwrap();
437    nsid_regex.is_match(nsid)
438}
439
440// WASM-specific code - only compiled when the wasm feature is enabled
441#[cfg(feature = "wasm")]
442mod wasm_bindings {
443    use super::*;
444    use wasm_bindgen::prelude::*;
445    use crate::validation::ValidationContext;
446
447    // When the `console_error_panic_hook` feature is enabled, we can call the
448    // `set_panic_hook` function at least once during initialization, and then
449    // we will get better error messages if our code ever panics.
450    //
451    // For more details see
452    // https://github.com/rustwasm/console_error_panic_hook#readme
453    #[cfg(feature = "console_error_panic_hook")]
454    #[wasm_bindgen(start)]
455    pub fn main() {
456        console_error_panic_hook::set_once();
457    }
458
459    // WASM-exposed validator wrapper using the new validation system
460    #[wasm_bindgen]
461    pub struct WasmLexiconValidator {
462        context: ValidationContext,
463    }
464
465    #[wasm_bindgen]
466    impl WasmLexiconValidator {
467        /// Create a new validator with the given lexicon documents
468        /// Takes a JSON string containing an array of lexicon documents
469        #[wasm_bindgen(constructor)]
470        pub fn new(lexicons_json: &str) -> Result<WasmLexiconValidator, JsValue> {
471            let lexicons: Vec<Value> = serde_json::from_str(lexicons_json)
472                .map_err(|e| JsValue::from_str(&format!("Failed to parse lexicons JSON: {}", e)))?;
473
474            let context = ValidationContext::builder()
475                .with_lexicons(lexicons)
476                .map_err(|e| JsValue::from_str(&format!("Failed to build validation context: {}", e)))?
477                .build()
478                .map_err(|e| JsValue::from_str(&format!("Failed to build validation context: {}", e)))?;
479
480            Ok(WasmLexiconValidator { context })
481        }
482
483        /// Validate a record against its collection's lexicon
484        /// Takes collection name and record as JSON strings
485        #[wasm_bindgen]
486        pub fn validate_record(&self, collection: &str, record_json: &str) -> Result<(), JsValue> {
487            let record: Value = serde_json::from_str(record_json)
488                .map_err(|e| JsValue::from_str(&format!("Failed to parse record JSON: {}", e)))?;
489
490            validate_data_against_schema(&record, &self.get_record_schema(collection)?, &self.context.with_path(collection))
491                .map_err(|e| JsValue::from_str(&e.to_string()))
492        }
493
494        /// Validate lexicon documents and return detailed validation results
495        /// Returns a JSON string containing validation results with enhanced error context
496        #[wasm_bindgen]
497        pub fn validate_lexicons(&self) -> String {
498            let mut results = HashMap::new();
499
500            for (lexicon_id, lexicon_doc) in &self.context.lexicons {
501                let mut lexicon_errors = Vec::new();
502
503                if let Some(defs) = lexicon_doc.defs.as_object() {
504                    for (def_name, def_value) in defs {
505                        let def_context = self.context.with_path(&format!("{}#{}", lexicon_id, def_name));
506
507                        if let Some(type_str) = def_value.get("type").and_then(|t| t.as_str()) {
508                            let validation_result = validate_definition_by_type(def_value, type_str, &def_context);
509
510                            if let Err(error) = validation_result {
511                                lexicon_errors.push(format!("{}#{}: {}", lexicon_id, def_name, error));
512                            }
513                        } else {
514                            lexicon_errors.push(format!("{}#{}: Missing 'type' field", lexicon_id, def_name));
515                        }
516                    }
517                }
518
519                if !lexicon_errors.is_empty() {
520                    results.insert(lexicon_id.clone(), lexicon_errors);
521                }
522            }
523
524            serde_json::to_string(&results).unwrap_or_else(|_| "{}".to_string())
525        }
526
527        /// Check if all references in the lexicon set can be resolved
528        /// Returns a JSON string with any unresolved references
529        #[wasm_bindgen]
530        pub fn check_references(&self) -> String {
531            let mut unresolved_refs = Vec::new();
532
533            for (lexicon_id, lexicon_doc) in &self.context.lexicons {
534                if let Some(defs) = lexicon_doc.defs.as_object() {
535                    for (def_name, def_value) in defs {
536                        collect_unresolved_references(def_value, &format!("{}#{}", lexicon_id, def_name), &self.context, &mut unresolved_refs);
537                    }
538                }
539            }
540
541            serde_json::to_string(&unresolved_refs).unwrap_or_else(|_| "[]".to_string())
542        }
543
544        /// Get detailed error context for validation failures
545        /// Returns enhanced error information with path context
546        #[wasm_bindgen]
547        pub fn get_error_context(&self, path: &str) -> String {
548            let ctx = self.context.with_path(path);
549            serde_json::json!({
550                "path": ctx.path(),
551                "current_lexicon": ctx.current_lexicon_id,
552                "has_circular_reference": !ctx.reference_stack.is_empty(),
553                "reference_stack": ctx.reference_stack
554            }).to_string()
555        }
556
557        // Helper method to get record schema
558        fn get_record_schema(&self, collection: &str) -> Result<Value, JsValue> {
559            let lexicon = self.context.get_lexicon(collection)
560                .ok_or_else(|| JsValue::from_str(&format!("Lexicon not found: {}", collection)))?;
561
562            let main_def = lexicon.defs.as_object()
563                .and_then(|defs| defs.get("main"))
564                .ok_or_else(|| JsValue::from_str(&format!("Missing main definition in lexicon: {}", collection)))?;
565
566            let record_schema = main_def.get("record")
567                .ok_or_else(|| JsValue::from_str(&format!("Missing record schema in lexicon: {}", collection)))?;
568
569            Ok(record_schema.clone())
570        }
571    }
572
573    // Helper function to validate definitions by type
574    fn validate_definition_by_type(def_value: &Value, type_str: &str, ctx: &ValidationContext) -> Result<(), ValidationError> {
575        use crate::validation::Validator;
576
577        match type_str {
578            // Primary types
579            "record" => RecordValidator.validate(def_value, ctx),
580            "query" => QueryValidator.validate(def_value, ctx),
581            "procedure" => ProcedureValidator.validate(def_value, ctx),
582            "subscription" => SubscriptionValidator.validate(def_value, ctx),
583
584            // Field types
585            "object" => ObjectValidator.validate(def_value, ctx),
586            "array" => ArrayValidator.validate(def_value, ctx),
587            "union" => UnionValidator.validate(def_value, ctx),
588            "ref" => RefValidator.validate(def_value, ctx),
589
590            // Primitive types
591            "string" => StringValidator.validate(def_value, ctx),
592            "integer" => IntegerValidator.validate(def_value, ctx),
593            "boolean" => BooleanValidator.validate(def_value, ctx),
594            "bytes" => BytesValidator.validate(def_value, ctx),
595            "blob" => BlobValidator.validate(def_value, ctx),
596            "cid-link" => CidLinkValidator.validate(def_value, ctx),
597            "null" => NullValidator.validate(def_value, ctx),
598
599            // Meta types
600            "token" => TokenValidator.validate(def_value, ctx),
601            "unknown" => UnknownValidator.validate(def_value, ctx),
602
603            _ => Err(ValidationError::InvalidSchema(format!("Unknown type: {}", type_str))),
604        }
605    }
606
607    // Helper function to collect unresolved references
608    fn collect_unresolved_references(value: &Value, path: &str, ctx: &ValidationContext, unresolved: &mut Vec<String>) {
609        match value {
610            Value::Object(obj) => {
611                if let Some(ref_str) = obj.get("$ref").and_then(|r| r.as_str()) {
612                    if ctx.resolve_reference(ref_str).is_err() {
613                        unresolved.push(format!("{}: {}", path, ref_str));
614                    }
615                }
616
617                for (key, val) in obj {
618                    collect_unresolved_references(val, &format!("{}.{}", path, key), ctx, unresolved);
619                }
620            },
621            Value::Array(arr) => {
622                for (i, val) in arr.iter().enumerate() {
623                    collect_unresolved_references(val, &format!("{}[{}]", path, i), ctx, unresolved);
624                }
625            },
626            _ => {}
627        }
628    }
629
630    /// Validate lexicons and return errors without creating a validator instance
631    /// Returns a JSON string containing a map of lexicon ID to error arrays
632    #[wasm_bindgen]
633    pub fn validate_lexicons_and_get_errors(lexicons_json: &str) -> String {
634        match validate_lexicons_from_json(lexicons_json) {
635            Ok(_) => "{}".to_string(), // No errors
636            Err(errors) => serde_json::to_string(&errors).unwrap_or_else(|_| "{}".to_string())
637        }
638    }
639
640    // Helper function to validate lexicons from JSON string
641    fn validate_lexicons_from_json(lexicons_json: &str) -> Result<(), HashMap<String, Vec<String>>> {
642        let lexicons: Vec<Value> = serde_json::from_str(lexicons_json)
643            .map_err(|e| {
644                let mut error_map = HashMap::new();
645                error_map.insert("parse_error".to_string(), vec![format!("Failed to parse lexicons JSON: {}", e)]);
646                error_map
647            })?;
648
649        validate(lexicons)
650    }
651
652    // Export individual validation functions for more granular use
653    #[wasm_bindgen]
654    pub fn validate_string_format(value: &str, format: &str) -> Result<(), JsValue> {
655        use crate::validation::primitive::string::StringValidator;
656        use crate::StringFormat;
657
658        let format_enum = format.parse::<StringFormat>()
659            .map_err(|_| JsValue::from_str(&format!("Unknown format: {}", format)))?;
660
661        let validator = StringValidator;
662
663        // Call the individual format validation methods directly
664        let is_valid = match format_enum {
665            StringFormat::DateTime => validator.is_valid_rfc3339_datetime(value),
666            StringFormat::Uri => validator.is_valid_uri(value),
667            StringFormat::AtUri => validator.is_valid_at_uri(value),
668            StringFormat::Did => validator.is_valid_did(value),
669            StringFormat::Handle => validator.is_valid_handle(value),
670            StringFormat::AtIdentifier => {
671                validator.is_valid_did(value) || validator.is_valid_handle(value)
672            },
673            StringFormat::Nsid => crate::is_valid_nsid(value),
674            StringFormat::Cid => validator.is_valid_cid(value),
675            StringFormat::Language => validator.is_valid_language_tag(value),
676            StringFormat::Tid => validator.is_valid_tid(value),
677            StringFormat::RecordKey => validator.is_valid_record_key(value),
678        };
679
680        if is_valid {
681            Ok(())
682        } else {
683            Err(JsValue::from_str(&format!("Invalid {} format: {}", format, value)))
684        }
685    }
686
687    // Utility function to check if a string is a valid lexicon ID (uses shared implementation)
688    #[wasm_bindgen]
689    pub fn is_valid_nsid(nsid: &str) -> bool {
690        crate::is_valid_nsid(nsid)
691    }
692}
693
694// Re-export WASM bindings when the feature is enabled
695#[cfg(feature = "wasm")]
696pub use wasm_bindings::*;
697
698#[cfg(test)]
699mod test_multiple_errors_example {
700    use super::*;
701    use serde_json::json;
702
703    #[test]
704    fn test_multiple_error_collection_comprehensive() {
705        let lexicons = vec![
706            // First lexicon with multiple errors
707            json!({
708                "lexicon": 1,
709                "id": "com.example.post",
710                "defs": {
711                    "main": {
712                        "type": "record",
713                        "key": "tid",
714                        // Missing required "record" field - ERROR 1
715                    },
716                    "metadata": {
717                        "type": "badtype"  // Invalid type - ERROR 2
718                    },
719                    "config": {
720                        // Missing "type" field entirely - ERROR 3
721                        "description": "Some config"
722                    }
723                }
724            }),
725
726            // Second lexicon with multiple errors
727            json!({
728                "lexicon": 1,
729                "id": "com.example.profile",
730                "defs": {
731                    "main": {
732                        "type": "record",
733                        "key": "tid",
734                        "record": {
735                            "type": "object",
736                            "properties": {
737                                "avatar": {
738                                    "type": "ref",
739                                    "$ref": "com.missing.lexicon#image"  // Invalid reference - ERROR 4
740                                }
741                            }
742                        }
743                    },
744                    "settings": {
745                        "type": "object",
746                        "properties": {
747                            "theme": {
748                                "type": "invalidtype"  // Invalid type - ERROR 5
749                            }
750                        }
751                    }
752                }
753            }),
754
755            // Third lexicon that's completely valid
756            json!({
757                "lexicon": 1,
758                "id": "com.example.valid",
759                "defs": {
760                    "main": {
761                        "type": "record",
762                        "key": "tid",
763                        "record": {
764                            "type": "object",
765                            "properties": {
766                                "text": {"type": "string"}
767                            }
768                        }
769                    }
770                }
771            })
772        ];
773
774        match validate(lexicons) {
775            Ok(_) => panic!("Expected validation errors but validation passed"),
776            Err(errors) => {
777                // Test that we have errors for the expected lexicons
778                assert!(errors.contains_key("com.example.post"), "Should have errors for com.example.post");
779                assert!(errors.contains_key("com.example.profile"), "Should have errors for com.example.profile");
780                assert!(!errors.contains_key("com.example.valid"), "Should NOT have errors for valid lexicon");
781
782                // Test that we collected multiple errors per lexicon (not just the first one)
783                assert_eq!(errors["com.example.post"].len(), 3, "com.example.post should have exactly 3 errors");
784                assert_eq!(errors["com.example.profile"].len(), 2, "com.example.profile should have exactly 2 errors");
785
786                // Test specific error messages exist (verifying all errors were collected)
787                let post_errors = &errors["com.example.post"];
788                assert!(post_errors.iter().any(|e| e.contains("missing 'type' field")),
789                    "Should contain missing type field error");
790                assert!(post_errors.iter().any(|e| e.contains("missing required 'record' field")),
791                    "Should contain missing record field error");
792                assert!(post_errors.iter().any(|e| e.contains("Unknown type: badtype")),
793                    "Should contain unknown type error");
794
795                let profile_errors = &errors["com.example.profile"];
796                assert!(profile_errors.iter().any(|e| e.contains("Unknown schema type 'ref'")),
797                    "Should contain unknown ref type error");
798                assert!(profile_errors.iter().any(|e| e.contains("invalidtype")),
799                    "Should contain invalidtype error");
800
801                // Verify total error count
802                let total_errors: usize = errors.values().map(|v| v.len()).sum();
803                assert_eq!(total_errors, 5, "Should have collected all 5 errors total");
804                assert_eq!(errors.len(), 2, "Should have errors for exactly 2 lexicons");
805            }
806        }
807    }
808
809    #[test]
810    fn test_broken_reference_validation() {
811        let broken_lexicon = json!({
812            "lexicon": 1,
813            "id": "test.broken.ref",
814            "defs": {
815                "main": {
816                    "type": "object",
817                    "properties": {
818                        "items": {
819                            "type": "array",
820                            "items": {
821                                "type": "ref",
822                                "ref": "#nonExistentDef"
823                            }
824                        }
825                    }
826                },
827                "existingDef": {
828                    "type": "string"
829                }
830            }
831        });
832
833        let result = validate(vec![broken_lexicon]);
834
835        // Should fail because #nonExistentDef doesn't exist
836        assert!(result.is_err(), "Validation should fail for broken reference");
837
838        if let Err(errors) = result {
839            assert!(errors.contains_key("test.broken.ref"), "Should have errors for the test lexicon");
840            let error_messages = errors.get("test.broken.ref").unwrap();
841            assert!(!error_messages.is_empty(), "Should have at least one error");
842
843            // Check that at least one error mentions the non-existent reference
844            let has_ref_error = error_messages.iter().any(|msg|
845                msg.contains("non-existent") || msg.contains("nonExistentDef")
846            );
847            assert!(has_ref_error, "Should have error about non-existent reference. Got: {:?}", error_messages);
848        }
849    }
850}
851