ormada_schema/
parser.rs

1//! Source file parsing for Ormada models and schemas
2//!
3//! This module parses Rust source files to extract schema information from
4//! `#[ormada_model]` and `#[ormada_schema]` attributes.
5
6use std::path::Path;
7use syn::{Attribute, Fields, Item, ItemStruct, Type};
8use walkdir::WalkDir;
9
10use crate::types::*;
11
12/// Result type for parser operations
13pub type ParseResult<T> = Result<T, ParseError>;
14
15/// Errors that can occur during parsing
16#[derive(Debug, Clone)]
17pub enum ParseError {
18    /// Failed to read file
19    IoError(String),
20    /// Failed to parse Rust syntax
21    SyntaxError { file: String, message: String },
22    /// Invalid attribute configuration
23    InvalidAttribute {
24        file: String,
25        struct_name: String,
26        message: String,
27    },
28}
29
30impl std::fmt::Display for ParseError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::IoError(msg) => write!(f, "IO error: {msg}"),
34            Self::SyntaxError { file, message } => {
35                write!(f, "Syntax error in {file}: {message}")
36            }
37            Self::InvalidAttribute { file, struct_name, message } => {
38                write!(f, "Invalid attribute on {struct_name} in {file}: {message}")
39            }
40        }
41    }
42}
43
44impl std::error::Error for ParseError {}
45
46/// Configuration for source discovery
47#[derive(Debug, Clone)]
48pub struct DiscoveryConfig {
49    /// Paths to include in scanning
50    pub include_paths: Vec<String>,
51    /// Paths to exclude from scanning
52    pub exclude_paths: Vec<String>,
53    /// Skip models with `migrate = false`
54    pub skip_non_migratable: bool,
55    /// Skip models inside `#[cfg(test)]`
56    pub skip_test_models: bool,
57}
58
59impl Default for DiscoveryConfig {
60    fn default() -> Self {
61        Self {
62            include_paths: vec!["src".to_string()],
63            exclude_paths: vec!["tests".to_string(), "examples".to_string(), "benches".to_string()],
64            skip_non_migratable: true,
65            skip_test_models: true,
66        }
67    }
68}
69
70/// Discover all models from source files
71pub fn discover_models(
72    project_root: &Path,
73    config: &DiscoveryConfig,
74) -> ParseResult<Vec<TableSchema>> {
75    let mut schemas = Vec::new();
76
77    for include_path in &config.include_paths {
78        let search_path = project_root.join(include_path);
79        if !search_path.exists() {
80            continue;
81        }
82
83        for entry in WalkDir::new(&search_path)
84            .into_iter()
85            .filter_map(Result::ok)
86            .filter(|e| e.path().extension().is_some_and(|ext| ext == "rs"))
87        {
88            let path = entry.path();
89
90            // Check exclusions
91            let path_str = path.to_string_lossy();
92            if config.exclude_paths.iter().any(|ex| path_str.contains(ex)) {
93                continue;
94            }
95
96            let file_schemas = parse_file(path, config)?;
97            schemas.extend(file_schemas);
98        }
99    }
100
101    Ok(schemas)
102}
103
104/// Parse a single Rust file for model definitions
105pub fn parse_file(path: &Path, config: &DiscoveryConfig) -> ParseResult<Vec<TableSchema>> {
106    let content = std::fs::read_to_string(path)
107        .map_err(|e| ParseError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
108
109    parse_source(&content, path.to_string_lossy().as_ref(), config)
110}
111
112/// Parse Rust source code for model definitions
113pub fn parse_source(
114    source: &str,
115    file_name: &str,
116    config: &DiscoveryConfig,
117) -> ParseResult<Vec<TableSchema>> {
118    let file = syn::parse_file(source).map_err(|e| ParseError::SyntaxError {
119        file: file_name.to_string(),
120        message: e.to_string(),
121    })?;
122
123    let mut schemas = Vec::new();
124
125    for item in file.items {
126        if let Item::Struct(item_struct) = item {
127            // Check for #[ormada_model] or #[ormada_schema]
128            if let Some(schema) = parse_ormada_struct(&item_struct, file_name, config)? {
129                schemas.push(schema);
130            }
131        }
132    }
133
134    Ok(schemas)
135}
136
137/// Parse a struct with #[ormada_model] or #[ormada_schema] attribute
138fn parse_ormada_struct(
139    item: &ItemStruct,
140    file_name: &str,
141    config: &DiscoveryConfig,
142) -> ParseResult<Option<TableSchema>> {
143    // Check for #[cfg(test)] if configured to skip
144    if config.skip_test_models && has_cfg_test(&item.attrs) {
145        return Ok(None);
146    }
147
148    // Look for ormada_model or ormada_schema attribute
149    let model_attr = find_ormada_attr(&item.attrs, "ormada_model");
150    let schema_attr = find_ormada_attr(&item.attrs, "ormada_schema");
151
152    let attr = match (model_attr, schema_attr) {
153        (Some(a), _) => a,
154        (_, Some(a)) => a,
155        (None, None) => return Ok(None),
156    };
157
158    // Parse attribute arguments
159    let attr_config = parse_model_attr(attr, file_name, &item.ident.to_string())?;
160
161    // Check migrate flag
162    if config.skip_non_migratable && !attr_config.migrate {
163        return Ok(None);
164    }
165
166    // Parse fields
167    let fields = match &item.fields {
168        Fields::Named(named) => &named.named,
169        _ => {
170            return Err(ParseError::InvalidAttribute {
171                file: file_name.to_string(),
172                struct_name: item.ident.to_string(),
173                message: "Only structs with named fields are supported".to_string(),
174            });
175        }
176    };
177
178    let mut table = TableSchema::new(&attr_config.table_name);
179    table.migration_id = attr_config.migration_id;
180
181    for field in fields {
182        let field_name = field.ident.as_ref().map(|i| i.to_string()).unwrap_or_default();
183        let field_type = type_to_string(&field.ty);
184        let is_nullable = ColumnType::is_option_type(&field_type);
185        let column_type = ColumnType::from_rust_type(&field_type);
186
187        let mut column = ColumnSchema::new(&field_name, column_type);
188        column.nullable = is_nullable;
189
190        // Parse field attributes
191        parse_field_attrs(&mut column, &mut table, &field.attrs, &field_name)?;
192
193        // Track primary key
194        if column.primary_key {
195            table.primary_key.push(field_name.clone());
196        }
197
198        table.add_column(column);
199    }
200
201    Ok(Some(table))
202}
203
204/// Parsed model/schema attribute configuration
205#[derive(Debug, Default)]
206struct ModelAttrConfig {
207    table_name: String,
208    migration_id: Option<String>,
209    after: Option<String>,
210    extends: Option<String>,
211    migrate: bool,
212}
213
214/// Parse #[ormada_model(...)] or #[ormada_schema(...)] attribute
215fn parse_model_attr(
216    attr: &Attribute,
217    file_name: &str,
218    struct_name: &str,
219) -> ParseResult<ModelAttrConfig> {
220    let mut config = ModelAttrConfig { migrate: true, ..Default::default() };
221
222    let meta_list = match &attr.meta {
223        syn::Meta::List(list) => list,
224        _ => {
225            return Err(ParseError::InvalidAttribute {
226                file: file_name.to_string(),
227                struct_name: struct_name.to_string(),
228                message: "Expected attribute with arguments".to_string(),
229            });
230        }
231    };
232
233    // Parse the token stream manually
234    let tokens = meta_list.tokens.to_string();
235
236    // Simple key=value parsing
237    for part in tokens.split(',') {
238        let part = part.trim();
239        if let Some((key, value)) = part.split_once('=') {
240            let key = key.trim();
241            let value = value.trim().trim_matches('"');
242
243            match key {
244                "table" => config.table_name = value.to_string(),
245                "migration" => config.migration_id = Some(value.to_string()),
246                "after" => config.after = Some(value.to_string()),
247                "extends" => config.extends = Some(value.to_string()),
248                "migrate" => config.migrate = value != "false",
249                _ => {}
250            }
251        }
252    }
253
254    if config.table_name.is_empty() {
255        return Err(ParseError::InvalidAttribute {
256            file: file_name.to_string(),
257            struct_name: struct_name.to_string(),
258            message: "Missing required 'table' attribute".to_string(),
259        });
260    }
261
262    Ok(config)
263}
264
265/// Parse field attributes and update column/table accordingly
266fn parse_field_attrs(
267    column: &mut ColumnSchema,
268    table: &mut TableSchema,
269    attrs: &[Attribute],
270    field_name: &str,
271) -> ParseResult<()> {
272    for attr in attrs {
273        let path = attr.path();
274        let attr_name = path.get_ident().map(|i| i.to_string()).unwrap_or_default();
275
276        match attr_name.as_str() {
277            "primary_key" => {
278                column.primary_key = true;
279                column.auto_increment = !has_attr_arg(attr, "auto_increment", "false");
280            }
281            "foreign_key" => {
282                if let Some((ref_table, on_delete)) = parse_foreign_key_attr(attr) {
283                    let fk =
284                        ForeignKeySchema::new(field_name, &ref_table, "id").on_delete(on_delete);
285                    table.foreign_keys.push(fk);
286                }
287            }
288            "index" => {
289                column.indexed = true;
290                column.index_name = get_attr_string_arg(attr, "name");
291            }
292            "unique" => {
293                column.unique = true;
294            }
295            "max_length" => {
296                if let Some(len) = get_attr_int_arg(attr) {
297                    column.max_length = Some(len as u32);
298                    // Update column type if it's a string
299                    if matches!(column.column_type, ColumnType::String(_)) {
300                        column.column_type = ColumnType::String(Some(len as u32));
301                    }
302                }
303            }
304            "min_length" => {
305                if let Some(len) = get_attr_int_arg(attr) {
306                    column.min_length = Some(len as u32);
307                }
308            }
309            "range" => {
310                column.range = parse_range_attr(attr);
311            }
312            "default" => {
313                column.default = get_attr_value_arg(attr);
314            }
315            "nullable" => {
316                column.nullable = true;
317            }
318            "soft_delete" => {
319                column.soft_delete = true;
320            }
321            "auto_now" | "auto_now_add" => {
322                // These don't affect schema, just runtime behavior
323            }
324            "rename" => {
325                // For delta migrations: #[rename(from = "old_name")]
326                // The 'to' is inferred from the field name
327                if let Some(from) = get_attr_string_arg(attr, "from") {
328                    column.renamed_from = Some(from);
329                }
330            }
331            "drop" => {
332                column.dropped = true;
333            }
334            _ => {}
335        }
336    }
337
338    Ok(())
339}
340
341/// Check if attributes contain #[cfg(test)]
342fn has_cfg_test(attrs: &[Attribute]) -> bool {
343    attrs.iter().any(|attr| {
344        if attr.path().is_ident("cfg") {
345            let tokens = attr.meta.to_token_stream().to_string();
346            tokens.contains("test")
347        } else {
348            false
349        }
350    })
351}
352
353/// Find an attribute by name
354fn find_ormada_attr<'a>(attrs: &'a [Attribute], name: &str) -> Option<&'a Attribute> {
355    attrs.iter().find(|attr| attr.path().is_ident(name))
356}
357
358/// Convert syn::Type to string representation
359fn type_to_string(ty: &Type) -> String {
360    quote::quote!(#ty).to_string().replace(' ', "")
361}
362
363/// Check if attribute has a specific argument with a value
364fn has_attr_arg(attr: &Attribute, key: &str, value: &str) -> bool {
365    let tokens = attr.meta.to_token_stream().to_string();
366    tokens.contains(&format!("{key} = {value}")) || tokens.contains(&format!("{key}={value}"))
367}
368
369/// Get string argument from attribute
370fn get_attr_string_arg(attr: &Attribute, key: &str) -> Option<String> {
371    let tokens = attr.meta.to_token_stream().to_string();
372
373    // Look for key = "value" pattern
374    for part in tokens.split(',') {
375        let part = part.trim();
376        if let Some((k, v)) = part.split_once('=') {
377            if k.trim() == key {
378                return Some(v.trim().trim_matches('"').to_string());
379            }
380        }
381    }
382    None
383}
384
385/// Get integer argument from attribute (for #[max_length(200)])
386fn get_attr_int_arg(attr: &Attribute) -> Option<i64> {
387    let tokens = attr.meta.to_token_stream().to_string();
388
389    // Look for pattern like max_length(200)
390    if let Some(start) = tokens.find('(') {
391        if let Some(end) = tokens.find(')') {
392            let inner = &tokens[start + 1..end];
393            return inner.trim().parse().ok();
394        }
395    }
396    None
397}
398
399/// Get value argument from attribute
400fn get_attr_value_arg(attr: &Attribute) -> Option<String> {
401    let tokens = attr.meta.to_token_stream().to_string();
402
403    if let Some(start) = tokens.find('(') {
404        if let Some(end) = tokens.rfind(')') {
405            let inner = &tokens[start + 1..end];
406            return Some(inner.trim().trim_matches('"').to_string());
407        }
408    }
409    None
410}
411
412/// Parse #[foreign_key(Entity)] or #[foreign_key(Entity, on_delete = Cascade)]
413fn parse_foreign_key_attr(attr: &Attribute) -> Option<(String, OnDeleteAction)> {
414    let tokens = attr.meta.to_token_stream().to_string();
415
416    // Extract content between parentheses
417    let start = tokens.find('(')?;
418    let end = tokens.rfind(')')?;
419    let inner = &tokens[start + 1..end];
420
421    let parts: Vec<&str> = inner.split(',').collect();
422
423    // First part is the entity path (e.g., "crate::server::models::author::Author")
424    let entity_path = parts.first()?.trim();
425
426    // Extract just the entity name from the path (last segment)
427    let entity_name = entity_path.rsplit("::").next().unwrap_or(entity_path).trim();
428
429    // Convert entity name to table name (snake_case + 's')
430    let table_name = to_table_name(entity_name);
431
432    // Look for on_delete
433    let mut on_delete = OnDeleteAction::NoAction;
434    for part in parts.iter().skip(1) {
435        let part = part.trim();
436        if let Some((key, value)) = part.split_once('=') {
437            if key.trim() == "on_delete" {
438                on_delete = OnDeleteAction::from_str(value.trim());
439            }
440        }
441    }
442
443    Some((table_name, on_delete))
444}
445
446/// Parse #[range(min = 0, max = 100)]
447fn parse_range_attr(attr: &Attribute) -> Option<RangeConstraint> {
448    let tokens = attr.meta.to_token_stream().to_string();
449
450    let mut min = None;
451    let mut max = None;
452
453    for part in tokens.split(',') {
454        let part = part.trim();
455        if let Some((key, value)) = part.split_once('=') {
456            let key = key.trim().trim_start_matches("range(").trim_start_matches('(');
457            let value = value.trim().trim_end_matches(')');
458
459            match key {
460                "min" => min = value.parse().ok(),
461                "max" => max = value.parse().ok(),
462                _ => {}
463            }
464        }
465    }
466
467    if min.is_some() || max.is_some() {
468        Some(RangeConstraint { min, max })
469    } else {
470        None
471    }
472}
473
474/// Convert PascalCase entity name to snake_case table name (pluralized)
475fn to_table_name(entity: &str) -> String {
476    use heck::ToSnakeCase as HeckSnakeCase;
477    use inflector::Inflector;
478
479    let snake = HeckSnakeCase::to_snake_case(entity);
480    snake.to_plural()
481}
482
483use quote::ToTokens;
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_parse_simple_model() {
491        let source = r#"
492            use ormada::prelude::*;
493            
494            #[ormada_model(table = "books")]
495            pub struct Book {
496                #[primary_key]
497                pub id: i32,
498                
499                #[max_length(200)]
500                pub title: String,
501                
502                pub published: bool,
503            }
504        "#;
505
506        let config = DiscoveryConfig::default();
507        let schemas = parse_source(source, "test.rs", &config).unwrap();
508
509        assert_eq!(schemas.len(), 1);
510        let table = &schemas[0];
511        assert_eq!(table.name, "books");
512        assert_eq!(table.columns.len(), 3);
513
514        let id_col = table.find_column("id").unwrap();
515        assert!(id_col.primary_key);
516
517        let title_col = table.find_column("title").unwrap();
518        assert_eq!(title_col.max_length, Some(200));
519    }
520
521    #[test]
522    fn test_parse_model_with_foreign_key() {
523        let source = r#"
524            #[ormada_model(table = "books")]
525            pub struct Book {
526                #[primary_key]
527                pub id: i32,
528                
529                #[foreign_key(Author)]
530                pub author_id: i32,
531            }
532        "#;
533
534        let config = DiscoveryConfig::default();
535        let schemas = parse_source(source, "test.rs", &config).unwrap();
536
537        assert_eq!(schemas.len(), 1);
538        let table = &schemas[0];
539        assert_eq!(table.foreign_keys.len(), 1);
540
541        let fk = &table.foreign_keys[0];
542        assert_eq!(fk.column, "author_id");
543        assert_eq!(fk.references_table, "authors");
544    }
545
546    #[test]
547    fn test_parse_schema_with_migration() {
548        let source = r#"
549            #[ormada_schema(table = "books", migration = "001_initial")]
550            pub struct Book {
551                #[primary_key]
552                pub id: i32,
553                pub title: String,
554            }
555        "#;
556
557        let config = DiscoveryConfig::default();
558        let schemas = parse_source(source, "test.rs", &config).unwrap();
559
560        assert_eq!(schemas.len(), 1);
561        let table = &schemas[0];
562        assert_eq!(table.migration_id, Some("001_initial".to_string()));
563    }
564
565    #[test]
566    fn test_skip_migrate_false() {
567        let source = r#"
568            #[ormada_model(table = "test_books", migrate = false)]
569            pub struct TestBook {
570                #[primary_key]
571                pub id: i32,
572            }
573        "#;
574
575        let config = DiscoveryConfig::default();
576        let schemas = parse_source(source, "test.rs", &config).unwrap();
577
578        assert!(schemas.is_empty());
579    }
580
581    #[test]
582    fn test_parse_foreign_key_with_full_path() {
583        let source = r#"
584            #[ormada_model(table = "books")]
585            pub struct Book {
586                #[primary_key]
587                pub id: i32,
588                
589                #[foreign_key(crate::server::models::author::Author, on_delete = Cascade)]
590                pub author_id: i32,
591            }
592        "#;
593
594        let config = DiscoveryConfig::default();
595        let schemas = parse_source(source, "test.rs", &config).unwrap();
596
597        assert_eq!(schemas.len(), 1);
598        let table = &schemas[0];
599        assert_eq!(table.foreign_keys.len(), 1);
600
601        let fk = &table.foreign_keys[0];
602        assert_eq!(fk.column, "author_id");
603        // Should extract just "Author" from the full path and convert to "authors"
604        assert_eq!(fk.references_table, "authors");
605        assert_eq!(fk.on_delete, OnDeleteAction::Cascade);
606    }
607
608    #[test]
609    fn test_parse_foreign_key_simple_name() {
610        let source = r#"
611            #[ormada_model(table = "books")]
612            pub struct Book {
613                #[primary_key]
614                pub id: i32,
615                
616                #[foreign_key(Author)]
617                pub author_id: i32,
618            }
619        "#;
620
621        let config = DiscoveryConfig::default();
622        let schemas = parse_source(source, "test.rs", &config).unwrap();
623
624        let fk = &schemas[0].foreign_keys[0];
625        assert_eq!(fk.references_table, "authors");
626        assert_eq!(fk.on_delete, OnDeleteAction::NoAction);
627    }
628
629    #[test]
630    fn test_parse_datetime_fields() {
631        let source = r#"
632            #[ormada_model(table = "posts")]
633            pub struct Post {
634                #[primary_key]
635                pub id: i32,
636                
637                pub created_at: ormada::prelude::DateTimeWithTimeZone,
638                pub updated_at: DateTimeWithTimeZone,
639                pub published_date: chrono::NaiveDate,
640            }
641        "#;
642
643        let config = DiscoveryConfig::default();
644        let schemas = parse_source(source, "test.rs", &config).unwrap();
645
646        let table = &schemas[0];
647
648        let created_at = table.find_column("created_at").unwrap();
649        assert_eq!(created_at.column_type, ColumnType::TimestampTz);
650
651        let updated_at = table.find_column("updated_at").unwrap();
652        assert_eq!(updated_at.column_type, ColumnType::TimestampTz);
653
654        let published_date = table.find_column("published_date").unwrap();
655        assert_eq!(published_date.column_type, ColumnType::Date);
656    }
657
658    #[test]
659    fn test_parse_nullable_fields() {
660        let source = r#"
661            #[ormada_model(table = "users")]
662            pub struct User {
663                #[primary_key]
664                pub id: i32,
665                
666                pub name: String,
667                pub bio: Option<String>,
668                pub deleted_at: Option<ormada::prelude::DateTimeWithTimeZone>,
669            }
670        "#;
671
672        let config = DiscoveryConfig::default();
673        let schemas = parse_source(source, "test.rs", &config).unwrap();
674
675        let table = &schemas[0];
676
677        let name = table.find_column("name").unwrap();
678        assert!(!name.nullable);
679
680        let bio = table.find_column("bio").unwrap();
681        assert!(bio.nullable);
682        assert_eq!(bio.column_type, ColumnType::String(None));
683
684        let deleted_at = table.find_column("deleted_at").unwrap();
685        assert!(deleted_at.nullable);
686        assert_eq!(deleted_at.column_type, ColumnType::TimestampTz);
687    }
688
689    #[test]
690    fn test_parse_indexed_and_unique_fields() {
691        let source = r#"
692            #[ormada_model(table = "users")]
693            pub struct User {
694                #[primary_key]
695                pub id: i32,
696                
697                #[unique]
698                pub email: String,
699                
700                #[index]
701                pub username: String,
702            }
703        "#;
704
705        let config = DiscoveryConfig::default();
706        let schemas = parse_source(source, "test.rs", &config).unwrap();
707
708        let table = &schemas[0];
709
710        let email = table.find_column("email").unwrap();
711        assert!(email.unique);
712
713        let username = table.find_column("username").unwrap();
714        assert!(username.indexed);
715    }
716
717    #[test]
718    fn test_to_table_name() {
719        assert_eq!(to_table_name("Author"), "authors");
720        assert_eq!(to_table_name("Book"), "books");
721        assert_eq!(to_table_name("Category"), "categories");
722        assert_eq!(to_table_name("Address"), "addresses");
723    }
724}