1use std::path::Path;
7use syn::{Attribute, Fields, Item, ItemStruct, Type};
8use walkdir::WalkDir;
9
10use crate::types::*;
11
12pub type ParseResult<T> = Result<T, ParseError>;
14
15#[derive(Debug, Clone)]
17pub enum ParseError {
18 IoError(String),
20 SyntaxError { file: String, message: String },
22 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#[derive(Debug, Clone)]
48pub struct DiscoveryConfig {
49 pub include_paths: Vec<String>,
51 pub exclude_paths: Vec<String>,
53 pub skip_non_migratable: bool,
55 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
70pub 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 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
104pub 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
112pub 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 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
137fn parse_ormada_struct(
139 item: &ItemStruct,
140 file_name: &str,
141 config: &DiscoveryConfig,
142) -> ParseResult<Option<TableSchema>> {
143 if config.skip_test_models && has_cfg_test(&item.attrs) {
145 return Ok(None);
146 }
147
148 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 let attr_config = parse_model_attr(attr, file_name, &item.ident.to_string())?;
160
161 if config.skip_non_migratable && !attr_config.migrate {
163 return Ok(None);
164 }
165
166 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_attrs(&mut column, &mut table, &field.attrs, &field_name)?;
192
193 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#[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
214fn 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 let tokens = meta_list.tokens.to_string();
235
236 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
265fn 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 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 }
324 "rename" => {
325 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
341fn 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
353fn find_ormada_attr<'a>(attrs: &'a [Attribute], name: &str) -> Option<&'a Attribute> {
355 attrs.iter().find(|attr| attr.path().is_ident(name))
356}
357
358fn type_to_string(ty: &Type) -> String {
360 quote::quote!(#ty).to_string().replace(' ', "")
361}
362
363fn 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
369fn get_attr_string_arg(attr: &Attribute, key: &str) -> Option<String> {
371 let tokens = attr.meta.to_token_stream().to_string();
372
373 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
385fn get_attr_int_arg(attr: &Attribute) -> Option<i64> {
387 let tokens = attr.meta.to_token_stream().to_string();
388
389 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
399fn 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
412fn parse_foreign_key_attr(attr: &Attribute) -> Option<(String, OnDeleteAction)> {
414 let tokens = attr.meta.to_token_stream().to_string();
415
416 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 let entity_path = parts.first()?.trim();
425
426 let entity_name = entity_path.rsplit("::").next().unwrap_or(entity_path).trim();
428
429 let table_name = to_table_name(entity_name);
431
432 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
446fn 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
474fn 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 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}