Skip to main content

prax_cli/commands/
generate.rs

1//! `prax generate` command - Generate Rust client code from schema.
2
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5
6use crate::cli::GenerateArgs;
7use crate::config::{CONFIG_FILE_NAME, Config, SCHEMA_FILE_PATH};
8use crate::error::{CliError, CliResult};
9use crate::output::{self, success};
10
11/// Run the generate command
12pub async fn run(args: GenerateArgs) -> CliResult<()> {
13    output::header("Generate Prax Client");
14
15    let cwd = std::env::current_dir()?;
16
17    // Load config
18    let config_path = cwd.join(CONFIG_FILE_NAME);
19    let config = if config_path.exists() {
20        Config::load(&config_path)?
21    } else {
22        Config::default()
23    };
24
25    // Resolve schema path
26    let schema_path = args
27        .schema
28        .clone()
29        .unwrap_or_else(|| cwd.join(SCHEMA_FILE_PATH));
30    if !schema_path.exists() {
31        return Err(
32            CliError::Config(format!("Schema file not found: {}", schema_path.display())).into(),
33        );
34    }
35
36    // Resolve output directory
37    let output_dir = args
38        .output
39        .clone()
40        .unwrap_or_else(|| PathBuf::from(&config.generator.output));
41
42    output::kv("Schema", &schema_path.display().to_string());
43    output::kv("Output", &output_dir.display().to_string());
44    output::newline();
45
46    output::step(1, 4, "Reading schema...");
47
48    // Parse schema
49    let schema_content = std::fs::read_to_string(&schema_path)?;
50    let schema = parse_schema(&schema_content)?;
51
52    output::step(2, 4, "Validating schema...");
53
54    // Validate schema
55    validate_schema(&schema)?;
56
57    output::step(3, 4, "Generating code...");
58
59    // Create output directory
60    std::fs::create_dir_all(&output_dir)?;
61
62    // Generate code
63    let generated_files = generate_code(&schema, &output_dir, &args, &config)?;
64
65    output::step(4, 4, "Writing files...");
66
67    // Print generated files
68    output::newline();
69    output::section("Generated files");
70
71    for file in &generated_files {
72        let relative_path = file
73            .strip_prefix(&cwd)
74            .unwrap_or(file)
75            .display()
76            .to_string();
77        output::list_item(&relative_path);
78    }
79
80    output::newline();
81    success(&format!(
82        "Generated {} files in {:.2}s",
83        generated_files.len(),
84        0.0 // TODO: Add timing
85    ));
86
87    Ok(())
88}
89
90/// Parse and validate the schema file
91fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
92    // Use validate_schema to ensure field types are properly resolved
93    // (e.g., FieldType::Model -> FieldType::Enum for enum references)
94    prax_schema::validate_schema(content)
95        .map_err(|e| CliError::Schema(format!("Failed to parse/validate schema: {}", e)))
96}
97
98/// Validate the schema (now a no-op since parse_schema does validation)
99fn validate_schema(_schema: &prax_schema::Schema) -> CliResult<()> {
100    // Validation is now done in parse_schema via validate_schema()
101    Ok(())
102}
103
104/// Generate code from the schema
105fn generate_code(
106    schema: &prax_schema::ast::Schema,
107    output_dir: &PathBuf,
108    args: &GenerateArgs,
109    config: &Config,
110) -> CliResult<Vec<PathBuf>> {
111    let mut generated_files = Vec::new();
112
113    // Determine which features to generate
114    let features = if !args.features.is_empty() {
115        args.features.clone()
116    } else {
117        config
118            .generator
119            .features
120            .clone()
121            .unwrap_or_else(|| vec!["client".to_string()])
122    };
123
124    // Build relation graph for cycle detection
125    let relation_graph = build_relation_graph(schema);
126
127    // Generate main client module
128    let client_path = output_dir.join("mod.rs");
129    let client_code = generate_client_module(schema, &features)?;
130    std::fs::write(&client_path, client_code)?;
131    generated_files.push(client_path);
132
133    // Generate model modules
134    for model in schema.models.values() {
135        let model_path = output_dir.join(format!("{}.rs", to_snake_case(model.name())));
136        let model_code = generate_model_module(model, &features, &relation_graph)?;
137        std::fs::write(&model_path, model_code)?;
138        generated_files.push(model_path);
139    }
140
141    // Generate enum modules
142    for enum_def in schema.enums.values() {
143        let enum_path = output_dir.join(format!("{}.rs", to_snake_case(enum_def.name())));
144        let enum_code = generate_enum_module(enum_def)?;
145        std::fs::write(&enum_path, enum_code)?;
146        generated_files.push(enum_path);
147    }
148
149    // Generate type definitions
150    let types_path = output_dir.join("types.rs");
151    let types_code = generate_types_module(schema)?;
152    std::fs::write(&types_path, types_code)?;
153    generated_files.push(types_path);
154
155    // Generate filters
156    let filters_path = output_dir.join("filters.rs");
157    let filters_code = generate_filters_module(schema)?;
158    std::fs::write(&filters_path, filters_code)?;
159    generated_files.push(filters_path);
160
161    Ok(generated_files)
162}
163
164/// Build a graph of model relations for cycle detection.
165/// Returns a map from model name to the set of model names it references
166/// (non-list relations only, since Vec<T> doesn't cause infinite size).
167fn build_relation_graph(schema: &prax_schema::ast::Schema) -> HashMap<String, HashSet<String>> {
168    let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
169
170    for model in schema.models.values() {
171        let entry = graph.entry(model.name().to_string()).or_default();
172        for field in model.fields.values() {
173            if let prax_schema::ast::FieldType::Model(ref target) = field.field_type {
174                if !field.is_list() {
175                    entry.insert(target.to_string());
176                }
177            }
178        }
179    }
180
181    graph
182}
183
184/// Check if a non-list relation field from `source_model` to `target_model`
185/// participates in a cycle (i.e. target_model can reach source_model through
186/// non-list relations). If so, the field must be wrapped in Box<T>.
187fn needs_boxing(
188    source_model: &str,
189    target_model: &str,
190    graph: &HashMap<String, HashSet<String>>,
191) -> bool {
192    let mut visited = HashSet::new();
193    let mut stack = vec![target_model.to_string()];
194
195    while let Some(current) = stack.pop() {
196        if current == source_model {
197            return true;
198        }
199        if !visited.insert(current.clone()) {
200            continue;
201        }
202        if let Some(neighbors) = graph.get(&current) {
203            for neighbor in neighbors {
204                stack.push(neighbor.clone());
205            }
206        }
207    }
208
209    false
210}
211
212/// Generate the main client module
213fn generate_client_module(
214    schema: &prax_schema::ast::Schema,
215    _features: &[String],
216) -> CliResult<String> {
217    let mut code = String::new();
218
219    code.push_str("//! Auto-generated by Prax - DO NOT EDIT\n");
220    code.push_str("//!\n");
221    code.push_str("//! This module contains the generated Prax client.\n\n");
222
223    // Module declarations
224    code.push_str("pub mod types;\n");
225    code.push_str("pub mod filters;\n\n");
226
227    for model in schema.models.values() {
228        code.push_str(&format!("pub mod {};\n", to_snake_case(model.name())));
229    }
230
231    for enum_def in schema.enums.values() {
232        code.push_str(&format!("pub mod {};\n", to_snake_case(enum_def.name())));
233    }
234
235    code.push_str("\n");
236
237    // Re-exports
238    code.push_str("#[allow(unused_imports)]\npub use types::*;\n");
239    code.push_str("#[allow(unused_imports)]\npub use filters::*;\n\n");
240
241    for model in schema.models.values() {
242        code.push_str(&format!(
243            "#[allow(unused_imports)]\npub use {}::{};\n",
244            to_snake_case(model.name()),
245            model.name()
246        ));
247    }
248
249    for enum_def in schema.enums.values() {
250        code.push_str(&format!(
251            "#[allow(unused_imports)]\npub use {}::{};\n",
252            to_snake_case(enum_def.name()),
253            enum_def.name()
254        ));
255    }
256
257    code.push_str("\n");
258
259    // Client struct with Clone bound and derive
260    code.push_str("#[allow(dead_code)]\n");
261    code.push_str("/// The Prax database client\n");
262    code.push_str("#[derive(Clone)]\n");
263    code.push_str("pub struct PraxClient<E: prax_query::QueryEngine> {\n");
264    code.push_str("    engine: E,\n");
265    code.push_str("}\n\n");
266
267    code.push_str("impl<E: prax_query::QueryEngine> PraxClient<E> {\n");
268    code.push_str("    /// Create a new Prax client with the given query engine\n");
269    code.push_str("    pub fn new(engine: E) -> Self {\n");
270    code.push_str("        Self { engine }\n");
271    code.push_str("    }\n\n");
272
273    for model in schema.models.values() {
274        let snake_name = to_snake_case(model.name());
275        code.push_str(&format!("    /// Access {} operations\n", model.name()));
276        code.push_str(&format!(
277            "    pub fn {}(&self) -> {}::{}Operations<E> {{\n",
278            snake_name,
279            snake_name,
280            model.name()
281        ));
282        code.push_str(&format!(
283            "        {}::{}Operations::new(self.engine.clone())\n",
284            snake_name,
285            model.name()
286        ));
287        code.push_str("    }\n\n");
288    }
289
290    code.push_str("}\n");
291
292    Ok(code)
293}
294
295/// Generate a model module
296fn generate_model_module(
297    model: &prax_schema::ast::Model,
298    features: &[String],
299    relation_graph: &HashMap<String, HashSet<String>>,
300) -> CliResult<String> {
301    let mut code = String::new();
302
303    code.push_str(&format!(
304        "//! Auto-generated module for {} model\n\n",
305        model.name()
306    ));
307
308    // Import sibling types for relation fields
309    code.push_str("#[allow(unused_imports)]\n");
310    code.push_str("use super::*;\n");
311    code.push_str("#[allow(unused_imports)]\n");
312    code.push_str("use prax_query::traits::Model;\n\n");
313
314    // Derive macros based on features
315    let mut derives = vec!["Debug", "Clone"];
316    if features.contains(&"serde".to_string()) {
317        derives.push("serde::Serialize");
318        derives.push("serde::Deserialize");
319    }
320
321    // Model struct
322    code.push_str("#[allow(dead_code)]\n");
323    code.push_str(&format!("#[derive({})]\n", derives.join(", ")));
324    code.push_str(&format!("pub struct {} {{\n", model.name()));
325
326    for field in model.fields.values() {
327        let field_name = to_snake_case(field.name());
328
329        // Add serde rename if mapped
330        if let Some(attr) = field.get_attribute("map") {
331            if features.contains(&"serde".to_string()) {
332                if let Some(value) = attr.first_arg().and_then(|v| v.as_string()) {
333                    code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
334                }
335            }
336        }
337
338        let rust_type = field_type_to_rust_with_boxing(
339            &field.field_type,
340            field.modifier,
341            model.name(),
342            relation_graph,
343        );
344        code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
345    }
346
347    code.push_str("}\n\n");
348
349    // Model trait implementation
350    let table_name = model.table_name();
351    let id_fields: Vec<&str> = model.id_fields().iter().map(|f| f.name()).collect();
352    let scalar_columns: Vec<String> = model
353        .scalar_fields()
354        .iter()
355        .map(|f| {
356            // Use @map name if present, otherwise snake_case the field name
357            f.get_attribute("map")
358                .and_then(|a| a.first_arg())
359                .and_then(|v| v.as_string())
360                .map(|s| s.to_string())
361                .unwrap_or_else(|| to_snake_case(f.name()))
362        })
363        .collect();
364
365    code.push_str(&format!("impl Model for {} {{\n", model.name()));
366    code.push_str(&format!(
367        "    const MODEL_NAME: &'static str = \"{}\";\n",
368        model.name()
369    ));
370    code.push_str(&format!(
371        "    const TABLE_NAME: &'static str = \"{}\";\n",
372        table_name
373    ));
374    code.push_str(&format!(
375        "    const PRIMARY_KEY: &'static [&'static str] = &[{}];\n",
376        id_fields
377            .iter()
378            .map(|f| format!("\"{}\"", to_snake_case(f)))
379            .collect::<Vec<_>>()
380            .join(", ")
381    ));
382    code.push_str(&format!(
383        "    const COLUMNS: &'static [&'static str] = &[{}];\n",
384        scalar_columns
385            .iter()
386            .map(|c| format!("\"{}\"", c))
387            .collect::<Vec<_>>()
388            .join(", ")
389    ));
390    code.push_str("}\n\n");
391
392    // Operations struct (owned engine, no lifetime)
393    code.push_str("#[allow(dead_code)]\n");
394    code.push_str(&format!("/// Operations for the {} model\n", model.name()));
395    code.push_str(&format!(
396        "pub struct {}Operations<E: prax_query::QueryEngine> {{\n",
397        model.name()
398    ));
399    code.push_str("    engine: E,\n");
400    code.push_str("}\n\n");
401
402    code.push_str(&format!(
403        "impl<E: prax_query::QueryEngine> {}Operations<E> {{\n",
404        model.name()
405    ));
406    code.push_str("    pub fn new(engine: E) -> Self {\n");
407    code.push_str("        Self { engine }\n");
408    code.push_str("    }\n\n");
409
410    // CRUD methods (1-arg constructors, no lifetime on return types)
411    code.push_str("    /// Find many records\n");
412    code.push_str(&format!(
413        "    pub fn find_many(&self) -> prax_query::FindManyOperation<E, {}> {{\n",
414        model.name()
415    ));
416    code.push_str("        prax_query::FindManyOperation::new(self.engine.clone())\n");
417    code.push_str("    }\n\n");
418
419    code.push_str("    /// Find a unique record\n");
420    code.push_str(&format!(
421        "    pub fn find_unique(&self) -> prax_query::FindUniqueOperation<E, {}> {{\n",
422        model.name()
423    ));
424    code.push_str("        prax_query::FindUniqueOperation::new(self.engine.clone())\n");
425    code.push_str("    }\n\n");
426
427    code.push_str("    /// Find the first matching record\n");
428    code.push_str(&format!(
429        "    pub fn find_first(&self) -> prax_query::FindFirstOperation<E, {}> {{\n",
430        model.name()
431    ));
432    code.push_str("        prax_query::FindFirstOperation::new(self.engine.clone())\n");
433    code.push_str("    }\n\n");
434
435    code.push_str("    /// Create a new record\n");
436    code.push_str(&format!(
437        "    pub fn create(&self) -> prax_query::CreateOperation<E, {}> {{\n",
438        model.name()
439    ));
440    code.push_str("        prax_query::CreateOperation::new(self.engine.clone())\n");
441    code.push_str("    }\n\n");
442
443    code.push_str("    /// Update a record\n");
444    code.push_str(&format!(
445        "    pub fn update(&self) -> prax_query::UpdateOperation<E, {}> {{\n",
446        model.name()
447    ));
448    code.push_str("        prax_query::UpdateOperation::new(self.engine.clone())\n");
449    code.push_str("    }\n\n");
450
451    code.push_str("    /// Delete a record\n");
452    code.push_str(&format!(
453        "    pub fn delete(&self) -> prax_query::DeleteOperation<E, {}> {{\n",
454        model.name()
455    ));
456    code.push_str("        prax_query::DeleteOperation::new(self.engine.clone())\n");
457    code.push_str("    }\n\n");
458
459    code.push_str("    /// Count records\n");
460    code.push_str(&format!(
461        "    pub fn count(&self) -> prax_query::CountOperation<E, {}> {{\n",
462        model.name()
463    ));
464    code.push_str("        prax_query::CountOperation::new(self.engine.clone())\n");
465    code.push_str("    }\n");
466
467    code.push_str("}\n");
468
469    Ok(code)
470}
471
472/// Generate an enum module
473fn generate_enum_module(enum_def: &prax_schema::ast::Enum) -> CliResult<String> {
474    let mut code = String::new();
475
476    code.push_str(&format!(
477        "//! Auto-generated module for {} enum\n\n",
478        enum_def.name()
479    ));
480
481    code.push_str("#[allow(dead_code)]\n");
482    code.push_str(
483        "#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
484    );
485    code.push_str(&format!("pub enum {} {{\n", enum_def.name()));
486
487    for variant in &enum_def.variants {
488        let raw_name = variant.name();
489        let pascal_name = to_pascal_case(raw_name);
490
491        // Check for explicit @map attribute first
492        if let Some(attr) = variant.attributes.iter().find(|a| a.is("map")) {
493            if let Some(value) = attr.first_arg().and_then(|v| v.as_string()) {
494                code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
495                code.push_str(&format!("    {},\n", pascal_name));
496                continue;
497            }
498        }
499
500        // If variant name differs from PascalCase form, add serde rename
501        if raw_name != pascal_name {
502            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", raw_name));
503        }
504        code.push_str(&format!("    {},\n", pascal_name));
505    }
506
507    code.push_str("}\n\n");
508
509    // Display implementation for SQL serialization
510    code.push_str(&format!(
511        "impl std::fmt::Display for {} {{\n",
512        enum_def.name()
513    ));
514    code.push_str("    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n");
515    code.push_str("        match self {\n");
516    for variant in &enum_def.variants {
517        let raw_name = variant.name();
518        let pascal_name = to_pascal_case(raw_name);
519        let db_value = variant.db_value();
520        code.push_str(&format!(
521            "            Self::{} => write!(f, \"{}\"),\n",
522            pascal_name, db_value
523        ));
524    }
525    code.push_str("        }\n");
526    code.push_str("    }\n");
527    code.push_str("}\n\n");
528
529    // Default implementation
530    if let Some(default_variant) = enum_def.variants.first() {
531        let pascal_name = to_pascal_case(default_variant.name());
532        code.push_str(&format!("impl Default for {} {{\n", enum_def.name()));
533        code.push_str(&format!(
534            "    fn default() -> Self {{\n        Self::{}\n    }}\n",
535            pascal_name
536        ));
537        code.push_str("}\n");
538    }
539
540    Ok(code)
541}
542
543/// Generate types module
544fn generate_types_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
545    let mut code = String::new();
546
547    code.push_str("//! Common type definitions\n\n");
548    code.push_str("#[allow(unused_imports)]\npub use chrono::{DateTime, Utc};\n");
549    code.push_str("#[allow(unused_imports)]\npub use uuid::Uuid;\n");
550    code.push_str("#[allow(unused_imports)]\npub use serde_json::Value as Json;\n");
551    code.push_str("\n");
552
553    // Add any custom types from composite types
554    for composite in schema.types.values() {
555        code.push_str("#[allow(dead_code)]\n");
556        code.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
557        code.push_str(&format!("pub struct {} {{\n", composite.name()));
558        for field in composite.fields.values() {
559            let rust_type = field_type_to_rust(&field.field_type, field.modifier);
560            let field_name = to_snake_case(field.name());
561            code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
562        }
563        code.push_str("}\n\n");
564    }
565
566    Ok(code)
567}
568
569/// Generate filters module
570fn generate_filters_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
571    let mut code = String::new();
572
573    code.push_str("//! Filter types for queries\n\n");
574    code.push_str("#[allow(unused_imports)]\n");
575    code.push_str("use prax_query::filter::{Filter, ScalarFilter};\n");
576
577    // Collect all enum types referenced by model scalar fields
578    let mut referenced_enums = HashSet::new();
579    for model in schema.models.values() {
580        for field in model.fields.values() {
581            if !field.is_relation() {
582                if let prax_schema::ast::FieldType::Enum(ref name) = field.field_type {
583                    referenced_enums.insert(name.to_string());
584                }
585            }
586        }
587    }
588
589    // Import enum types
590    for enum_name in &referenced_enums {
591        code.push_str(&format!(
592            "#[allow(unused_imports)]\nuse super::{}::{};\n",
593            to_snake_case(enum_name),
594            enum_name
595        ));
596    }
597
598    code.push_str("\n");
599
600    for model in schema.models.values() {
601        // Where input
602        code.push_str("#[allow(dead_code)]\n");
603        code.push_str(&format!("/// Filter input for {} queries\n", model.name()));
604        code.push_str("#[derive(Debug, Default, Clone)]\n");
605        code.push_str(&format!("pub struct {}WhereInput {{\n", model.name()));
606
607        for field in model.fields.values() {
608            if !field.is_relation() {
609                let filter_type = field_to_filter_type(&field.field_type);
610                let field_name = to_snake_case(field.name());
611                code.push_str(&format!(
612                    "    pub {}: Option<{}>,\n",
613                    field_name, filter_type
614                ));
615            }
616        }
617
618        code.push_str("    pub and: Option<Vec<Self>>,\n");
619        code.push_str("    pub or: Option<Vec<Self>>,\n");
620        code.push_str("    pub not: Option<Box<Self>>,\n");
621        code.push_str("}\n\n");
622
623        // OrderBy input
624        code.push_str("#[allow(dead_code)]\n");
625        code.push_str(&format!(
626            "/// Order by input for {} queries\n",
627            model.name()
628        ));
629        code.push_str("#[derive(Debug, Default, Clone)]\n");
630        code.push_str(&format!("pub struct {}OrderByInput {{\n", model.name()));
631
632        for field in model.fields.values() {
633            if !field.is_relation() {
634                let field_name = to_snake_case(field.name());
635                code.push_str(&format!(
636                    "    pub {}: Option<prax_query::SortOrder>,\n",
637                    field_name
638                ));
639            }
640        }
641
642        code.push_str("}\n\n");
643    }
644
645    Ok(code)
646}
647
648/// Convert a field type to Rust type (basic, without boxing)
649fn field_type_to_rust(
650    field_type: &prax_schema::ast::FieldType,
651    modifier: prax_schema::ast::TypeModifier,
652) -> String {
653    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
654
655    let base_type = match field_type {
656        FieldType::Scalar(scalar) => match scalar {
657            ScalarType::Int => "i32".to_string(),
658            ScalarType::BigInt => "i64".to_string(),
659            ScalarType::Float => "f64".to_string(),
660            ScalarType::String => "String".to_string(),
661            ScalarType::Boolean => "bool".to_string(),
662            ScalarType::DateTime => "chrono::DateTime<chrono::Utc>".to_string(),
663            ScalarType::Date => "chrono::NaiveDate".to_string(),
664            ScalarType::Time => "chrono::NaiveTime".to_string(),
665            ScalarType::Json => "serde_json::Value".to_string(),
666            ScalarType::Bytes => "Vec<u8>".to_string(),
667            ScalarType::Decimal => "rust_decimal::Decimal".to_string(),
668            ScalarType::Uuid => "uuid::Uuid".to_string(),
669            ScalarType::Cuid => "String".to_string(),
670            ScalarType::Cuid2 => "String".to_string(),
671            ScalarType::NanoId => "String".to_string(),
672            ScalarType::Ulid => "String".to_string(),
673            ScalarType::Vector(_) | ScalarType::HalfVector(_) => "Vec<f32>".to_string(),
674            ScalarType::SparseVector(_) => "Vec<(u32, f32)>".to_string(),
675            ScalarType::Bit(_) => "Vec<u8>".to_string(),
676        },
677        FieldType::Model(name) => name.to_string(),
678        FieldType::Enum(name) => name.to_string(),
679        FieldType::Composite(name) => name.to_string(),
680        FieldType::Unsupported(_) => "serde_json::Value".to_string(),
681    };
682
683    match modifier {
684        TypeModifier::Optional | TypeModifier::OptionalList => format!("Option<{}>", base_type),
685        TypeModifier::List => format!("Vec<{}>", base_type),
686        TypeModifier::Required => base_type,
687    }
688}
689
690/// Convert a field type to Rust type with Box<T> wrapping for cyclic relations.
691fn field_type_to_rust_with_boxing(
692    field_type: &prax_schema::ast::FieldType,
693    modifier: prax_schema::ast::TypeModifier,
694    source_model: &str,
695    relation_graph: &HashMap<String, HashSet<String>>,
696) -> String {
697    use prax_schema::ast::{FieldType, TypeModifier};
698
699    // For model references (non-list), check if boxing is needed to break cycles
700    if let FieldType::Model(target) = field_type {
701        if !matches!(modifier, TypeModifier::List) {
702            let should_box = needs_boxing(source_model, target, relation_graph);
703            let base = target.to_string();
704            return match modifier {
705                TypeModifier::Optional | TypeModifier::OptionalList => {
706                    if should_box {
707                        format!("Option<Box<{}>>", base)
708                    } else {
709                        format!("Option<{}>", base)
710                    }
711                }
712                TypeModifier::Required => {
713                    if should_box {
714                        format!("Box<{}>", base)
715                    } else {
716                        base
717                    }
718                }
719                TypeModifier::List => unreachable!(),
720            };
721        }
722    }
723
724    // Fallback to basic conversion for non-cyclic fields
725    field_type_to_rust(field_type, modifier)
726}
727
728/// Convert a field type to filter type
729fn field_to_filter_type(field_type: &prax_schema::ast::FieldType) -> String {
730    use prax_schema::ast::{FieldType, ScalarType};
731
732    match field_type {
733        FieldType::Scalar(scalar) => match scalar {
734            ScalarType::Int | ScalarType::BigInt => "ScalarFilter<i64>".to_string(),
735            ScalarType::Float | ScalarType::Decimal => "ScalarFilter<f64>".to_string(),
736            ScalarType::String
737            | ScalarType::Uuid
738            | ScalarType::Cuid
739            | ScalarType::Cuid2
740            | ScalarType::NanoId
741            | ScalarType::Ulid => "ScalarFilter<String>".to_string(),
742            ScalarType::Boolean => "ScalarFilter<bool>".to_string(),
743            ScalarType::DateTime => "ScalarFilter<chrono::DateTime<chrono::Utc>>".to_string(),
744            ScalarType::Date => "ScalarFilter<chrono::NaiveDate>".to_string(),
745            ScalarType::Time => "ScalarFilter<chrono::NaiveTime>".to_string(),
746            ScalarType::Json => "ScalarFilter<serde_json::Value>".to_string(),
747            ScalarType::Bytes => "ScalarFilter<Vec<u8>>".to_string(),
748            // Vector types don't have standard scalar filters
749            ScalarType::Vector(_) | ScalarType::HalfVector(_) => "VectorFilter".to_string(),
750            ScalarType::SparseVector(_) => "SparseVectorFilter".to_string(),
751            ScalarType::Bit(_) => "BitFilter".to_string(),
752        },
753        FieldType::Enum(name) => format!("ScalarFilter<{}>", name),
754        _ => "Filter".to_string(),
755    }
756}
757
758/// Convert PascalCase to snake_case
759fn to_snake_case(name: &str) -> String {
760    let mut result = String::new();
761    for (i, c) in name.chars().enumerate() {
762        if c.is_uppercase() {
763            if i > 0 {
764                result.push('_');
765            }
766            result.push(c.to_lowercase().next().unwrap());
767        } else {
768            result.push(c);
769        }
770    }
771    result
772}
773
774/// Convert snake_case, SCREAMING_SNAKE_CASE, or any other casing to PascalCase.
775fn to_pascal_case(name: &str) -> String {
776    if name.is_empty() {
777        return String::new();
778    }
779
780    // If already PascalCase (starts with uppercase, contains lowercase), return as-is
781    let first = name.chars().next().unwrap();
782    if first.is_uppercase() && name.chars().any(|c| c.is_lowercase()) && !name.contains('_') {
783        return name.to_string();
784    }
785
786    // Split on underscores and capitalize each segment
787    name.split('_')
788        .filter(|s| !s.is_empty())
789        .map(|segment| {
790            let mut chars = segment.chars();
791            match chars.next() {
792                None => String::new(),
793                Some(first) => {
794                    let rest: String = chars.collect();
795                    format!("{}{}", first.to_uppercase(), rest.to_lowercase())
796                }
797            }
798        })
799        .collect()
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn test_to_snake_case() {
808        assert_eq!(to_snake_case("BoardMember"), "board_member");
809        assert_eq!(to_snake_case("User"), "user");
810        assert_eq!(to_snake_case("JiraImportConfig"), "jira_import_config");
811    }
812
813    #[test]
814    fn test_to_pascal_case_from_snake() {
815        assert_eq!(to_pascal_case("card_created"), "CardCreated");
816        assert_eq!(to_pascal_case("branch_deleted"), "BranchDeleted");
817        assert_eq!(to_pascal_case("pr_merged"), "PrMerged");
818    }
819
820    #[test]
821    fn test_to_pascal_case_from_screaming() {
822        assert_eq!(to_pascal_case("CARD_CREATED"), "CardCreated");
823        assert_eq!(to_pascal_case("PR_MERGED"), "PrMerged");
824    }
825
826    #[test]
827    fn test_to_pascal_case_already_pascal() {
828        assert_eq!(to_pascal_case("Admin"), "Admin");
829        assert_eq!(to_pascal_case("SuperAdmin"), "SuperAdmin");
830        assert_eq!(to_pascal_case("Low"), "Low");
831    }
832
833    #[test]
834    fn test_to_pascal_case_single_word() {
835        assert_eq!(to_pascal_case("active"), "Active");
836        assert_eq!(to_pascal_case("ACTIVE"), "Active");
837    }
838
839    #[test]
840    fn test_needs_boxing_direct_cycle() {
841        let mut graph = HashMap::new();
842        graph.insert(
843            "Board".to_string(),
844            HashSet::from(["JiraConfig".to_string()]),
845        );
846        graph.insert(
847            "JiraConfig".to_string(),
848            HashSet::from(["Board".to_string()]),
849        );
850
851        assert!(needs_boxing("Board", "JiraConfig", &graph));
852        assert!(needs_boxing("JiraConfig", "Board", &graph));
853    }
854
855    #[test]
856    fn test_needs_boxing_no_cycle() {
857        let mut graph = HashMap::new();
858        graph.insert("Post".to_string(), HashSet::from(["User".to_string()]));
859        graph.insert("User".to_string(), HashSet::new());
860
861        assert!(!needs_boxing("Post", "User", &graph));
862    }
863
864    #[test]
865    fn test_needs_boxing_indirect_cycle() {
866        let mut graph = HashMap::new();
867        graph.insert("A".to_string(), HashSet::from(["B".to_string()]));
868        graph.insert("B".to_string(), HashSet::from(["C".to_string()]));
869        graph.insert("C".to_string(), HashSet::from(["A".to_string()]));
870
871        assert!(needs_boxing("A", "B", &graph));
872        assert!(needs_boxing("B", "C", &graph));
873        assert!(needs_boxing("C", "A", &graph));
874    }
875}