Skip to main content

grounddb_codegen/
lib.rs

1//! GroundDB code generation - generates Rust types from schema.yaml at compile time.
2//!
3//! The main entry point is [`generate_from_schema`], which reads a schema.yaml file
4//! and writes a complete Rust source file with typed structs, enums, and store accessors.
5
6mod enum_gen;
7mod generator;
8mod store_gen;
9mod struct_gen;
10pub mod type_utils;
11mod view_gen;
12
13use std::path::Path;
14
15/// Generate Rust types from a schema.yaml file.
16///
17/// Reads the schema at `schema_path`, generates typed Rust code, and writes
18/// the output to `output_path`. This is intended to be called from a `build.rs`
19/// build script.
20///
21/// # Example
22///
23/// ```no_run
24/// // In build.rs:
25/// grounddb_codegen::generate_from_schema("schema.yaml", "src/generated.rs").unwrap();
26/// ```
27pub fn generate_from_schema(
28    schema_path: &str,
29    output_path: &str,
30) -> Result<(), Box<dyn std::error::Error>> {
31    let schema = grounddb::schema::parse_schema(Path::new(schema_path))?;
32    let tokens = generator::generate_all(&schema);
33    let formatted = generator::format_token_stream(&tokens);
34    std::fs::write(output_path, formatted)?;
35    Ok(())
36}
37
38/// Generate Rust types from a schema YAML string.
39///
40/// Like [`generate_from_schema`] but takes the schema content directly
41/// instead of reading from a file. Useful for testing.
42pub fn generate_from_schema_str(
43    schema_yaml: &str,
44) -> Result<String, Box<dyn std::error::Error>> {
45    let schema = grounddb::schema::parse_schema_str(schema_yaml)?;
46    let tokens = generator::generate_all(&schema);
47    let formatted = generator::format_token_stream(&tokens);
48    Ok(formatted)
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    const TEST_SCHEMA: &str = r#"
56types:
57  address:
58    street: { type: string, required: true }
59    city: { type: string, required: true }
60    state: { type: string }
61    zip: { type: string }
62
63collections:
64  users:
65    path: "users/{name}.md"
66    fields:
67      name: { type: string, required: true }
68      email: { type: string, required: true }
69      role: { type: string, enum: [admin, member, guest], default: member }
70      address: { type: address }
71    additional_properties: false
72    strict: true
73    on_delete: error
74
75  posts:
76    path: "posts/{status}/{date:YYYY-MM-DD}-{title}.md"
77    fields:
78      title: { type: string, required: true }
79      author_id: { type: ref, target: users, required: true }
80      date: { type: date, required: true }
81      tags: { type: list, items: string }
82      status: { type: string, enum: [draft, published, archived], default: draft }
83    content: true
84    additional_properties: false
85    strict: true
86
87  comments:
88    path: "comments/{parent:type}/{parent:id}/{user:id}.md"
89    fields:
90      user: { type: ref, target: users, required: true }
91      parent: { type: ref, target: [posts, comments], required: true }
92    content: true
93
94  events:
95    path: "events/{id}.md"
96    fields:
97      type: { type: string, required: true }
98      severity: { type: string, enum: [info, warn, error], default: info }
99      payload: { type: object }
100    additional_properties: true
101    strict: false
102
103views:
104  post_feed:
105    query: |
106      SELECT p.title, p.date, p.tags, u.name AS author_name, u.id AS author_id
107      FROM posts p
108      JOIN users u ON p.author_id = u.id
109      WHERE p.status = 'published'
110      ORDER BY p.date DESC
111      LIMIT 100
112    materialize: true
113    buffer: 2x
114
115  user_lookup:
116    query: |
117      SELECT id, name, email, role
118      FROM users
119      ORDER BY name ASC
120    materialize: true
121
122  recent_activity:
123    query: |
124      SELECT id, title, modified_at, status
125      FROM posts
126      ORDER BY modified_at DESC
127      LIMIT 50
128    materialize: true
129    buffer: 2x
130
131  post_comments:
132    type: query
133    query: |
134      SELECT c.id, c.created_at, c.content, u.name AS commenter_name
135      FROM comments c
136      JOIN users u ON c.user = u.id
137      WHERE c.parent = :post_id
138      ORDER BY c.created_at ASC
139    params:
140      post_id: { type: string }
141"#;
142
143    #[test]
144    fn test_generate_from_schema_str_full() {
145        let result = generate_from_schema_str(TEST_SCHEMA);
146        assert!(result.is_ok(), "Generation failed: {:?}", result.err());
147
148        let code = result.unwrap();
149
150        // Verify it's valid Rust
151        assert!(
152            syn::parse_file(&code).is_ok(),
153            "Generated code is not valid Rust:\n{}",
154            &code[..code.len().min(2000)]
155        );
156
157        // Enum types
158        assert!(code.contains("UserRole"), "Missing UserRole enum");
159        assert!(code.contains("PostStatus"), "Missing PostStatus enum");
160        assert!(code.contains("EventSeverity"), "Missing EventSeverity enum");
161
162        // Enum variants
163        assert!(code.contains("Admin"), "Missing Admin variant");
164        assert!(code.contains("Member"), "Missing Member variant");
165        assert!(code.contains("Guest"), "Missing Guest variant");
166        assert!(code.contains("Draft"), "Missing Draft variant");
167        assert!(code.contains("Published"), "Missing Published variant");
168
169        // Default impls
170        assert!(
171            code.contains("impl Default for UserRole"),
172            "Missing UserRole Default impl"
173        );
174        assert!(
175            code.contains("impl Default for PostStatus"),
176            "Missing PostStatus Default impl"
177        );
178
179        // Document structs
180        assert!(code.contains("pub struct User"), "Missing User struct");
181        assert!(code.contains("pub struct Post"), "Missing Post struct");
182        assert!(code.contains("pub struct Comment"), "Missing Comment struct");
183        assert!(code.contains("pub struct Event"), "Missing Event struct");
184
185        // Reusable types
186        assert!(code.contains("pub struct Address"), "Missing Address struct");
187
188        // Polymorphic ref
189        assert!(code.contains("ParentRef"), "Missing ParentRef enum");
190
191        // Partial structs
192        assert!(code.contains("pub struct UserPartial"), "Missing UserPartial");
193        assert!(code.contains("pub struct PostPartial"), "Missing PostPartial");
194        assert!(code.contains("pub struct CommentPartial"), "Missing CommentPartial");
195        assert!(code.contains("pub struct EventPartial"), "Missing EventPartial");
196
197        // View row structs
198        assert!(code.contains("PostFeedRow"), "Missing PostFeedRow");
199        assert!(code.contains("UserLookupRow"), "Missing UserLookupRow");
200        assert!(code.contains("RecentActivityRow"), "Missing RecentActivityRow");
201        assert!(code.contains("PostCommentsRow"), "Missing PostCommentsRow");
202
203        // View params
204        assert!(code.contains("PostCommentsParams"), "Missing PostCommentsParams");
205
206        // Store extension
207        assert!(code.contains("StoreExt"), "Missing StoreExt trait");
208        assert!(code.contains("fn users"), "Missing users accessor");
209        assert!(code.contains("fn posts"), "Missing posts accessor");
210        assert!(code.contains("fn comments"), "Missing comments accessor");
211        assert!(code.contains("fn events"), "Missing events accessor");
212    }
213
214    #[test]
215    fn test_generate_minimal_schema() {
216        let schema = r#"
217collections:
218  items:
219    path: "items/{name}.md"
220    fields:
221      name: { type: string, required: true }
222"#;
223        let result = generate_from_schema_str(schema);
224        assert!(result.is_ok(), "Generation failed: {:?}", result.err());
225
226        let code = result.unwrap();
227        assert!(syn::parse_file(&code).is_ok(), "Not valid Rust");
228        assert!(code.contains("pub struct Item"));
229        assert!(code.contains("pub struct ItemPartial"));
230    }
231
232    #[test]
233    fn test_generate_all_field_types() {
234        let schema = r#"
235collections:
236  records:
237    path: "records/{id}.md"
238    fields:
239      name: { type: string, required: true }
240      count: { type: number, required: true }
241      active: { type: boolean, required: true }
242      birthday: { type: date }
243      updated: { type: datetime }
244      tags: { type: list, items: string }
245      metadata: { type: object }
246      owner: { type: ref, target: records }
247"#;
248        let result = generate_from_schema_str(schema);
249        assert!(result.is_ok(), "Generation failed: {:?}", result.err());
250
251        let code = result.unwrap();
252        assert!(syn::parse_file(&code).is_ok(), "Not valid Rust:\n{}", &code[..code.len().min(2000)]);
253
254        assert!(code.contains("String"), "Missing String type");
255        assert!(code.contains("f64"), "Missing f64 type");
256        assert!(code.contains("bool"), "Missing bool type");
257        assert!(code.contains("NaiveDate"), "Missing NaiveDate type");
258        assert!(code.contains("DateTime"), "Missing DateTime type");
259        assert!(code.contains("Vec"), "Missing Vec type");
260        assert!(code.contains("serde_json"), "Missing serde_json::Value type");
261    }
262
263    #[test]
264    fn test_rust_keyword_field_names() {
265        let schema = r#"
266collections:
267  events:
268    path: "events/{id}.md"
269    fields:
270      type: { type: string, required: true }
271      ref: { type: string }
272"#;
273        let result = generate_from_schema_str(schema);
274        assert!(result.is_ok(), "Generation failed: {:?}", result.err());
275
276        let code = result.unwrap();
277        assert!(syn::parse_file(&code).is_ok(), "Not valid Rust:\n{}", &code[..code.len().min(2000)]);
278    }
279}