Skip to main content

drizzle_cli/commands/
new.rs

1//! Interactive schema builder (`drizzle new`)
2//!
3//! Walks the user through an interactive wizard to define tables, columns,
4//! indexes, and foreign keys, then generates Rust schema code using the
5//! existing codegen pipeline (the same one `drizzle introspect` uses).
6//!
7//! Supports JSON import/export for CI-friendly, reproducible schema generation:
8//! - `drizzle new --json` reads a JSON schema definition from stdin
9//! - `drizzle new --json --from file.json` reads from a file
10//! - `drizzle new --export-json out.json` exports the schema as JSON
11//! - `drizzle new --schema-help` prints the expected JSON shape
12
13use std::borrow::Cow;
14use std::collections::HashSet;
15use std::path::PathBuf;
16
17use inquire::validator::Validation;
18use inquire::{Confirm, MultiSelect, Select, Text};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use crate::config::{Config, Dialect};
23use crate::error::CliError;
24use crate::output;
25
26// ── Public API ──────────────────────────────────────────────────────────────
27
28#[derive(clap::Args, Debug)]
29pub struct NewOptions {
30    /// Override dialect (sqlite, postgresql, mysql)
31    #[arg(long)]
32    pub dialect: Option<Dialect>,
33
34    /// Override schema output path
35    #[arg(long)]
36    pub schema: Option<String>,
37
38    /// Read schema definition from JSON (stdin by default)
39    #[arg(long)]
40    pub json: bool,
41
42    /// Read JSON from a file instead of stdin (requires --json)
43    #[arg(long, requires = "json", value_name = "PATH")]
44    pub from: Option<PathBuf>,
45
46    /// Export the schema definition as JSON after building
47    #[arg(long = "export-json", value_name = "PATH")]
48    pub export_json: Option<PathBuf>,
49
50    /// Print the expected JSON schema shape and exit
51    #[arg(long = "schema-help")]
52    pub schema_help: bool,
53}
54
55/// Runs `drizzle new`: builds a [`SchemaDefinition`] from prompts or JSON,
56/// validates it, and writes the generated Rust schema file (`--schema`, else
57/// the definition's `output_path`).
58///
59/// # Errors
60///
61/// Returns [`CliError`] if the JSON cannot be read or parsed, a prompt is
62/// cancelled, the definition fails validation (unknown tables or columns,
63/// options used on the wrong dialect, ...), or a file cannot be written.
64pub fn run(config: Option<&Config>, options: &NewOptions) -> Result<(), CliError> {
65    // --schema-help: print annotated example and exit
66    if options.schema_help {
67        print_json_schema();
68        return Ok(());
69    }
70
71    // Build the schema definition from either JSON input or interactive prompts
72    let def = if options.json {
73        load_json(options.from.as_deref())?
74    } else {
75        collect_interactively(config, options)?
76    };
77
78    // Validate the schema definition
79    validate_schema(&def)?;
80
81    // Export JSON if requested
82    if let Some(ref export_path) = options.export_json {
83        export_to_json(&def, export_path)?;
84    }
85
86    // Determine output path (JSON definition's output_path, or CLI override)
87    let output_path = if let Some(ref s) = options.schema {
88        s.clone()
89    } else {
90        def.output_path.clone()
91    };
92
93    // Generate code
94    let code = match def.dialect {
95        Dialect::Sqlite | Dialect::Turso => generate_sqlite(
96            &def.tables,
97            &def.indexes,
98            &def.foreign_keys,
99            &def.schema_name,
100            def.casing,
101        ),
102        Dialect::Postgresql => generate_postgres(
103            &def.tables,
104            &def.indexes,
105            &def.foreign_keys,
106            &def.enums,
107            &def.schema_name,
108            def.casing,
109        ),
110        Dialect::Mysql => generate_mysql(
111            &def.tables,
112            &def.indexes,
113            &def.foreign_keys,
114            &def.enums,
115            &def.schema_name,
116            def.casing,
117        )?,
118    };
119
120    // Write output
121    let path = PathBuf::from(&output_path);
122    if let Some(parent) = path.parent() {
123        std::fs::create_dir_all(parent)
124            .map_err(|e| CliError::IoError(format!("Failed to create directory: {e}")))?;
125    }
126    std::fs::write(&path, &code)
127        .map_err(|e| CliError::IoError(format!("Failed to write schema file: {e}")))?;
128
129    // Print summary
130    println!();
131    println!("{}", output::success("Schema generated successfully!"));
132    println!();
133    println!(
134        "  Tables: {}",
135        def.tables
136            .iter()
137            .map(|t| t.name.as_str())
138            .collect::<Vec<_>>()
139            .join(", ")
140    );
141    if !def.indexes.is_empty() {
142        println!(
143            "  Indexes: {}",
144            def.indexes
145                .iter()
146                .map(|i| i.name.as_str())
147                .collect::<Vec<_>>()
148                .join(", ")
149        );
150    }
151    if !def.foreign_keys.is_empty() {
152        println!("  Foreign keys: {}", def.foreign_keys.len());
153    }
154    println!("  Output: {output_path}");
155    if let Some(ref export_path) = options.export_json {
156        println!("  JSON export: {}", export_path.display());
157    }
158    println!();
159    println!("Next steps:");
160    println!(
161        "  Run {} to generate your first migration",
162        output::heading("drizzle generate")
163    );
164
165    Ok(())
166}
167
168// ── Schema definition (top-level JSON document) ─────────────────────────────
169
170/// The JSON document `drizzle new --json` reads and `--export-json` writes.
171#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
172pub struct SchemaDefinition {
173    /// Target dialect.
174    pub dialect: Dialect,
175    /// Casing of generated Rust field names (default `snake_case`).
176    #[serde(default = "default_casing")]
177    pub casing: FieldCasing,
178    /// Name of the generated schema struct (default `AppSchema`).
179    #[serde(default = "default_schema_name")]
180    pub schema_name: String,
181    /// Where to write the schema file (default `src/schema.rs`).
182    #[serde(default = "default_output_path")]
183    pub output_path: String,
184    /// Enum types (PostgreSQL enums or MySQL inline enums).
185    #[serde(default)]
186    pub enums: Vec<EnumDef>,
187    /// Tables.
188    pub tables: Vec<TableDef>,
189    /// Indexes.
190    #[serde(default)]
191    pub indexes: Vec<IndexDef>,
192    /// Foreign keys.
193    #[serde(default)]
194    pub foreign_keys: Vec<ForeignKeyDef>,
195}
196
197const fn default_casing() -> FieldCasing {
198    FieldCasing::Snake
199}
200
201fn default_schema_name() -> String {
202    "AppSchema".to_string()
203}
204
205fn default_output_path() -> String {
206    "src/schema.rs".to_string()
207}
208
209fn default_fk_action() -> String {
210    "No Action".to_string()
211}
212
213// ── Intermediate structs ────────────────────────────────────────────────────
214
215/// An enum type: name and labels.
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
217pub struct EnumDef {
218    /// Enum name, referenced by a column's `enum_name`.
219    pub name: String,
220    /// Labels, in order.
221    pub variants: Vec<String>,
222}
223
224/// A table definition.
225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
226pub struct TableDef {
227    /// SQL table name.
228    pub name: String,
229    /// Columns, in order.
230    pub columns: Vec<ColumnDef>,
231    /// `SQLite` only
232    #[serde(default)]
233    pub strict: bool,
234    /// `SQLite` only
235    #[serde(default)]
236    pub without_rowid: bool,
237    /// `PostgreSQL` only: the table's schema (default `public`).
238    #[serde(default = "default_pg_schema")]
239    pub pg_schema: String,
240}
241
242fn default_pg_schema() -> String {
243    "public".to_string()
244}
245
246/// Auto-generation strategy for a column value.
247///
248/// `autoincrement` is supported by SQLite (`AUTOINCREMENT`) and MySQL
249/// (`AUTO_INCREMENT`); `identity` is PostgreSQL-specific. They are
250/// mutually exclusive dialect variants, so they live in a single optional enum
251/// rather than two parallel booleans.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
253#[serde(rename_all = "lowercase")]
254pub enum AutoGenKind {
255    /// SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` or MySQL `AUTO_INCREMENT`.
256    Autoincrement,
257    /// `PostgreSQL` `GENERATED ALWAYS AS IDENTITY`.
258    Identity,
259}
260
261/// A column definition.
262#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
263pub struct ColumnDef {
264    /// SQL column name.
265    pub name: String,
266    /// SQL type, as the database spells it (for example `integer`, `text`).
267    pub sql_type: String,
268    /// Adds `NOT NULL`.
269    #[serde(default)]
270    pub not_null: bool,
271    /// Part of the primary key.
272    #[serde(default)]
273    pub primary_key: bool,
274    /// Adds a unique constraint.
275    #[serde(default)]
276    pub unique: bool,
277    /// SQL default expression.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub default: Option<String>,
280    /// Auto-generation strategy (SQLite/MySQL autoincrement or PostgreSQL identity).
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub auto_gen: Option<AutoGenKind>,
283    /// PostgreSQL enum type or MySQL inline enum definition name.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub enum_name: Option<String>,
286}
287
288impl ColumnDef {
289    /// Returns `true` if [`auto_gen`](Self::auto_gen) is `autoincrement`.
290    #[must_use]
291    pub const fn is_autoincrement(&self) -> bool {
292        matches!(self.auto_gen, Some(AutoGenKind::Autoincrement))
293    }
294
295    /// Returns `true` if [`auto_gen`](Self::auto_gen) is `identity`.
296    #[must_use]
297    pub const fn is_identity(&self) -> bool {
298        matches!(self.auto_gen, Some(AutoGenKind::Identity))
299    }
300}
301
302/// An index definition.
303#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
304pub struct IndexDef {
305    /// Index name.
306    pub name: String,
307    /// Table it indexes.
308    pub table: String,
309    /// Indexed columns, in order.
310    pub columns: Vec<String>,
311    /// Creates a unique index.
312    #[serde(default)]
313    pub unique: bool,
314    /// `PostgreSQL` only: the table's schema.
315    #[serde(default)]
316    pub pg_schema: String,
317}
318
319/// A foreign-key definition.
320#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
321pub struct ForeignKeyDef {
322    /// Constraint name.
323    pub name: String,
324    /// Referencing table.
325    pub table: String,
326    /// Referencing columns.
327    pub columns: Vec<String>,
328    /// Referenced table.
329    pub table_to: String,
330    /// Referenced columns, matching `columns` in order.
331    pub columns_to: Vec<String>,
332    /// `ON DELETE` action (default `No Action`).
333    #[serde(default = "default_fk_action")]
334    pub on_delete: String,
335    /// `ON UPDATE` action (default `No Action`).
336    #[serde(default = "default_fk_action")]
337    pub on_update: String,
338    /// `PostgreSQL` only: the referencing table's schema.
339    #[serde(default)]
340    pub pg_schema: String,
341    /// `PostgreSQL` only: the referenced table's schema.
342    #[serde(default)]
343    pub pg_schema_to: String,
344}
345
346/// Casing of generated Rust field names.
347#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
348pub enum FieldCasing {
349    /// `snake_case`.
350    #[serde(rename = "snake_case")]
351    Snake,
352    /// `camelCase`.
353    #[serde(rename = "camelCase")]
354    Camel,
355}
356
357// ── JSON import/export ──────────────────────────────────────────────────────
358
359fn load_json(from: Option<&std::path::Path>) -> Result<SchemaDefinition, CliError> {
360    let content = if let Some(path) = from {
361        std::fs::read_to_string(path)
362            .map_err(|e| CliError::IoError(format!("Failed to read {}: {e}", path.display())))?
363    } else {
364        use std::io::Read;
365        let mut buf = String::new();
366        std::io::stdin()
367            .read_to_string(&mut buf)
368            .map_err(|e| CliError::IoError(format!("Failed to read stdin: {e}")))?;
369        buf
370    };
371    serde_json::from_str(&content)
372        .map_err(|e| CliError::Other(format!("Invalid JSON schema definition: {e}")))
373}
374
375fn export_to_json(def: &SchemaDefinition, path: &std::path::Path) -> Result<(), CliError> {
376    let json = serde_json::to_string_pretty(def)
377        .map_err(|e| CliError::Other(format!("Failed to serialize schema: {e}")))?;
378    if let Some(parent) = path.parent() {
379        std::fs::create_dir_all(parent)
380            .map_err(|e| CliError::IoError(format!("Failed to create directory: {e}")))?;
381    }
382    std::fs::write(path, json)
383        .map_err(|e| CliError::IoError(format!("Failed to write JSON: {e}")))?;
384    Ok(())
385}
386
387fn print_json_schema() {
388    let schema = schemars::schema_for!(SchemaDefinition);
389    let json = serde_json::to_string_pretty(&schema).expect("schema serialization cannot fail");
390    println!("{json}");
391    println!();
392    println!(
393        "Valid on_delete/on_update actions: \"No Action\", \"Cascade\", \"Set Null\", \"Set Default\", \"Restrict\""
394    );
395    println!();
396    println!("Tip: Run `drizzle new --export-json schema.json` to export an interactive");
397    println!(
398        "session as valid JSON, then edit and replay with `drizzle new --json --from schema.json`."
399    );
400}
401
402// ── Validation ──────────────────────────────────────────────────────────────
403
404const VALID_FK_ACTIONS: &[&str] = &[
405    "No Action",
406    "Cascade",
407    "Set Null",
408    "Set Default",
409    "Restrict",
410];
411
412fn validate_schema(def: &SchemaDefinition) -> Result<(), CliError> {
413    // Must have at least one table
414    if def.tables.is_empty() {
415        return Err(CliError::Other(
416            "Schema must have at least one table".into(),
417        ));
418    }
419
420    // Check table names are valid and unique, and per-table column/dialect rules
421    let mut table_names = HashSet::new();
422    for table in &def.tables {
423        if !is_valid_identifier(&table.name) {
424            return Err(CliError::Other(format!(
425                "Invalid table name: '{}'",
426                table.name
427            )));
428        }
429        if !table_names.insert(&table.name) {
430            return Err(CliError::Other(format!(
431                "Duplicate table name: '{}'",
432                table.name
433            )));
434        }
435        validate_table(table, def.dialect)?;
436    }
437
438    let enum_names = validate_enums(def)?;
439
440    // Validate enum references in columns
441    for table in &def.tables {
442        for col in &table.columns {
443            if let Some(ref en) = col.enum_name
444                && !enum_names.contains(en.as_str())
445            {
446                return Err(CliError::Other(format!(
447                    "Column '{}.{}' references unknown enum '{}'",
448                    table.name, col.name, en
449                )));
450            }
451        }
452    }
453
454    validate_indexes(def)?;
455    validate_foreign_keys(def)?;
456
457    Ok(())
458}
459
460/// Validate a single table: column names, dialect-specific column rules, and
461/// that the table itself has at least one column.
462fn validate_table(table: &TableDef, dialect: Dialect) -> Result<(), CliError> {
463    if table.columns.is_empty() {
464        return Err(CliError::Other(format!(
465            "Table '{}' must have at least one column",
466            table.name
467        )));
468    }
469
470    let mut col_names = HashSet::new();
471    for col in &table.columns {
472        if !is_valid_identifier(&col.name) {
473            return Err(CliError::Other(format!(
474                "Invalid column name '{}' in table '{}'",
475                col.name, table.name
476            )));
477        }
478        if !col_names.insert(&col.name) {
479            return Err(CliError::Other(format!(
480                "Duplicate column name '{}' in table '{}'",
481                col.name, table.name
482            )));
483        }
484    }
485
486    match dialect {
487        Dialect::Sqlite | Dialect::Turso => {
488            for col in &table.columns {
489                if col.is_identity() {
490                    return Err(CliError::Other(format!(
491                        "Column '{}.{}': 'identity' is only supported for PostgreSQL",
492                        table.name, col.name
493                    )));
494                }
495                if col.enum_name.is_some() {
496                    return Err(CliError::Other(format!(
497                        "Column '{}.{}': 'enum_name' is only supported for PostgreSQL",
498                        table.name, col.name
499                    )));
500                }
501            }
502        }
503        Dialect::Postgresql => {
504            if table.strict {
505                return Err(CliError::Other(format!(
506                    "Table '{}': 'strict' is only supported for SQLite",
507                    table.name
508                )));
509            }
510            if table.without_rowid {
511                return Err(CliError::Other(format!(
512                    "Table '{}': 'without_rowid' is only supported for SQLite",
513                    table.name
514                )));
515            }
516            for col in &table.columns {
517                if col.is_autoincrement() {
518                    return Err(CliError::Other(format!(
519                        "Column '{}.{}': 'autoincrement' is only supported for SQLite (use 'identity' for PostgreSQL)",
520                        table.name, col.name
521                    )));
522                }
523            }
524        }
525        Dialect::Mysql => {
526            if table.strict || table.without_rowid {
527                return Err(CliError::Other(format!(
528                    "Table '{}': SQLite table options are not supported for MySQL",
529                    table.name
530                )));
531            }
532            for col in &table.columns {
533                if col.is_identity() {
534                    return Err(CliError::Other(format!(
535                        "Column '{}.{}': 'identity' is only supported for PostgreSQL (use 'autoincrement' for MySQL)",
536                        table.name, col.name
537                    )));
538                }
539            }
540        }
541    }
542
543    Ok(())
544}
545
546/// Validate enum definitions and return the set of declared enum names.
547fn validate_enums(def: &SchemaDefinition) -> Result<HashSet<&str>, CliError> {
548    if matches!(def.dialect, Dialect::Sqlite | Dialect::Turso) && !def.enums.is_empty() {
549        return Err(CliError::Other(
550            "Enums are only supported for PostgreSQL and MySQL".into(),
551        ));
552    }
553    let mut enum_names = HashSet::new();
554    for e in &def.enums {
555        if !is_valid_identifier(&e.name) {
556            return Err(CliError::Other(format!("Invalid enum name: '{}'", e.name)));
557        }
558        if !enum_names.insert(e.name.as_str()) {
559            return Err(CliError::Other(format!(
560                "Duplicate enum name: '{}'",
561                e.name
562            )));
563        }
564        if e.variants.is_empty() {
565            return Err(CliError::Other(format!(
566                "Enum '{}' must have at least one variant",
567                e.name
568            )));
569        }
570    }
571    Ok(enum_names)
572}
573
574/// Validate that each index references a real table and real columns.
575fn validate_indexes(def: &SchemaDefinition) -> Result<(), CliError> {
576    for idx in &def.indexes {
577        let table = def.tables.iter().find(|t| t.name == idx.table);
578        let Some(table) = table else {
579            return Err(CliError::Other(format!(
580                "Index '{}' references unknown table '{}'",
581                idx.name, idx.table
582            )));
583        };
584        for col_name in &idx.columns {
585            if !table.columns.iter().any(|c| &c.name == col_name) {
586                return Err(CliError::Other(format!(
587                    "Index '{}' references unknown column '{}.{}'",
588                    idx.name, idx.table, col_name
589                )));
590            }
591        }
592    }
593    Ok(())
594}
595
596/// Validate that each foreign key references real tables/columns and uses a
597/// recognized on-delete / on-update action.
598fn validate_foreign_keys(def: &SchemaDefinition) -> Result<(), CliError> {
599    for fk in &def.foreign_keys {
600        // Source table
601        let src = def.tables.iter().find(|t| t.name == fk.table);
602        let Some(src) = src else {
603            return Err(CliError::Other(format!(
604                "Foreign key '{}' references unknown source table '{}'",
605                fk.name, fk.table
606            )));
607        };
608        for col_name in &fk.columns {
609            if !src.columns.iter().any(|c| &c.name == col_name) {
610                return Err(CliError::Other(format!(
611                    "Foreign key '{}' references unknown source column '{}.{}'",
612                    fk.name, fk.table, col_name
613                )));
614            }
615        }
616
617        // Target table
618        let tgt = def.tables.iter().find(|t| t.name == fk.table_to);
619        let Some(tgt) = tgt else {
620            return Err(CliError::Other(format!(
621                "Foreign key '{}' references unknown target table '{}'",
622                fk.name, fk.table_to
623            )));
624        };
625        for col_name in &fk.columns_to {
626            if !tgt.columns.iter().any(|c| &c.name == col_name) {
627                return Err(CliError::Other(format!(
628                    "Foreign key '{}' references unknown target column '{}.{}'",
629                    fk.name, fk.table_to, col_name
630                )));
631            }
632        }
633
634        // Validate FK actions
635        if !VALID_FK_ACTIONS.contains(&fk.on_delete.as_str()) {
636            return Err(CliError::Other(format!(
637                "Foreign key '{}': invalid on_delete action '{}'. Valid: {}",
638                fk.name,
639                fk.on_delete,
640                VALID_FK_ACTIONS.join(", ")
641            )));
642        }
643        if !VALID_FK_ACTIONS.contains(&fk.on_update.as_str()) {
644            return Err(CliError::Other(format!(
645                "Foreign key '{}': invalid on_update action '{}'. Valid: {}",
646                fk.name,
647                fk.on_update,
648                VALID_FK_ACTIONS.join(", ")
649            )));
650        }
651        if def.dialect == Dialect::Mysql
652            && (fk.on_delete == "Set Default" || fk.on_update == "Set Default")
653        {
654            return Err(CliError::Other(format!(
655                "Foreign key '{}': MySQL does not support SET DEFAULT",
656                fk.name
657            )));
658        }
659        if fk.columns.len() != fk.columns_to.len() {
660            return Err(CliError::Other(format!(
661                "Foreign key '{}': source and target column counts must match",
662                fk.name
663            )));
664        }
665    }
666    Ok(())
667}
668
669// ── Interactive collection ──────────────────────────────────────────────────
670
671fn collect_interactively(
672    config: Option<&Config>,
673    options: &NewOptions,
674) -> Result<SchemaDefinition, CliError> {
675    // Phase 1: Setup
676    let dialect = resolve_dialect(config, options.dialect)?;
677    let casing = prompt_casing()?;
678    let output_path = resolve_output_path(config, options.schema.clone())?;
679    let schema_name = prompt_schema_name()?;
680
681    // Phase 2: named PostgreSQL enums and inline MySQL enums.
682    let enums: Vec<EnumDef> = if matches!(dialect, Dialect::Postgresql | Dialect::Mysql) {
683        prompt_enums()?
684    } else {
685        Vec::new()
686    };
687
688    // Phase 3 & 4: Tables + Columns
689    let mut tables: Vec<TableDef> = Vec::new();
690    loop {
691        let table = prompt_table(dialect, &enums)?;
692        tables.push(table);
693        if !confirm("Add another table?", false)? {
694            break;
695        }
696    }
697
698    // Phase 5: Indexes
699    let indexes: Vec<IndexDef> = if confirm("Add indexes?", false)? {
700        prompt_indexes(&tables)?
701    } else {
702        Vec::new()
703    };
704
705    // Phase 6: Foreign Keys
706    let foreign_keys: Vec<ForeignKeyDef> =
707        if tables.len() > 1 && confirm("Add foreign keys?", false)? {
708            prompt_foreign_keys(&tables, dialect)?
709        } else {
710            Vec::new()
711        };
712
713    Ok(SchemaDefinition {
714        dialect,
715        casing,
716        schema_name,
717        output_path,
718        enums,
719        tables,
720        indexes,
721        foreign_keys,
722    })
723}
724
725// ── Phase 1: Setup prompts ──────────────────────────────────────────────────
726
727fn resolve_dialect(
728    config: Option<&Config>,
729    cli_dialect: Option<Dialect>,
730) -> Result<Dialect, CliError> {
731    if let Some(d) = cli_dialect {
732        return Ok(d);
733    }
734    if let Some(c) = config {
735        return Ok(c.dialect());
736    }
737    let options = vec!["SQLite", "PostgreSQL", "MySQL"];
738    let answer = Select::new("Select database dialect:", options)
739        .prompt()
740        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
741    match answer {
742        "SQLite" => Ok(Dialect::Sqlite),
743        "PostgreSQL" => Ok(Dialect::Postgresql),
744        "MySQL" => Ok(Dialect::Mysql),
745        _ => unreachable!(),
746    }
747}
748
749fn prompt_casing() -> Result<FieldCasing, CliError> {
750    let options = vec!["snake_case (default)", "camelCase"];
751    let answer = Select::new("Select field casing:", options)
752        .prompt()
753        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
754    match answer {
755        s if s.starts_with("snake") => Ok(FieldCasing::Snake),
756        s if s.starts_with("camel") => Ok(FieldCasing::Camel),
757        _ => Ok(FieldCasing::Snake),
758    }
759}
760
761fn resolve_output_path(
762    config: Option<&Config>,
763    cli_schema: Option<String>,
764) -> Result<String, CliError> {
765    if let Some(s) = cli_schema {
766        return Ok(s);
767    }
768    let default = config.map_or_else(
769        || "src/schema.rs".to_string(),
770        super::super::config::Config::schema_display,
771    );
772    Text::new("Schema output path:")
773        .with_default(&default)
774        .prompt()
775        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))
776}
777
778fn prompt_schema_name() -> Result<String, CliError> {
779    Text::new("Schema struct name:")
780        .with_default("AppSchema")
781        .prompt()
782        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))
783}
784
785// ── Phase 2: Enums (PostgreSQL only) ────────────────────────────────────────
786
787fn prompt_enums() -> Result<Vec<EnumDef>, CliError> {
788    let mut enums = Vec::new();
789    if !confirm("Define any enums?", false)? {
790        return Ok(enums);
791    }
792    loop {
793        let name = Text::new("Enum name:")
794            .with_validator(|s: &str| {
795                if s.is_empty() {
796                    Ok(Validation::Invalid("Name cannot be empty".into()))
797                } else if !is_valid_identifier(s) {
798                    Ok(Validation::Invalid(
799                        "Must be a valid Rust identifier".into(),
800                    ))
801                } else {
802                    Ok(Validation::Valid)
803                }
804            })
805            .prompt()
806            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
807
808        let mut variants = Vec::new();
809        loop {
810            let variant = Text::new("  Enum variant (empty to finish):")
811                .prompt()
812                .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
813            if variant.is_empty() {
814                break;
815            }
816            variants.push(variant);
817        }
818        if variants.is_empty() {
819            println!("  Skipping enum with no variants.");
820        } else {
821            enums.push(EnumDef { name, variants });
822        }
823        if !confirm("Add another enum?", false)? {
824            break;
825        }
826    }
827    Ok(enums)
828}
829
830// ── Phase 3 & 4: Tables + Columns ──────────────────────────────────────────
831
832fn prompt_table(dialect: Dialect, enums: &[EnumDef]) -> Result<TableDef, CliError> {
833    let name = Text::new("Table name:")
834        .with_validator(|s: &str| {
835            if s.is_empty() {
836                Ok(Validation::Invalid("Name cannot be empty".into()))
837            } else if !is_valid_identifier(s) {
838                Ok(Validation::Invalid(
839                    "Must be a valid Rust identifier (letters, digits, underscores)".into(),
840                ))
841            } else {
842                Ok(Validation::Valid)
843            }
844        })
845        .prompt()
846        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
847
848    let mut strict = false;
849    let mut without_rowid = false;
850    let mut pg_schema = "public".to_string();
851
852    match dialect {
853        Dialect::Sqlite | Dialect::Turso => {
854            let table_opts = vec!["strict", "without_rowid"];
855            let selected = MultiSelect::new("Table options (space to toggle):", table_opts)
856                .prompt()
857                .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
858            strict = selected.contains(&"strict");
859            without_rowid = selected.contains(&"without_rowid");
860        }
861        Dialect::Postgresql => {
862            pg_schema = Text::new("PostgreSQL schema:")
863                .with_default("public")
864                .prompt()
865                .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
866        }
867        Dialect::Mysql => {}
868    }
869
870    // Columns
871    let mut columns = Vec::new();
872    println!();
873    println!("  Define columns for '{name}':");
874    loop {
875        let col = prompt_column(dialect, enums)?;
876        columns.push(col);
877        if !confirm("  Add another column?", true)? {
878            break;
879        }
880    }
881
882    Ok(TableDef {
883        name,
884        columns,
885        strict,
886        without_rowid,
887        pg_schema,
888    })
889}
890
891fn prompt_column(dialect: Dialect, enums: &[EnumDef]) -> Result<ColumnDef, CliError> {
892    let col_name = Text::new("  Column name:")
893        .with_validator(|s: &str| {
894            if s.is_empty() {
895                Ok(Validation::Invalid("Name cannot be empty".into()))
896            } else if !is_valid_identifier(s) {
897                Ok(Validation::Invalid("Must be a valid identifier".into()))
898            } else {
899                Ok(Validation::Valid)
900            }
901        })
902        .prompt()
903        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
904
905    let (sql_type, enum_name) = prompt_type(dialect, enums)?;
906
907    let nullable = confirm("  Nullable (Option<T>)?", false)?;
908
909    let constraint_opts = match dialect {
910        Dialect::Sqlite | Dialect::Turso => {
911            vec!["Primary Key", "Autoincrement", "Unique", "Default value"]
912        }
913        Dialect::Postgresql => {
914            vec![
915                "Primary Key",
916                "Identity (auto-increment)",
917                "Unique",
918                "Default value",
919            ]
920        }
921        Dialect::Mysql => {
922            vec!["Primary Key", "Autoincrement", "Unique", "Default value"]
923        }
924    };
925    let selected = MultiSelect::new("  Column constraints (space to toggle):", constraint_opts)
926        .prompt()
927        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
928
929    let primary_key = selected.iter().any(|s| s.starts_with("Primary"));
930    let autoincrement = selected.iter().any(|s| s.starts_with("Autoincrement"));
931    let identity = selected.iter().any(|s| s.starts_with("Identity"));
932    let unique = selected.iter().any(|s| s.starts_with("Unique"));
933    let has_default = selected.iter().any(|s| s.starts_with("Default"));
934
935    let default = if has_default {
936        let val = Text::new("  Default value:")
937            .prompt()
938            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
939        Some(val)
940    } else {
941        None
942    };
943
944    let auto_gen = if autoincrement {
945        Some(AutoGenKind::Autoincrement)
946    } else if identity {
947        Some(AutoGenKind::Identity)
948    } else {
949        None
950    };
951
952    Ok(ColumnDef {
953        name: col_name,
954        sql_type,
955        not_null: !nullable,
956        primary_key,
957        unique,
958        default,
959        auto_gen,
960        enum_name,
961    })
962}
963
964fn prompt_type(dialect: Dialect, enums: &[EnumDef]) -> Result<(String, Option<String>), CliError> {
965    let mut options: Vec<String> = match dialect {
966        Dialect::Sqlite | Dialect::Turso => {
967            vec![
968                "i32".into(),
969                "i64".into(),
970                "f64".into(),
971                "String".into(),
972                "bool".into(),
973                "Vec<u8>".into(),
974            ]
975        }
976        Dialect::Postgresql => {
977            vec![
978                "i16".into(),
979                "i32".into(),
980                "i64".into(),
981                "f32".into(),
982                "f64".into(),
983                "String".into(),
984                "bool".into(),
985                "Vec<u8>".into(),
986                "uuid::Uuid".into(),
987                "chrono::NaiveDate".into(),
988                "chrono::NaiveDateTime".into(),
989                "chrono::DateTime<chrono::Utc>".into(),
990                "serde_json::Value".into(),
991            ]
992        }
993        Dialect::Mysql => {
994            vec![
995                "i8".into(),
996                "i16".into(),
997                "i32".into(),
998                "i64".into(),
999                "u8".into(),
1000                "u16".into(),
1001                "u32".into(),
1002                "u64".into(),
1003                "f32".into(),
1004                "f64".into(),
1005                "String".into(),
1006                "bool".into(),
1007                "Vec<u8>".into(),
1008                "chrono::NaiveDate".into(),
1009                "chrono::NaiveDateTime".into(),
1010                "serde_json::Value".into(),
1011            ]
1012        }
1013    };
1014
1015    // Append user-defined enums as type choices
1016    for e in enums {
1017        options.push(format!("enum:{}", e.name));
1018    }
1019
1020    let refs: Vec<&str> = options.iter().map(std::string::String::as_str).collect();
1021    let chosen = Select::new("  Rust type:", refs)
1022        .prompt()
1023        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1024
1025    // Map user-friendly Rust type -> SQL type string for codegen
1026    if let Some(enum_name) = chosen.strip_prefix("enum:") {
1027        // For enum columns, the sql_type is the enum name itself
1028        return Ok((enum_name.to_string(), Some(enum_name.to_string())));
1029    }
1030
1031    let sql_type = match dialect {
1032        Dialect::Sqlite | Dialect::Turso => match chosen {
1033            "i32" | "i64" => "integer",
1034            "f64" => "real",
1035            "bool" => "boolean",
1036            "Vec<u8>" => "blob",
1037            _ => "text",
1038        },
1039        Dialect::Postgresql => match chosen {
1040            "i16" => "int2",
1041            "i32" => "int4",
1042            "i64" => "int8",
1043            "f32" => "float4",
1044            "f64" => "float8",
1045            "bool" => "bool",
1046            "Vec<u8>" => "bytea",
1047            "uuid::Uuid" => "uuid",
1048            "chrono::NaiveDate" => "date",
1049            "chrono::NaiveDateTime" => "timestamp",
1050            "chrono::DateTime<chrono::Utc>" => "timestamptz",
1051            "serde_json::Value" => "jsonb",
1052            _ => "text",
1053        },
1054        Dialect::Mysql => match chosen {
1055            "i8" => "tinyint",
1056            "u8" => "tinyint unsigned",
1057            "i16" => "smallint",
1058            "u16" => "smallint unsigned",
1059            "i32" => "int",
1060            "u32" => "int unsigned",
1061            "i64" => "bigint",
1062            "u64" => "bigint unsigned",
1063            "f32" => "float",
1064            "f64" => "double",
1065            "bool" => "boolean",
1066            "Vec<u8>" => "blob",
1067            "chrono::NaiveDate" => "date",
1068            "chrono::NaiveDateTime" => "datetime",
1069            "serde_json::Value" => "json",
1070            _ => "text",
1071        },
1072    };
1073
1074    Ok((sql_type.to_string(), None))
1075}
1076
1077// ── Phase 5: Indexes ────────────────────────────────────────────────────────
1078
1079fn prompt_indexes(tables: &[TableDef]) -> Result<Vec<IndexDef>, CliError> {
1080    let mut indexes = Vec::new();
1081    loop {
1082        let table_names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
1083        let table_name = Select::new("Index on which table?", table_names)
1084            .prompt()
1085            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1086
1087        let table = tables.iter().find(|t| t.name == table_name).unwrap();
1088        let col_names: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
1089
1090        if col_names.is_empty() {
1091            println!("  Table has no columns, skipping.");
1092            if !confirm("Add another index?", false)? {
1093                break;
1094            }
1095            continue;
1096        }
1097
1098        let selected_cols = MultiSelect::new("Select columns for index:", col_names)
1099            .prompt()
1100            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1101
1102        if selected_cols.is_empty() {
1103            println!("  No columns selected, skipping.");
1104            if !confirm("Add another index?", false)? {
1105                break;
1106            }
1107            continue;
1108        }
1109
1110        let is_unique = confirm("  Unique index?", false)?;
1111
1112        let suggested_name = format!("{}_{}_idx", table_name, selected_cols.join("_"));
1113        let idx_name = Text::new("  Index name:")
1114            .with_default(&suggested_name)
1115            .prompt()
1116            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1117
1118        indexes.push(IndexDef {
1119            name: idx_name,
1120            table: table_name.to_string(),
1121            columns: selected_cols
1122                .into_iter()
1123                .map(std::string::ToString::to_string)
1124                .collect(),
1125            unique: is_unique,
1126            pg_schema: table.pg_schema.clone(),
1127        });
1128
1129        if !confirm("Add another index?", false)? {
1130            break;
1131        }
1132    }
1133    Ok(indexes)
1134}
1135
1136// ── Phase 6: Foreign Keys ───────────────────────────────────────────────────
1137
1138fn prompt_foreign_keys(
1139    tables: &[TableDef],
1140    dialect: Dialect,
1141) -> Result<Vec<ForeignKeyDef>, CliError> {
1142    let mut fks = Vec::new();
1143    let action_options = vec![
1144        "No Action",
1145        "Cascade",
1146        "Set Null",
1147        "Set Default",
1148        "Restrict",
1149    ];
1150    loop {
1151        let table_names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
1152
1153        let src_table_name = Select::new("Source table:", table_names.clone())
1154            .prompt()
1155            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1156        let src_table = tables.iter().find(|t| t.name == src_table_name).unwrap();
1157        let src_col_names: Vec<&str> = src_table.columns.iter().map(|c| c.name.as_str()).collect();
1158
1159        let src_cols = MultiSelect::new("Source column(s):", src_col_names)
1160            .prompt()
1161            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1162
1163        let tgt_table_name = Select::new("Target (referenced) table:", table_names)
1164            .prompt()
1165            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1166        let tgt_table = tables.iter().find(|t| t.name == tgt_table_name).unwrap();
1167        let tgt_col_names: Vec<&str> = tgt_table.columns.iter().map(|c| c.name.as_str()).collect();
1168
1169        let tgt_cols = MultiSelect::new("Target column(s):", tgt_col_names)
1170            .prompt()
1171            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1172
1173        let on_delete = Select::new("ON DELETE action:", action_options.clone())
1174            .prompt()
1175            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1176
1177        let on_update = Select::new("ON UPDATE action:", action_options.clone())
1178            .prompt()
1179            .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))?;
1180
1181        let fk_name = format!("{}_{}_fk", src_table_name, src_cols.join("_"));
1182
1183        let pg_schema_to = match dialect {
1184            Dialect::Postgresql => tgt_table.pg_schema.clone(),
1185            _ => String::new(),
1186        };
1187
1188        fks.push(ForeignKeyDef {
1189            name: fk_name,
1190            table: src_table_name.to_string(),
1191            columns: src_cols
1192                .into_iter()
1193                .map(std::string::ToString::to_string)
1194                .collect(),
1195            table_to: tgt_table_name.to_string(),
1196            columns_to: tgt_cols
1197                .into_iter()
1198                .map(std::string::ToString::to_string)
1199                .collect(),
1200            on_delete: on_delete.to_string(),
1201            on_update: on_update.to_string(),
1202            pg_schema: src_table.pg_schema.clone(),
1203            pg_schema_to,
1204        });
1205
1206        if !confirm("Add another foreign key?", false)? {
1207            break;
1208        }
1209    }
1210    Ok(fks)
1211}
1212
1213// ── Phase 7: Code generation ────────────────────────────────────────────────
1214
1215fn generate_sqlite(
1216    tables: &[TableDef],
1217    indexes: &[IndexDef],
1218    fks: &[ForeignKeyDef],
1219    schema_name: &str,
1220    casing: FieldCasing,
1221) -> String {
1222    use drizzle_migrations::sqlite::codegen;
1223    use drizzle_migrations::sqlite::collection::SQLiteDDL;
1224    use drizzle_types::sqlite::ddl::{
1225        Column, ForeignKey, Index, IndexColumn, PrimaryKey, Table, UniqueConstraint,
1226    };
1227
1228    let mut ddl = SQLiteDDL::new();
1229
1230    for (table_idx, table) in tables.iter().enumerate() {
1231        let mut t = Table::new(table.name.clone());
1232        if table.strict {
1233            t = t.strict();
1234        }
1235        if table.without_rowid {
1236            t = t.without_rowid();
1237        }
1238        ddl.tables.push(t);
1239
1240        let mut pk_cols: Vec<String> = Vec::new();
1241        let mut unique_cols: Vec<String> = Vec::new();
1242
1243        for (col_idx, col) in table.columns.iter().enumerate() {
1244            let mut column =
1245                Column::new(table.name.clone(), col.name.clone(), col.sql_type.clone());
1246            if col.not_null {
1247                column = column.not_null();
1248            }
1249            if col.is_autoincrement() {
1250                column = column.autoincrement();
1251            }
1252            if let Some(ref default) = col.default {
1253                column = column.default_value(default.clone());
1254            }
1255            // Set ordinal position to preserve order
1256            column.ordinal_position = Some(
1257                i32::try_from(col_idx)
1258                    .ok()
1259                    .and_then(|i| i.checked_add(1))
1260                    .unwrap_or(i32::MAX),
1261            );
1262            ddl.columns.push(column);
1263
1264            if col.primary_key {
1265                pk_cols.push(col.name.clone());
1266            }
1267            if col.unique {
1268                unique_cols.push(col.name.clone());
1269            }
1270        }
1271
1272        if !pk_cols.is_empty() {
1273            ddl.pks.push(PrimaryKey::from_strings(
1274                table.name.clone(),
1275                format!("{}_pk", table.name),
1276                pk_cols,
1277            ));
1278        }
1279        for uc in unique_cols {
1280            ddl.uniques.push(UniqueConstraint::from_strings(
1281                table.name.clone(),
1282                format!("{}_{}_unique", table.name, uc),
1283                vec![uc],
1284            ));
1285        }
1286
1287        // Drop the table_idx binding explicitly
1288        let _ = table_idx;
1289    }
1290
1291    // Add indexes
1292    for idx in indexes {
1293        let columns: Vec<IndexColumn> = idx
1294            .columns
1295            .iter()
1296            .map(|c| IndexColumn::new(c.clone()))
1297            .collect();
1298        let mut index = Index::new(idx.table.clone(), idx.name.clone(), columns);
1299        if idx.unique {
1300            index = index.unique();
1301        }
1302        ddl.indexes.push(index);
1303    }
1304
1305    // Add foreign keys
1306    for fk in fks {
1307        let mut foreign_key = ForeignKey::from_strings(
1308            fk.table.clone(),
1309            fk.name.clone(),
1310            fk.columns.clone(),
1311            fk.table_to.clone(),
1312            fk.columns_to.clone(),
1313        );
1314        if fk.on_delete != "No Action" {
1315            foreign_key = foreign_key.on_delete(fk.on_delete.to_uppercase());
1316        }
1317        if fk.on_update != "No Action" {
1318            foreign_key = foreign_key.on_update(fk.on_update.to_uppercase());
1319        }
1320        ddl.fks.push(foreign_key);
1321    }
1322
1323    let field_casing = match casing {
1324        FieldCasing::Snake => codegen::FieldCasing::Snake,
1325        FieldCasing::Camel => codegen::FieldCasing::Camel,
1326    };
1327
1328    let options = codegen::CodegenOptions {
1329        module_doc: Some("Generated by `drizzle new`".to_string()),
1330        include_schema: true,
1331        schema_name: schema_name.to_string(),
1332        use_pub: true,
1333        field_casing,
1334    };
1335
1336    codegen::generate_rust_schema(&ddl, &options).code
1337}
1338
1339fn generate_mysql(
1340    tables: &[TableDef],
1341    indexes: &[IndexDef],
1342    fks: &[ForeignKeyDef],
1343    enums: &[EnumDef],
1344    schema_name: &str,
1345    casing: FieldCasing,
1346) -> Result<String, CliError> {
1347    use drizzle_migrations::mysql::codegen;
1348    use drizzle_migrations::mysql::collection::MySQLDDL;
1349    use drizzle_migrations::mysql::ddl::{
1350        Column, ForeignKey, Index, IndexColumn, InlineEnum, InlineType, PrimaryKey, Table,
1351        UniqueConstraint,
1352    };
1353
1354    let enum_values = enums
1355        .iter()
1356        .map(|definition| (definition.name.as_str(), definition.variants.as_slice()))
1357        .collect::<std::collections::HashMap<_, _>>();
1358    let mut ddl = MySQLDDL::new();
1359
1360    for table in tables {
1361        ddl.tables.push(Table::new(table.name.clone()));
1362        let mut primary_columns = Vec::new();
1363
1364        for column_definition in &table.columns {
1365            let mut column = Column::new(
1366                table.name.clone(),
1367                column_definition.name.clone(),
1368                column_definition.sql_type.clone(),
1369            );
1370            column.not_null = column_definition.not_null;
1371            column.autoincrement = column_definition.is_autoincrement();
1372            column.default = column_definition
1373                .default
1374                .as_ref()
1375                .map(|value| Cow::Owned(value.clone()));
1376            if let Some(enum_name) = column_definition.enum_name.as_deref()
1377                && let Some(values) = enum_values.get(enum_name)
1378            {
1379                column.inline_type =
1380                    Some(InlineType::Enum(InlineEnum::new(values.iter().cloned())));
1381            }
1382            ddl.columns.push(column);
1383
1384            if column_definition.primary_key {
1385                primary_columns.push(column_definition.name.clone());
1386            }
1387            if column_definition.unique {
1388                ddl.uniques.push(UniqueConstraint::new(
1389                    table.name.clone(),
1390                    format!("{}_{}_unique", table.name, column_definition.name),
1391                    [column_definition.name.clone()],
1392                ));
1393            }
1394        }
1395
1396        if !primary_columns.is_empty() {
1397            ddl.pks
1398                .push(PrimaryKey::new(table.name.clone(), primary_columns));
1399        }
1400    }
1401
1402    for definition in indexes {
1403        let columns = definition
1404            .columns
1405            .iter()
1406            .cloned()
1407            .map(IndexColumn::column)
1408            .collect();
1409        let mut index = Index::new(definition.table.clone(), definition.name.clone(), columns);
1410        index.unique = definition.unique;
1411        ddl.indexes.push(index);
1412    }
1413
1414    for definition in fks {
1415        let mut foreign_key = ForeignKey::new(
1416            definition.table.clone(),
1417            definition.name.clone(),
1418            definition.columns.clone(),
1419            definition.table_to.clone(),
1420            definition.columns_to.clone(),
1421        );
1422        foreign_key.on_delete = mysql_referential_action(&definition.on_delete);
1423        foreign_key.on_update = mysql_referential_action(&definition.on_update);
1424        ddl.fks.push(foreign_key);
1425    }
1426
1427    let options = codegen::CodegenOptions {
1428        module_doc: Some("Generated by `drizzle new`".to_string()),
1429        include_schema: true,
1430        schema_name: schema_name.to_string(),
1431        use_pub: true,
1432        field_casing: match casing {
1433            FieldCasing::Snake => codegen::FieldCasing::Snake,
1434            FieldCasing::Camel => codegen::FieldCasing::Camel,
1435        },
1436    };
1437    codegen::generate_rust_schema(&ddl, &options)
1438        .map(|generated| generated.code)
1439        .map_err(|error| CliError::Other(format!("Cannot generate MySQL schema: {error}")))
1440}
1441
1442fn mysql_referential_action(
1443    action: &str,
1444) -> Option<drizzle_migrations::mysql::ddl::ReferentialAction> {
1445    use drizzle_migrations::mysql::ddl::ReferentialAction;
1446
1447    match action {
1448        "Cascade" => Some(ReferentialAction::Cascade),
1449        "Set Null" => Some(ReferentialAction::SetNull),
1450        "Restrict" => Some(ReferentialAction::Restrict),
1451        "No Action" => Some(ReferentialAction::NoAction),
1452        // MySQL doesn't support SET DEFAULT, and validation rejects it for
1453        // MySQL before generation.
1454        _ => None,
1455    }
1456}
1457
1458fn generate_postgres(
1459    tables: &[TableDef],
1460    indexes: &[IndexDef],
1461    fks: &[ForeignKeyDef],
1462    enums: &[EnumDef],
1463    schema_name: &str,
1464    casing: FieldCasing,
1465) -> String {
1466    use drizzle_migrations::postgres::codegen;
1467    use drizzle_migrations::postgres::collection::PostgresDDL;
1468    use drizzle_types::postgres::ddl::{Enum, Table};
1469
1470    let mut ddl = PostgresDDL::new();
1471
1472    // Add enums
1473    for e in enums {
1474        let values: Vec<Cow<'static, str>> =
1475            e.variants.iter().map(|v| Cow::Owned(v.clone())).collect();
1476        ddl.enums.push(Enum::new(
1477            "public",
1478            Cow::<str>::Owned(e.name.clone()),
1479            Cow::<[Cow<'static, str>]>::Owned(values),
1480        ));
1481    }
1482
1483    // Add tables + columns
1484    for table in tables {
1485        ddl.tables
1486            .push(Table::new(table.pg_schema.clone(), table.name.clone()));
1487        add_postgres_table_columns(&mut ddl, table);
1488    }
1489
1490    add_postgres_indexes(&mut ddl, indexes);
1491    add_postgres_foreign_keys(&mut ddl, fks);
1492
1493    let field_casing = match casing {
1494        FieldCasing::Snake => codegen::FieldCasing::Snake,
1495        FieldCasing::Camel => codegen::FieldCasing::Camel,
1496    };
1497
1498    let options = codegen::CodegenOptions {
1499        module_doc: Some("Generated by `drizzle new`".to_string()),
1500        include_schema: true,
1501        schema_name: schema_name.to_string(),
1502        use_pub: true,
1503        field_casing,
1504    };
1505
1506    codegen::generate_rust_schema(&ddl, &options).code
1507}
1508
1509/// Populate `ddl.columns`, `ddl.pks`, `ddl.uniques` for a single postgres table.
1510fn add_postgres_table_columns(
1511    ddl: &mut drizzle_migrations::postgres::collection::PostgresDDL,
1512    table: &TableDef,
1513) {
1514    use drizzle_types::postgres::ddl::{Column, PrimaryKey, UniqueConstraint};
1515
1516    let mut pk_cols: Vec<String> = Vec::new();
1517    let mut unique_cols: Vec<String> = Vec::new();
1518
1519    for (col_idx, col) in table.columns.iter().enumerate() {
1520        let mut column = Column::new(
1521            table.pg_schema.clone(),
1522            table.name.clone(),
1523            col.name.clone(),
1524            col.sql_type.clone(),
1525        );
1526        if col.not_null {
1527            column = column.not_null();
1528        }
1529        if let Some(ref default) = col.default {
1530            column = column.default_value(default.clone());
1531        }
1532        if col.is_identity() {
1533            use drizzle_types::postgres::ddl::Identity;
1534            let seq_name = format!("{}_{}_seq", table.name, col.name);
1535            column.identity = Some(Identity::always(seq_name));
1536        }
1537        if col.enum_name.is_some() {
1538            // Set type_schema so codegen can find it in the enum_map
1539            column.type_schema = Some(Cow::Owned(table.pg_schema.clone()));
1540        }
1541        column.ordinal_position = Some(
1542            i32::try_from(col_idx)
1543                .ok()
1544                .and_then(|i| i.checked_add(1))
1545                .unwrap_or(i32::MAX),
1546        );
1547        ddl.columns.push(column);
1548
1549        if col.primary_key {
1550            pk_cols.push(col.name.clone());
1551        }
1552        if col.unique {
1553            unique_cols.push(col.name.clone());
1554        }
1555    }
1556
1557    if !pk_cols.is_empty() {
1558        ddl.pks.push(PrimaryKey::from_strings(
1559            table.pg_schema.clone(),
1560            table.name.clone(),
1561            format!("{}_pk", table.name),
1562            pk_cols,
1563        ));
1564    }
1565    for uc in unique_cols {
1566        ddl.uniques.push(UniqueConstraint::from_strings(
1567            table.pg_schema.clone(),
1568            table.name.clone(),
1569            format!("{}_{}_unique", table.name, uc),
1570            vec![uc],
1571        ));
1572    }
1573}
1574
1575/// Append index definitions to the postgres DDL collection.
1576fn add_postgres_indexes(
1577    ddl: &mut drizzle_migrations::postgres::collection::PostgresDDL,
1578    indexes: &[IndexDef],
1579) {
1580    use drizzle_types::postgres::ddl::{Index, IndexColumn};
1581    for idx in indexes {
1582        let columns: Vec<IndexColumn> = idx
1583            .columns
1584            .iter()
1585            .map(|c| IndexColumn::new(c.clone()))
1586            .collect();
1587        let mut index = Index::new(
1588            idx.pg_schema.clone(),
1589            idx.table.clone(),
1590            idx.name.clone(),
1591            columns,
1592        );
1593        if idx.unique {
1594            index = index.unique();
1595        }
1596        ddl.indexes.push(index);
1597    }
1598}
1599
1600/// Append foreign-key definitions to the postgres DDL collection.
1601fn add_postgres_foreign_keys(
1602    ddl: &mut drizzle_migrations::postgres::collection::PostgresDDL,
1603    fks: &[ForeignKeyDef],
1604) {
1605    use drizzle_types::postgres::ddl::ForeignKey;
1606    for fk in fks {
1607        let mut foreign_key = ForeignKey::from_strings(
1608            fk.pg_schema.clone(),
1609            fk.table.clone(),
1610            fk.name.clone(),
1611            fk.columns.clone(),
1612            fk.pg_schema_to.clone(),
1613            fk.table_to.clone(),
1614            fk.columns_to.clone(),
1615        );
1616        if fk.on_delete != "No Action" {
1617            foreign_key = foreign_key.on_delete(fk.on_delete.to_uppercase());
1618        }
1619        if fk.on_update != "No Action" {
1620            foreign_key = foreign_key.on_update(fk.on_update.to_uppercase());
1621        }
1622        ddl.fks.push(foreign_key);
1623    }
1624}
1625
1626// ── Utility helpers ─────────────────────────────────────────────────────────
1627
1628fn confirm(message: &str, default: bool) -> Result<bool, CliError> {
1629    Confirm::new(message)
1630        .with_default(default)
1631        .prompt()
1632        .map_err(|e| CliError::Other(format!("Prompt cancelled: {e}")))
1633}
1634
1635fn is_valid_identifier(s: &str) -> bool {
1636    if s.is_empty() {
1637        return false;
1638    }
1639    let mut chars = s.chars();
1640    let first = chars.next().unwrap();
1641    if !first.is_ascii_alphabetic() && first != '_' {
1642        return false;
1643    }
1644    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1645}
1646
1647// ── Tests ───────────────────────────────────────────────────────────────────
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::*;
1652
1653    fn minimal_sqlite_def() -> SchemaDefinition {
1654        SchemaDefinition {
1655            dialect: Dialect::Sqlite,
1656            casing: FieldCasing::Snake,
1657            schema_name: "TestSchema".into(),
1658            output_path: "src/schema.rs".into(),
1659            enums: vec![],
1660            tables: vec![TableDef {
1661                name: "users".into(),
1662                columns: vec![ColumnDef {
1663                    name: "id".into(),
1664                    sql_type: "integer".into(),
1665                    not_null: true,
1666                    primary_key: true,
1667                    unique: false,
1668                    default: None,
1669                    auto_gen: None,
1670                    enum_name: None,
1671                }],
1672                strict: false,
1673                without_rowid: false,
1674                pg_schema: String::new(),
1675            }],
1676            indexes: vec![],
1677            foreign_keys: vec![],
1678        }
1679    }
1680
1681    fn minimal_mysql_def() -> SchemaDefinition {
1682        let mut definition = minimal_sqlite_def();
1683        definition.dialect = Dialect::Mysql;
1684        definition.enums = vec![EnumDef {
1685            name: "status".into(),
1686            variants: vec!["active".into(), "disabled".into()],
1687        }];
1688        definition.tables[0].columns[0].sql_type = "bigint unsigned".into();
1689        definition.tables[0].columns[0].auto_gen = Some(AutoGenKind::Autoincrement);
1690        definition.tables[0].columns.push(ColumnDef {
1691            name: "status".into(),
1692            sql_type: "status".into(),
1693            not_null: true,
1694            primary_key: false,
1695            unique: true,
1696            default: Some("'active'".into()),
1697            auto_gen: None,
1698            enum_name: Some("status".into()),
1699        });
1700        definition
1701    }
1702
1703    #[test]
1704    fn validate_minimal_schema() {
1705        let def = minimal_sqlite_def();
1706        assert!(validate_schema(&def).is_ok());
1707    }
1708
1709    #[test]
1710    fn mysql_generation_uses_mysql_macros_and_dialect_features() {
1711        let definition = minimal_mysql_def();
1712        validate_schema(&definition).expect("valid MySQL definition");
1713        let code = generate_mysql(
1714            &definition.tables,
1715            &definition.indexes,
1716            &definition.foreign_keys,
1717            &definition.enums,
1718            &definition.schema_name,
1719            definition.casing,
1720        )
1721        .expect("valid MySQL definition");
1722
1723        assert!(code.contains("MySQLTable"), "{code}");
1724        assert!(code.contains("MySQLEnum"), "{code}");
1725        assert!(code.contains("AUTO_INCREMENT"), "{code}");
1726        assert!(code.contains("UNIQUE"), "{code}");
1727        assert!(!code.contains("SQLiteTable"), "{code}");
1728        assert!(!code.contains("PostgresTable"), "{code}");
1729    }
1730
1731    #[test]
1732    fn mysql_rejects_set_default_foreign_keys() {
1733        let mut definition = minimal_mysql_def();
1734        definition.tables.push(TableDef {
1735            name: "posts".into(),
1736            columns: vec![ColumnDef {
1737                name: "user_id".into(),
1738                sql_type: "bigint unsigned".into(),
1739                not_null: true,
1740                primary_key: false,
1741                unique: false,
1742                default: None,
1743                auto_gen: None,
1744                enum_name: None,
1745            }],
1746            strict: false,
1747            without_rowid: false,
1748            pg_schema: String::new(),
1749        });
1750        definition.foreign_keys.push(ForeignKeyDef {
1751            name: "posts_user_fk".into(),
1752            table: "posts".into(),
1753            columns: vec!["user_id".into()],
1754            table_to: "users".into(),
1755            columns_to: vec!["id".into()],
1756            on_delete: "Set Default".into(),
1757            on_update: "No Action".into(),
1758            pg_schema: String::new(),
1759            pg_schema_to: String::new(),
1760        });
1761
1762        let error = validate_schema(&definition).expect_err("SET DEFAULT is invalid in MySQL");
1763        assert!(error.to_string().contains("does not support SET DEFAULT"));
1764    }
1765
1766    #[test]
1767    fn validate_rejects_empty_tables() {
1768        let mut def = minimal_sqlite_def();
1769        def.tables.clear();
1770        let err = validate_schema(&def).unwrap_err();
1771        assert!(err.to_string().contains("at least one table"));
1772    }
1773
1774    #[test]
1775    fn validate_rejects_duplicate_table_names() {
1776        let mut def = minimal_sqlite_def();
1777        def.tables.push(def.tables[0].clone());
1778        let err = validate_schema(&def).unwrap_err();
1779        assert!(err.to_string().contains("Duplicate table name"));
1780    }
1781
1782    #[test]
1783    fn validate_rejects_empty_columns() {
1784        let mut def = minimal_sqlite_def();
1785        def.tables[0].columns.clear();
1786        let err = validate_schema(&def).unwrap_err();
1787        assert!(err.to_string().contains("at least one column"));
1788    }
1789
1790    #[test]
1791    fn validate_rejects_duplicate_column_names() {
1792        let mut def = minimal_sqlite_def();
1793        let dup = def.tables[0].columns[0].clone();
1794        def.tables[0].columns.push(dup);
1795        let err = validate_schema(&def).unwrap_err();
1796        assert!(err.to_string().contains("Duplicate column name"));
1797    }
1798
1799    #[test]
1800    fn validate_rejects_identity_on_sqlite() {
1801        let mut def = minimal_sqlite_def();
1802        def.tables[0].columns[0].auto_gen = Some(AutoGenKind::Identity);
1803        let err = validate_schema(&def).unwrap_err();
1804        assert!(err.to_string().contains("identity"));
1805        assert!(err.to_string().contains("PostgreSQL"));
1806    }
1807
1808    #[test]
1809    fn validate_rejects_autoincrement_on_postgres() {
1810        let mut def = minimal_sqlite_def();
1811        def.dialect = Dialect::Postgresql;
1812        def.tables[0].columns[0].auto_gen = Some(AutoGenKind::Autoincrement);
1813        let err = validate_schema(&def).unwrap_err();
1814        assert!(err.to_string().contains("autoincrement"));
1815        assert!(err.to_string().contains("SQLite"));
1816    }
1817
1818    #[test]
1819    fn validate_rejects_strict_on_postgres() {
1820        let mut def = minimal_sqlite_def();
1821        def.dialect = Dialect::Postgresql;
1822        def.tables[0].strict = true;
1823        let err = validate_schema(&def).unwrap_err();
1824        assert!(err.to_string().contains("strict"));
1825        assert!(err.to_string().contains("SQLite"));
1826    }
1827
1828    #[test]
1829    fn validate_rejects_enums_on_sqlite() {
1830        let mut def = minimal_sqlite_def();
1831        def.enums.push(EnumDef {
1832            name: "status".into(),
1833            variants: vec!["active".into()],
1834        });
1835        let err = validate_schema(&def).unwrap_err();
1836        assert!(err.to_string().contains("Enums"));
1837        assert!(err.to_string().contains("PostgreSQL"));
1838    }
1839
1840    #[test]
1841    fn validate_rejects_unknown_enum_reference() {
1842        let mut def = minimal_sqlite_def();
1843        def.dialect = Dialect::Postgresql;
1844        def.tables[0].columns[0].enum_name = Some("nonexistent".into());
1845        let err = validate_schema(&def).unwrap_err();
1846        assert!(err.to_string().contains("unknown enum"));
1847    }
1848
1849    #[test]
1850    fn validate_rejects_bad_fk_table_ref() {
1851        let mut def = minimal_sqlite_def();
1852        def.foreign_keys.push(ForeignKeyDef {
1853            name: "test_fk".into(),
1854            table: "nonexistent".into(),
1855            columns: vec!["id".into()],
1856            table_to: "users".into(),
1857            columns_to: vec!["id".into()],
1858            on_delete: "No Action".into(),
1859            on_update: "No Action".into(),
1860            pg_schema: String::new(),
1861            pg_schema_to: String::new(),
1862        });
1863        let err = validate_schema(&def).unwrap_err();
1864        assert!(err.to_string().contains("unknown source table"));
1865    }
1866
1867    #[test]
1868    fn validate_rejects_bad_fk_action() {
1869        let mut def = minimal_sqlite_def();
1870        def.tables.push(TableDef {
1871            name: "posts".into(),
1872            columns: vec![ColumnDef {
1873                name: "user_id".into(),
1874                sql_type: "integer".into(),
1875                not_null: true,
1876                primary_key: false,
1877                unique: false,
1878                default: None,
1879                auto_gen: None,
1880                enum_name: None,
1881            }],
1882            strict: false,
1883            without_rowid: false,
1884            pg_schema: String::new(),
1885        });
1886        def.foreign_keys.push(ForeignKeyDef {
1887            name: "posts_user_id_fk".into(),
1888            table: "posts".into(),
1889            columns: vec!["user_id".into()],
1890            table_to: "users".into(),
1891            columns_to: vec!["id".into()],
1892            on_delete: "INVALID".into(),
1893            on_update: "No Action".into(),
1894            pg_schema: String::new(),
1895            pg_schema_to: String::new(),
1896        });
1897        let err = validate_schema(&def).unwrap_err();
1898        assert!(err.to_string().contains("invalid on_delete"));
1899    }
1900
1901    #[test]
1902    fn validate_rejects_bad_index_column_ref() {
1903        let mut def = minimal_sqlite_def();
1904        def.indexes.push(IndexDef {
1905            name: "test_idx".into(),
1906            table: "users".into(),
1907            columns: vec!["nonexistent".into()],
1908            unique: false,
1909            pg_schema: String::new(),
1910        });
1911        let err = validate_schema(&def).unwrap_err();
1912        assert!(err.to_string().contains("unknown column"));
1913    }
1914
1915    #[test]
1916    fn json_round_trip() {
1917        let def = minimal_sqlite_def();
1918        let json = serde_json::to_string_pretty(&def).unwrap();
1919        let parsed: SchemaDefinition = serde_json::from_str(&json).unwrap();
1920        assert_eq!(parsed.dialect, def.dialect);
1921        assert_eq!(parsed.tables.len(), 1);
1922        assert_eq!(parsed.tables[0].name, "users");
1923        assert_eq!(parsed.tables[0].columns[0].name, "id");
1924    }
1925
1926    #[test]
1927    fn json_defaults_applied() {
1928        let json = r#"{
1929            "dialect": "sqlite",
1930            "tables": [{
1931                "name": "items",
1932                "columns": [{"name": "id", "sql_type": "integer"}]
1933            }]
1934        }"#;
1935        let def: SchemaDefinition = serde_json::from_str(json).unwrap();
1936        assert_eq!(def.schema_name, "AppSchema");
1937        assert_eq!(def.output_path, "src/schema.rs");
1938        assert!(def.enums.is_empty());
1939        assert!(def.indexes.is_empty());
1940        assert!(def.foreign_keys.is_empty());
1941        assert!(!def.tables[0].columns[0].not_null);
1942        assert!(!def.tables[0].columns[0].primary_key);
1943    }
1944
1945    #[test]
1946    fn json_fk_action_defaults() {
1947        let json = r#"{
1948            "name": "test_fk",
1949            "table": "a",
1950            "columns": ["x"],
1951            "table_to": "b",
1952            "columns_to": ["y"]
1953        }"#;
1954        let fk: ForeignKeyDef = serde_json::from_str(json).unwrap();
1955        assert_eq!(fk.on_delete, "No Action");
1956        assert_eq!(fk.on_update, "No Action");
1957    }
1958}