jacquard_lexicon/
schema.rs

1//! # Lexicon Schema Generation
2//!
3//! This module provides traits and utilities for generating AT Protocol lexicon schemas
4//! from Rust types. This is the reverse direction from the usual lexicon→Rust codegen.
5//!
6//! ## Use Cases
7//!
8//! - **Rapid prototyping**: Define types in Rust, generate schemas automatically
9//! - **Custom lexicons**: Easy creation of third-party lexicons
10//! - **Documentation**: Keep types and schemas in sync
11//! - **Runtime introspection**: Access schema metadata at runtime
12//!
13//! ## Usage
14//!
15//! ### Derive Macro
16//!
17//! Use `#[derive(LexiconSchema)]` to automatically generate schemas:
18//!
19//! ```rust,ignore
20//! use jacquard_lexicon::schema::LexiconSchema;
21//! use jacquard_common::CowStr;
22//!
23//! #[derive(LexiconSchema)]
24//! #[lexicon(nsid = "app.bsky.feed.post", record, key = "tid")]
25//! struct Post<'a> {
26//!     #[lexicon(max_graphemes = 300, max_length = 3000)]
27//!     text: CowStr<'a>,
28//!     created_at: Datetime,
29//! }
30//! ```
31//!
32//! #### Constraint Attributes
33//!
34//! - **Field constraints**: `max_length`, `max_graphemes`, `min_length`, `min_graphemes`
35//! - **Array constraints**: `max_items`, `min_items` (for the array itself)
36//! - **Item constraints**: `item_max_length`, `item_max_graphemes`, etc. (for array items)
37//! - **Integer constraints**: `minimum`, `maximum`
38//! - **Refs**: `ref = "nsid"` to explicitly reference another type
39//! - **Unions**: `union` to mark a field as a union type
40//!
41//! #### Fragments
42//!
43//! Multiple types can share the same NSID using fragments:
44//!
45//! ```rust,ignore
46//! #[derive(LexiconSchema)]
47//! #[lexicon(nsid = "app.bsky.feed.post", fragment = "textSlice")]
48//! struct TextSlice {
49//!     start: i64,
50//!     end: i64,
51//! }
52//! ```
53//!
54//! ### Runtime Registry
55//!
56//! Access complete schemas (with all fragments merged) via the global registry:
57//!
58//! ```rust,ignore
59//! let registry = jacquard_lexicon::schema::global_registry();
60//! let post_doc = registry.get("app.bsky.feed.post").expect("schema exists");
61//!
62//! // The doc contains all defs: main, textSlice, entity, replyRef, etc.
63//! for (def_name, def) in &post_doc.defs {
64//!     println!("Def: {}", def_name);
65//! }
66//! ```
67//!
68//! ## Design Pattern
69//!
70//! - **Trait-based**: Types implement `LexiconSchema` trait
71//! - **Inventory-based discovery**: Runtime schema registry via `inventory` crate
72//! - **Fragment merging**: Multiple types with same NSID have their defs merged
73//! - **Const literals**: Generated code emits schema as const data
74//! - **Validation**: Runtime constraint checking via `validate()` method
75
76pub mod builder;
77#[cfg(feature = "codegen")]
78pub mod from_ast;
79#[cfg(feature = "codegen")]
80pub mod type_mapping;
81
82use crate::lexicon::LexiconDoc;
83
84/// Trait for types that can generate lexicon schemas
85pub trait LexiconSchema {
86    /// The NSID for this type's primary definition
87    ///
88    /// For fragments, this is the base NSID (without `#fragment`).
89    fn nsid() -> &'static str;
90
91    /// The definition name within the lexicon document
92    ///
93    /// Returns "main" for the primary definition, or the fragment name for other defs.
94    /// For example, in a lexicon with multiple defs like `pub.leaflet.poll.definition`,
95    /// the main type returns "main" while the `Option` type returns "option".
96    fn def_name() -> &'static str {
97        "main"
98    }
99
100    /// The schema ID for this type
101    ///
102    /// Defaults to NSID. Override for fragments to include `#fragment` suffix.
103    fn schema_id() -> jacquard_common::CowStr<'static> {
104        jacquard_common::CowStr::new_static(Self::nsid())
105    }
106
107    /// Whether this type should be inlined vs referenced
108    ///
109    /// - `false` (default): Type becomes a def, references use `{"type": "ref", "ref": "nsid"}`
110    /// - `true`: Type's schema is inlined directly into parent
111    ///
112    /// Recursive types MUST return `false` to avoid infinite expansion.
113    fn inline_schema() -> bool {
114        false
115    }
116
117    /// Generate the lexicon document for this type
118    ///
119    /// Returns the complete lexicon schema for this type. Nested refs are resolved
120    /// at runtime via the inventory-based registry.
121    fn lexicon_doc() -> LexiconDoc<'static>;
122
123    /// Validate an instance against lexicon constraints
124    ///
125    /// Checks runtime constraints like `max_length`, `max_graphemes`, `minimum`, etc.
126    /// Returns `Ok(())` if valid, `Err` with details if invalid.
127    fn validate(&self) -> Result<(), crate::validation::ConstraintError> {
128        // Default impl: no constraints to check
129        Ok(())
130    }
131}
132
133/// Registry entry for schema discovery via inventory
134///
135/// Generated automatically by `#[derive(LexiconSchema)]` to enable runtime schema discovery.
136pub struct LexiconSchemaRef {
137    /// The NSID for this schema
138    pub nsid: &'static str,
139    /// The def name within the lexicon (e.g., "main", "textSlice")
140    pub def_name: &'static str,
141    /// Function that generates the lexicon document
142    pub provider: fn() -> crate::lexicon::LexiconDoc<'static>,
143}
144
145inventory::collect!(LexiconSchemaRef);
146
147/// Registry of lexicon schemas
148///
149/// Collects schemas from inventory at construction and supports runtime insertion.
150#[derive(Debug, Clone)]
151pub struct SchemaRegistry {
152    /// Schema documents indexed by NSID (concurrent access safe)
153    schemas: dashmap::DashMap<jacquard_common::smol_str::SmolStr, crate::lexicon::LexiconDoc<'static>>,
154}
155
156impl SchemaRegistry {
157    /// Build registry from inventory-collected schemas
158    pub fn from_inventory() -> Self {
159        use jacquard_common::smol_str::ToSmolStr;
160        let schemas = dashmap::DashMap::new();
161
162        for entry in inventory::iter::<LexiconSchemaRef> {
163            let doc = (entry.provider)();
164
165            // Get existing doc or create new one
166            let mut doc_entry = schemas.entry(entry.nsid.to_smolstr()).or_insert_with(|| {
167                crate::lexicon::LexiconDoc {
168                    lexicon: crate::lexicon::Lexicon::Lexicon1,
169                    id: jacquard_common::CowStr::new_static(entry.nsid),
170                    revision: None,
171                    description: None,
172                    defs: Default::default(),
173                }
174            });
175
176            // Merge the defs from this schema
177            // Each type's lexicon_doc() now returns a doc with the def under its proper name
178            for (def_name, def) in doc.defs {
179                doc_entry.defs.insert(def_name, def);
180            }
181        }
182
183        Self { schemas }
184    }
185
186    /// Create an empty registry
187    pub fn new() -> Self {
188        Self {
189            schemas: dashmap::DashMap::new(),
190        }
191    }
192
193    /// Get schema by NSID
194    ///
195    /// IMPORTANT: Clone the returned schema immediately to avoid holding DashMap ref
196    pub fn get(&self, nsid: &str) -> Option<crate::lexicon::LexiconDoc<'static>> {
197        self.schemas.get(nsid).map(|doc| doc.clone())
198    }
199
200    /// Insert or update a schema (for runtime schema loading)
201    pub fn insert(&self, nsid: jacquard_common::smol_str::SmolStr, doc: crate::lexicon::LexiconDoc<'static>) {
202        self.schemas.insert(nsid, doc);
203    }
204
205    /// Get specific def from a schema
206    ///
207    /// IMPORTANT: Returns cloned def to avoid holding DashMap ref
208    pub fn get_def(
209        &self,
210        nsid: &str,
211        def_name: &str,
212    ) -> Option<crate::lexicon::LexUserType<'static>> {
213        // Clone immediately to release DashMap ref before returning
214        self.schemas
215            .get(nsid)
216            .and_then(|doc| doc.defs.get(def_name).cloned())
217    }
218}
219
220impl Default for SchemaRegistry {
221    fn default() -> Self {
222        Self::from_inventory()
223    }
224}
225
226/// Global schema registry built from inventory
227pub fn global_registry() -> &'static SchemaRegistry {
228    static REGISTRY: std::sync::LazyLock<SchemaRegistry> = std::sync::LazyLock::new(SchemaRegistry::from_inventory);
229    &REGISTRY
230}
231
232#[cfg(test)]
233mod tests {
234    use crate::validation::{ConstraintError, ValidationPath};
235
236    #[test]
237    fn test_validation_max_length() {
238        let err = ConstraintError::MaxLength {
239            path: ValidationPath::from_field("text"),
240            max: 100,
241            actual: 150,
242        };
243        assert!(err.to_string().contains("exceeds max length"));
244    }
245
246    #[test]
247    fn test_validation_max_graphemes() {
248        let err = ConstraintError::MaxGraphemes {
249            path: ValidationPath::from_field("text"),
250            max: 50,
251            actual: 75,
252        };
253        assert!(err.to_string().contains("exceeds max graphemes"));
254    }
255}