Skip to main content

pgmt/commands/init/
commands.rs

1use anyhow::Result;
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use super::InitArgs;
6use super::import::{ImportSource, import_schema};
7use super::project::{create_project_structure, generate_config_file};
8use super::prompts::{gather_init_options_with_args, prompt_baseline_creation};
9use crate::baseline::operations::{
10    BaselineCreationRequest, create_baseline, display_baseline_summary,
11};
12use crate::catalog::Catalog;
13use crate::config::load_config;
14use crate::constants::CONFIG_FILENAME;
15use crate::db::connection::mask_url_password;
16use crate::migration_tracking;
17use crate::prompts::ShadowDatabaseInput;
18
19/// Result of checking for existing configuration
20#[derive(Debug)]
21pub enum ExistingConfigResult {
22    /// No existing config found - proceed with fresh init
23    NotFound,
24    /// User chose to update existing config - provides loaded config
25    Update(Box<crate::config::types::ConfigInput>),
26    /// User chose fresh init - overwrite existing config
27    Fresh,
28    /// User cancelled the operation
29    Cancelled,
30}
31
32/// Check for existing config file and prompt user for how to proceed
33pub fn check_existing_config(
34    project_dir: &Path,
35    force_fresh: bool,
36) -> Result<ExistingConfigResult> {
37    let config_path = project_dir.join(CONFIG_FILENAME);
38
39    if !config_path.exists() {
40        return Ok(ExistingConfigResult::NotFound);
41    }
42
43    // If --fresh flag was passed, skip prompting
44    if force_fresh {
45        println!(
46            "āš ļø  Existing {} will be overwritten (--fresh flag)\n",
47            CONFIG_FILENAME
48        );
49        return Ok(ExistingConfigResult::Fresh);
50    }
51
52    // Load existing config
53    let config_path_str = config_path.to_string_lossy();
54    let (existing_config, _) = load_config(&config_path_str)?;
55
56    // Show current configuration
57    let databases = existing_config.databases.as_ref();
58    let directories = existing_config.directories.as_ref();
59    println!("šŸ“‹ Existing configuration found:\n");
60    if let Some(url) = databases.and_then(|d| d.dev_url.as_ref()) {
61        println!("   Database: {}", mask_url_password(url));
62    }
63    if let Some(schema_dir) = directories.and_then(|d| d.schema_dir.as_ref()) {
64        println!("   Schema dir: {}", schema_dir);
65    }
66    if let Some(migrations_dir) = directories.and_then(|d| d.migrations_dir.as_ref()) {
67        println!("   Migrations: {}", migrations_dir);
68    }
69    if let Some(baselines_dir) = directories.and_then(|d| d.baselines_dir.as_ref()) {
70        println!("   Baselines: {}", baselines_dir);
71    }
72    if let Some(pg_version) = databases
73        .and_then(|d| d.shadow.as_ref())
74        .and_then(|s| s.docker.as_ref())
75        .and_then(|d| d.version.as_ref())
76    {
77        println!("   Shadow PG: {}", pg_version);
78    }
79    println!();
80
81    // Prompt user for action
82    let choices = vec![
83        "Update - modify existing configuration",
84        "Fresh - start over with new configuration",
85        "Cancel - keep current configuration",
86    ];
87
88    let selection = dialoguer::Select::new()
89        .with_prompt("What would you like to do?")
90        .items(&choices)
91        .default(0)
92        .interact()?;
93
94    match selection {
95        0 => {
96            println!("\nāœļø  Update mode: existing values will be shown as defaults\n");
97            Ok(ExistingConfigResult::Update(Box::new(existing_config)))
98        }
99        1 => {
100            println!("\nšŸ”„ Fresh mode: creating new configuration\n");
101            Ok(ExistingConfigResult::Fresh)
102        }
103        _ => {
104            println!("\nāŒ Keeping existing configuration");
105            Ok(ExistingConfigResult::Cancelled)
106        }
107    }
108}
109
110/// Complete configuration for project initialization
111#[derive(Debug)]
112pub struct InitOptions {
113    pub project_dir: std::path::PathBuf,
114    pub dev_database_url: String,
115    pub shadow_config: ShadowDatabaseInput,
116    /// PostgreSQL version for auto shadow database (e.g., "14", "15", "16")
117    /// If None, uses detected_pg_version from dev database
118    pub shadow_pg_version: Option<String>,
119    /// PostgreSQL version detected from dev database connection (e.g., "15.4")
120    pub detected_pg_version: Option<String>,
121    pub schema_dir: std::path::PathBuf,
122    pub migrations_dir: String,
123    pub baselines_dir: String,
124    pub import_source: Option<ImportSource>,
125    pub object_config: ObjectManagementConfig,
126    pub baseline_config: BaselineCreationConfig,
127    #[allow(dead_code)]
128    pub tracking_table: crate::config::types::TrackingTable,
129    /// Path to roles file (None means no roles file, Some("roles.sql") means auto-detected or explicit)
130    pub roles_file: Option<String>,
131    /// Managed-object scoping (from an existing pgmt.yaml on re-init, defaults
132    /// otherwise). Scopes the shadow clean during schema import.
133    pub objects: crate::config::types::Objects,
134    /// Image-provided substrate schemas the user chose to exclude during
135    /// import; persisted to the generated pgmt.yaml's exclude list.
136    pub substrate_exclusions: Vec<String>,
137}
138
139/// Configuration options for what database objects to manage
140#[derive(Debug, Clone)]
141pub struct ObjectManagementConfig {
142    pub comments: bool,
143    pub grants: bool,
144    pub triggers: bool,
145    pub extensions: bool,
146}
147
148impl Default for ObjectManagementConfig {
149    fn default() -> Self {
150        Self {
151            comments: true,
152            grants: true,
153            triggers: true,
154            extensions: true,
155        }
156    }
157}
158
159/// Configuration for baseline creation during init
160#[derive(Debug, Clone, Default)]
161pub struct BaselineCreationConfig {
162    /// Whether to create baseline: None = prompt user, Some(true/false) = explicit
163    pub create_baseline: Option<bool>,
164    /// Custom description for baseline
165    pub description: Option<String>,
166}
167
168/// Command with CLI arguments for non-interactive mode
169pub async fn cmd_init_with_args(args: &InitArgs) -> Result<()> {
170    println!("šŸš€ Welcome to pgmt! Let's set up your PostgreSQL migration project.\n");
171
172    // Check for existing config before proceeding
173    let project_dir = std::env::current_dir()?;
174    let existing_config = check_existing_config(&project_dir, args.fresh)?;
175
176    // Handle the different init modes
177    let existing_input = match existing_config {
178        ExistingConfigResult::NotFound | ExistingConfigResult::Fresh => None,
179        ExistingConfigResult::Update(config) => Some(*config),
180        ExistingConfigResult::Cancelled => {
181            return Ok(());
182        }
183    };
184
185    // Gather configuration through prompts or CLI args (WITHOUT object management yet)
186    let mut options = gather_init_options_with_args(args, existing_input.as_ref()).await?;
187
188    // Show confirmation summary and get user approval (unless using defaults)
189    if !args.defaults {
190        let confirmed = super::prompts::prompt_project_confirmation(&options)?;
191        if !confirmed {
192            println!("āŒ Project initialization cancelled by user.");
193            return Ok(());
194        }
195    }
196
197    // Step 1: Create directories only (no config file yet)
198    println!("šŸ—ļø  Creating project structure...");
199    create_project_structure(&options)?;
200    println!("āœ… Project directories created");
201
202    // Step 2: Import existing schema catalog (just fetch, don't process yet)
203    let catalog = if let Some(import_source) = options.import_source.clone() {
204        match import_catalog_from_source(&import_source, &options).await? {
205            Some((catalog, substrate_exclusions)) => {
206                if !substrate_exclusions.is_empty() {
207                    options
208                        .objects
209                        .exclude
210                        .schemas
211                        .extend(substrate_exclusions.iter().cloned());
212                    options.substrate_exclusions = substrate_exclusions;
213                }
214                // Single filtering point: preview, generated files, and
215                // validation all see the catalog scoped by the objects config
216                // (existing config plus any substrate exclusions just chosen).
217                let filter = crate::config::filter::ObjectFilter::new(
218                    &options.objects,
219                    &options.tracking_table,
220                );
221                Some(filter.filter_catalog(catalog))
222            }
223            None => None,
224        }
225    } else {
226        None
227    };
228
229    // Step 3: Show preview and ask object management WITH context (if interactive)
230    if let Some(ref cat) = catalog {
231        // Show import preview
232        show_catalog_preview(cat);
233
234        // Ask object management questions with catalog context (if interactive)
235        if !args.defaults {
236            options.object_config =
237                super::prompts::prompt_object_management_config_with_context(cat)?;
238        }
239    } else if !args.defaults {
240        // No catalog, ask without context
241        options.object_config = super::prompts::prompt_object_management_config()?;
242    }
243
244    // Step 4: Process the catalog (baseline, generate, validate, create)
245    let baseline_result = if let Some(ref cat) = catalog {
246        process_imported_catalog(cat, &options).await?
247    } else {
248        BaselineResult::NotRequested
249    };
250
251    // Step 5: Write config file LAST (now we have all the information)
252    println!("šŸ“ Generating configuration file...");
253    generate_config_file(&options, existing_input.as_ref(), &options.project_dir)?;
254    println!("āœ… pgmt.yaml created");
255
256    // Success summary
257    print_success_summary(&options, &baseline_result);
258
259    Ok(())
260}
261
262/// Convert prompts::ShadowDatabaseInput + version to config::types::ShadowDatabase
263fn resolve_shadow_database(
264    shadow_config: &ShadowDatabaseInput,
265    shadow_pg_version: Option<&String>,
266    detected_pg_version: Option<&String>,
267) -> crate::config::types::ShadowDatabase {
268    use crate::config::types::{ShadowDatabase, ShadowDockerConfig};
269
270    match shadow_config {
271        ShadowDatabaseInput::Auto => {
272            // CLI takes precedence, then detected version, then default
273            let version = shadow_pg_version.or(detected_pg_version);
274            if let Some(v) = version {
275                let major_version = crate::prompts::extract_major_version(v);
276                ShadowDatabase::Docker(ShadowDockerConfig {
277                    version: Some(major_version),
278                    ..Default::default()
279                })
280            } else {
281                ShadowDatabase::Auto
282            }
283        }
284        ShadowDatabaseInput::Docker { image, platform } => {
285            ShadowDatabase::Docker(ShadowDockerConfig {
286                image: image.clone(),
287                platform: platform.clone(),
288                ..Default::default()
289            })
290        }
291        ShadowDatabaseInput::Manual(url) => ShadowDatabase::Url {
292            url: url.clone(),
293            reset: crate::config::types::ShadowResetMode::default(),
294        },
295    }
296}
297
298/// Import catalog from source without processing it yet
299/// Returns the catalog for later processing
300async fn import_catalog_from_source(
301    import_source: &ImportSource,
302    options: &InitOptions,
303) -> Result<Option<(Catalog, Vec<String>)>> {
304    use crate::config::types::{ShadowDatabase, ShadowResetMode};
305
306    println!("šŸ“„ Importing existing schema...");
307    println!("   Source: {}", import_source.description());
308
309    // Convert ShadowDatabaseInput to ShadowDatabase for import
310    let shadow_database = resolve_shadow_database(
311        &options.shadow_config,
312        options.shadow_pg_version.as_ref(),
313        options.detected_pg_version.as_ref(),
314    );
315
316    let sql_source = matches!(
317        import_source,
318        ImportSource::SqlFile(_) | ImportSource::Directory(_)
319    );
320
321    // Importing from a SQL source resets the shadow database first. For a
322    // Docker shadow that's a throwaway branch, but a clean-mode external URL
323    // is a database the user controls — confirm before dropping schemas in it.
324    if let ShadowDatabase::Url {
325        url,
326        reset: ShadowResetMode::Clean,
327    } = &shadow_database
328        && sql_source
329    {
330        println!(
331            "āš ļø  The shadow database at {} will be reset: every schema pgmt manages will be dropped before the import.",
332            crate::db::connection::mask_url_password(url)
333        );
334        let confirmed = dialoguer::Confirm::new()
335            .with_prompt("   Reset this database and continue?")
336            .default(false)
337            .interact()?;
338        if !confirmed {
339            return Err(anyhow::anyhow!(
340                "import cancelled — shadow database left untouched"
341            ));
342        }
343    }
344
345    // Provision the shadow now (live-database imports don't need one): SQL
346    // sources apply onto a fresh branch, and what already exists on that
347    // branch is the image-provided substrate.
348    let (shadow_url, substrate_exclusions) = if sql_source {
349        let shadow_url = shadow_database.get_connection_string().await?;
350
351        // Substrate is only trustworthy on branch-backed shadows: a clean-mode
352        // external database may hold leftovers from earlier runs.
353        let branch_backed = !matches!(
354            shadow_database,
355            ShadowDatabase::Url {
356                reset: ShadowResetMode::Clean,
357                ..
358            }
359        );
360        let exclusions = if branch_backed {
361            let substrate = fetch_substrate_schemas(&shadow_url).await?;
362            if substrate.is_empty() {
363                Vec::new()
364            } else {
365                super::prompts::prompt_substrate_exclusions(&substrate)?
366            }
367        } else {
368            Vec::new()
369        };
370        (shadow_url, exclusions)
371    } else {
372        (String::new(), Vec::new())
373    };
374
375    // Resolve roles file path for import (roles must exist before schema GRANTs)
376    let roles_path = options
377        .roles_file
378        .as_ref()
379        .map(|f| options.project_dir.join(f));
380
381    match import_schema(
382        import_source.clone(),
383        &shadow_url,
384        roles_path.as_deref(),
385        &options.objects,
386    )
387    .await
388    {
389        Ok(catalog) => {
390            println!("āœ… Schema import completed");
391            Ok(Some((catalog, substrate_exclusions)))
392        }
393        Err(e) => {
394            // Use {:#} to show the full error chain including the root cause
395            eprintln!("\nāš ļø  Schema import failed:\n{:#}", e);
396            eprintln!("\nšŸ”§ What would you like to do?");
397
398            let recovery_options = vec![
399                "Skip import and continue with empty project",
400                "Exit setup (you can run 'pgmt init' again later)",
401            ];
402
403            let choice = dialoguer::Select::new()
404                .with_prompt("Choose an option")
405                .items(&recovery_options)
406                .default(0)
407                .interact()?;
408
409            match choice {
410                0 => {
411                    println!(
412                        "āš ļø  Skipping schema import. You can add schema files manually later."
413                    );
414                    println!(
415                        "   šŸ’” Tip: You can also try importing again with 'pgmt apply' after setup."
416                    );
417                    eprintln!("   Continuing with empty project setup...");
418                    Ok(None)
419                }
420                1 => {
421                    println!("āŒ Setup cancelled. Run 'pgmt init' again when ready.");
422                    std::process::exit(1);
423                }
424                _ => Ok(None),
425            }
426        }
427    }
428}
429
430/// Schemas present on the freshly-branched shadow before any of the user's
431/// schema is applied: the image/baseline-provided substrate (PostGIS's tiger
432/// and topology, Supabase's auth and storage, …). `public` is never substrate
433/// — it always exists and is where managed objects usually live.
434pub async fn fetch_substrate_schemas(shadow_url: &str) -> Result<Vec<String>> {
435    let pool = crate::db::connection::connect_with_retry_quiet(shadow_url).await?;
436    let rows: Vec<(String,)> = sqlx::query_as(
437        "SELECT nspname FROM pg_namespace
438         WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
439           AND nspname NOT LIKE 'pg_temp_%'
440           AND nspname NOT LIKE 'pg_toast_temp_%'
441         ORDER BY nspname",
442    )
443    .fetch_all(&pool)
444    .await?;
445    pool.close().await;
446    Ok(rows.into_iter().map(|(n,)| n).collect())
447}
448
449/// Result of baseline creation during init
450#[derive(Debug, Clone)]
451pub enum BaselineResult {
452    /// Baseline was not requested
453    NotRequested,
454    /// Baseline was successfully created and synced
455    Created,
456    /// Validation found issues that need manual resolution (e.g., circular deps)
457    NeedsAttention { reason: String },
458    /// Baseline creation was requested but failed
459    Failed(String),
460}
461
462/// Process an imported catalog - generate files, validate, then ask about baseline
463async fn process_imported_catalog(
464    catalog: &Catalog,
465    options: &InitOptions,
466) -> Result<BaselineResult> {
467    let total_objects = count_catalog_objects(catalog);
468
469    if total_objects == 0 {
470        println!("āš ļø  No database objects found in the imported schema.");
471        println!("   Continuing with empty schema directory...");
472        return Ok(BaselineResult::NotRequested);
473    }
474
475    // Step 1: Generate schema files (no questions, just do it)
476    println!("\nšŸ“ Generating schema files from your database...");
477    let file_count = match generate_schema_files(catalog, options).await {
478        Ok(count) => count,
479        Err(e) => {
480            eprintln!("āŒ Schema file generation failed: {}", e);
481            return Ok(BaselineResult::Failed(e.to_string()));
482        }
483    };
484    println!("āœ… Generated {} schema files", file_count);
485
486    // Step 2: Validate schema files
487    println!("\nšŸ” Validating schema files...");
488    let schema_dir = options.project_dir.join(&options.schema_dir);
489    let roles_path = options
490        .roles_file
491        .as_ref()
492        .map(|f| options.project_dir.join(f));
493
494    match validate_schema_files(
495        &schema_dir,
496        roles_path.as_deref(),
497        &options.shadow_config,
498        options.shadow_pg_version.as_ref(),
499        options.detected_pg_version.as_ref(),
500    )
501    .await
502    {
503        Ok(_) => {
504            println!("āœ… Schema validation passed");
505        }
506        Err(e) => {
507            let error_str = format!("{:#}", e);
508
509            // Check if this is a circular dependency (expected for complex databases)
510            if error_str.contains("Circular dependency") {
511                println!("\nšŸ“Œ Circular dependency detected in schema files");
512                if let Some(cycle_info) = extract_circular_dep_info(&error_str) {
513                    println!("   {}", cycle_info);
514                }
515                println!();
516                println!("   This is common in complex databases with bidirectional foreign keys.");
517                println!("   To fix: move one foreign key to a separate file (e.g., constraints/)");
518                println!("   so the tables can be created before the constraint is added.");
519                return Ok(BaselineResult::NeedsAttention {
520                    reason: "Circular dependency detected".to_string(),
521                });
522            }
523
524            // Other validation failures are actual errors
525            println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
526            println!("āš ļø  SCHEMA VALIDATION FAILED");
527            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
528            println!("{}\n", error_str);
529            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
530            println!("Next steps:");
531            println!("  1. Fix dependencies in schema files (add '-- require:' statements)");
532            println!("  2. Test with: pgmt apply --dry-run");
533            println!("  3. Repeat until validation passes");
534            println!("  4. Create baseline: pgmt migrate baseline\n");
535            return Ok(BaselineResult::Failed(e.to_string()));
536        }
537    }
538
539    // Step 3: Schema is valid! Now ask about baseline
540    let database_state = analyze_database_state(catalog);
541    let should_create_baseline = match &options.baseline_config.create_baseline {
542        Some(true) => true,   // CLI --create-baseline
543        Some(false) => false, // CLI --no-baseline
544        None => {
545            // Interactive prompting based on database state
546            prompt_baseline_creation(&database_state)?
547        }
548    };
549
550    // Step 4: Create baseline if requested
551    if should_create_baseline {
552        let version = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
553        match create_baseline_with_migration_sync(catalog, options, version).await {
554            Ok((_baseline_path, _baseline_content)) => Ok(BaselineResult::Created),
555            Err(e) => {
556                handle_baseline_failure(&e);
557                Ok(BaselineResult::Failed(e.to_string()))
558            }
559        }
560    } else {
561        Ok(BaselineResult::NotRequested)
562    }
563}
564
565/// Count total objects in a catalog
566fn count_catalog_objects(catalog: &Catalog) -> usize {
567    catalog.tables.len()
568        + catalog.views.len()
569        + catalog.functions.len()
570        + catalog.types.len()
571        + catalog.sequences.len()
572        + catalog.indexes.len()
573        + catalog.constraints.len()
574        + catalog.triggers.len()
575        + catalog.extensions.len()
576        + catalog.grants.len()
577}
578
579/// Represents the state of a database for baseline decision making
580#[derive(Debug)]
581pub enum DatabaseState {
582    /// Database is empty or only has the migration tracking table
583    Empty,
584    /// Database has existing objects that should be captured in a baseline
585    Existing { object_count: usize },
586}
587
588/// Analyze database state to determine if it's empty or contains existing objects
589fn analyze_database_state(catalog: &Catalog) -> DatabaseState {
590    let total_objects = count_catalog_objects(catalog);
591
592    // Consider database empty if it has 1 or fewer objects
593    // (the migration tracking table is excluded from object counts by filtering)
594    if total_objects <= 1 {
595        DatabaseState::Empty
596    } else {
597        DatabaseState::Existing {
598            object_count: total_objects,
599        }
600    }
601}
602
603/// Show a preview of catalog contents
604fn show_catalog_preview(catalog: &Catalog) {
605    let total_objects = count_catalog_objects(catalog);
606
607    println!("\nšŸ“Š Schema Import Preview:");
608    println!("  šŸ“‹ {} tables", catalog.tables.len());
609    println!("  šŸ‘ {} views", catalog.views.len());
610    println!("  āš™ļø {} functions", catalog.functions.len());
611    println!("  šŸ·ļø {} custom types", catalog.types.len());
612    println!("  šŸ”¢ {} sequences", catalog.sequences.len());
613    println!("  šŸ“‡ {} indexes", catalog.indexes.len());
614    println!("  šŸ”— {} constraints", catalog.constraints.len());
615    println!("  ⚔  {} triggers", catalog.triggers.len());
616    println!("  🧩 {} extensions", catalog.extensions.len());
617    println!("  šŸ”‘ {} grants", catalog.grants.len());
618    println!("  ═══════════════════");
619    println!("  šŸ“¦ {} total objects", total_objects);
620}
621
622/// Extract the circular dependency cycle info from an error message
623/// Returns the cycle portion like "A.sql -> B.sql -> A.sql"
624fn extract_circular_dep_info(error_str: &str) -> Option<String> {
625    // Look for the cycle pattern in the error message
626    // Format: "Circular dependency detected: A -> B -> A"
627    if let Some(start) = error_str.find("Circular dependency detected:") {
628        let after_prefix = &error_str[start + "Circular dependency detected:".len()..];
629        // Take until end of line or end of string
630        let cycle = after_prefix
631            .lines()
632            .next()
633            .map(|s| s.trim().to_string())
634            .filter(|s| !s.is_empty());
635        return cycle;
636    }
637    None
638}
639
640/// Handle baseline creation failure with user-friendly error messages and guidance
641fn handle_baseline_failure(error: &anyhow::Error) {
642    println!("\nāŒ Baseline creation failed: {}", error);
643
644    if error.to_string().contains("relation") && error.to_string().contains("does not exist") {
645        println!("\nšŸ” This error often indicates missing function dependencies.");
646        println!("   Some functions may reference tables that haven't been loaded yet.");
647        println!("   This is a known limitation - see README for details.");
648        println!("\nšŸ’” Common fixes:");
649        println!("   • Add '-- require: tables/table_name.sql' to function files");
650        println!("   • Check function bodies for table references");
651        println!("   • Ensure proper loading order in your schema files");
652    } else {
653        println!("\nšŸ” Baseline creation encountered an error.");
654        println!("   This might be due to:");
655        println!("   • Missing dependencies between schema objects");
656        println!("   • Permission issues");
657        println!("   • Database connection problems");
658    }
659
660    println!("\nāš ļø  Skipping baseline creation due to errors.");
661    println!("šŸ’” After fixing the dependency issues, run: pgmt migrate baseline");
662}
663
664/// Create baseline from imported catalog during init and sync migration state
665async fn create_baseline_with_migration_sync(
666    catalog: &Catalog,
667    options: &InitOptions,
668    version: u64,
669) -> Result<(std::path::PathBuf, String)> {
670    println!("šŸ’¾ Creating baseline from current database state...");
671
672    // Create baseline using shared logic
673    let request = BaselineCreationRequest {
674        catalog: catalog.clone(),
675        version,
676        description: options
677            .baseline_config
678            .description
679            .clone()
680            .unwrap_or_else(|| "baseline".to_string()),
681        baselines_dir: options.project_dir.join(&options.baselines_dir),
682        verbose: false, // Less verbose for init context
683    };
684
685    let result = create_baseline(request).await?;
686
687    // Show custom success message for init context
688    println!(
689        "āœ… Created baseline: {}",
690        result.path.file_name().unwrap().to_str().unwrap()
691    );
692
693    // Show baseline summary using shared display function
694    display_baseline_summary(&result);
695
696    // Mark baseline as applied in migration tracking
697    println!("šŸ”„ Marking baseline as applied in migration tracking...");
698
699    // Connect to development database for migration tracking
700    use sqlx::PgPool;
701    let dev_pool = PgPool::connect(&options.dev_database_url).await?;
702
703    // Use default tracking table configuration (will be read from config later)
704    let tracking_table = crate::config::types::TrackingTable {
705        schema: "public".to_string(),
706        name: "pgmt_migrations".to_string(),
707    };
708
709    // Calculate checksum for baseline content
710    let checksum = migration_tracking::calculate_checksum(&result.baseline_sql);
711
712    // Record baseline as applied
713    migration_tracking::record_baseline_as_applied(
714        &dev_pool,
715        &tracking_table,
716        version,
717        &options
718            .baseline_config
719            .description
720            .clone()
721            .unwrap_or_else(|| "baseline".to_string()),
722        &checksum,
723    )
724    .await?;
725
726    println!("āœ… Baseline marked as applied in migration tracking");
727    println!("šŸ’” Future migrations will only contain NEW changes");
728
729    Ok((result.path, result.baseline_sql))
730}
731
732/// Print success summary at the end of initialization
733pub fn print_success_summary(options: &InitOptions, baseline_result: &BaselineResult) {
734    match baseline_result {
735        BaselineResult::Created => {
736            println!("\nšŸŽ‰ Project initialized successfully!");
737            println!("\nšŸ“ Created:");
738            println!("  āœ… pgmt.yaml (configuration)");
739            println!(
740                "  āœ… {} directory with modular files",
741                options.schema_dir.display()
742            );
743            println!("  āœ… migrations/ directory");
744            println!("  āœ… schema_baselines/ directory");
745            println!("  āœ… Initial baseline from existing database");
746
747            println!("\nNext steps:");
748            println!("  šŸš€ Run 'pgmt migrate new \"description\"' to create new migrations");
749            println!("  šŸ’” Future migrations will only contain NEW changes");
750        }
751        BaselineResult::NeedsAttention { reason } => {
752            println!("\nšŸŽ‰ Project initialized successfully!");
753            println!("\nšŸ“ Created:");
754            println!("  āœ… pgmt.yaml (configuration)");
755            println!(
756                "  āœ… {} directory with modular files",
757                options.schema_dir.display()
758            );
759            println!("  āœ… migrations/ directory");
760            println!("  āœ… schema_baselines/ directory");
761
762            println!("\nšŸ“Œ {}", reason);
763            println!("\nNext steps:");
764            println!(
765                "  1. Move one foreign key from the cycle to a separate file (e.g., schema/constraints/)"
766            );
767            println!("  2. Test with: pgmt apply --dry-run");
768            println!("  3. Create baseline: pgmt migrate baseline");
769            println!("  šŸ’» Run 'pgmt apply' to sync your dev database");
770            println!("  šŸš€ Run 'pgmt migrate new \"description\"' to create migrations");
771        }
772        BaselineResult::Failed(error) => {
773            // Validation failure - schema needs to be fixed
774            if error.contains("relation") || error.contains("does not exist") {
775                println!("\nāš ļø Project initialized - schema validation failed\n");
776                println!("šŸ“ Created:");
777                println!("   āœ… pgmt.yaml");
778                println!(
779                    "   āœ… {} (needs dependency fixes)",
780                    options.schema_dir.display()
781                );
782                println!("   āœ… migrations/");
783                println!("\nšŸ”§ Next steps:");
784                println!("   1. Fix schema dependencies (see error above)");
785                println!("   2. Test with: pgmt apply --dry-run");
786                println!("   3. Repeat until validation passes");
787                println!("   4. Create baseline: pgmt migrate baseline");
788            } else {
789                // Check if baseline was explicitly requested via CLI
790                let was_explicit_request =
791                    matches!(options.baseline_config.create_baseline, Some(true));
792
793                if was_explicit_request {
794                    println!("\nāš ļø Project partially initialized - baseline creation failed!");
795                    println!("\nšŸ“ Created:");
796                    println!("  āœ… pgmt.yaml (configuration)");
797                    println!(
798                        "  āœ… {} directory with modular files",
799                        options.schema_dir.display()
800                    );
801                    println!("  āœ… migrations/ directory");
802                    println!("  āœ… schema_baselines/ directory");
803                    println!("  āŒ Initial baseline creation failed: {}", error);
804
805                    println!("\nNext steps:");
806                    println!("  šŸ”§ Fix the baseline creation issue:");
807                    println!("     • Check database connectivity and permissions");
808                    println!("     • Review schema file dependencies");
809                    println!("     • Consider running 'pgmt migrate baseline' manually");
810                    println!("  šŸ’» Run 'pgmt apply' to sync your dev database");
811                    println!("  šŸš€ Run 'pgmt migrate new \"description\"' to create migrations");
812                } else {
813                    // Interactive prompt case - user chose baseline but it failed (non-validation)
814                    println!("\nšŸŽ‰ Project initialized successfully!");
815                    println!("\nšŸ“ Created:");
816                    println!("  āœ… pgmt.yaml (configuration)");
817                    println!(
818                        "  āœ… {} directory with modular files",
819                        options.schema_dir.display()
820                    );
821                    println!("  āœ… migrations/ directory");
822                    println!("  āœ… schema_baselines/ directory");
823                    println!("  āš ļø Baseline creation failed (see error above)");
824
825                    println!("\nNext steps:");
826                    println!("  šŸ’” Fix the issue and create baseline: pgmt migrate baseline");
827                    println!("  šŸ’» Run 'pgmt apply' to sync your dev database");
828                    println!("  šŸš€ Run 'pgmt migrate new \"description\"' to create migrations");
829                }
830            }
831        }
832        BaselineResult::NotRequested => {
833            println!("\nšŸŽ‰ Project initialized successfully!");
834            println!("\nšŸ“ Created:");
835            println!("  āœ… pgmt.yaml (configuration)");
836            println!(
837                "  āœ… {} directory with modular files",
838                options.schema_dir.display()
839            );
840            println!("  āœ… migrations/ directory");
841            println!("  āœ… schema_baselines/ directory");
842
843            println!("\nNext steps:");
844            println!("  šŸ’» Run 'pgmt apply' to sync your dev database");
845            println!(
846                "  šŸ“ Add schema files to {} and customize as needed",
847                options.schema_dir.display()
848            );
849            println!("  šŸš€ Run 'pgmt migrate new \"description\"' to create migrations");
850        }
851    }
852
853    println!("  šŸ“š Visit https://docs.pgmt.dev for more information");
854}
855
856/// Validate generated schema files by applying them to a shadow database
857async fn validate_schema_files(
858    schema_dir: &std::path::Path,
859    roles_file: Option<&std::path::Path>,
860    shadow_config: &ShadowDatabaseInput,
861    shadow_pg_version: Option<&String>,
862    detected_pg_version: Option<&String>,
863) -> Result<()> {
864    validate_schema_files_impl(
865        schema_dir,
866        roles_file,
867        shadow_config,
868        shadow_pg_version,
869        detected_pg_version,
870    )
871    .await
872}
873
874/// Implementation of schema validation
875async fn validate_schema_files_impl(
876    schema_dir: &std::path::Path,
877    roles_file: Option<&std::path::Path>,
878    shadow_config: &ShadowDatabaseInput,
879    shadow_pg_version: Option<&String>,
880    detected_pg_version: Option<&String>,
881) -> Result<()> {
882    use crate::db::cleaner;
883    use crate::db::connection::connect_with_retry;
884    use crate::db::schema_processor::{SchemaProcessor, SchemaProcessorConfig};
885
886    // Get shadow URL from in-memory config (no yaml file needed!)
887    let shadow_database =
888        resolve_shadow_database(shadow_config, shadow_pg_version, detected_pg_version);
889    let shadow_url = shadow_database.get_connection_string().await?;
890
891    // Connect to shadow database
892    let pool = connect_with_retry(&shadow_url).await?;
893
894    // Clean shadow database first
895    cleaner::clean_shadow_db(&pool, &crate::config::types::Objects::default()).await?;
896
897    // Apply roles file before schema files (if provided)
898    if let Some(roles_path) = roles_file
899        && roles_path.exists()
900    {
901        crate::schema_ops::apply_roles_file(&pool, roles_path).await?;
902    }
903
904    // Process schema directory (loads, orders, and applies all files)
905    // Note: clean_before_apply is false since we already cleaned above
906    let config = SchemaProcessorConfig {
907        verbose: false,            // Silent validation
908        clean_before_apply: false, // Already cleaned above
909        ..Default::default()
910    };
911    let processor = SchemaProcessor::new(pool.clone(), config);
912    processor.process_schema_directory(schema_dir).await?;
913
914    pool.close().await;
915    Ok(())
916}
917
918/// Generate modular schema files using the diffing-based schema generator
919async fn generate_schema_files(catalog: &Catalog, options: &InitOptions) -> Result<usize> {
920    use crate::schema_generator::{SchemaGenerator, SchemaGeneratorConfig};
921
922    let schema_path = options.project_dir.join(&options.schema_dir);
923    std::fs::create_dir_all(&schema_path)?;
924
925    // Configure schema generation based on object management settings
926    let config = SchemaGeneratorConfig {
927        include_comments: options.object_config.comments,
928        include_grants: options.object_config.grants,
929        include_triggers: options.object_config.triggers,
930        include_extensions: options.object_config.extensions,
931    };
932
933    // Create and run the diffing-based schema generator
934    let generator = SchemaGenerator::new(catalog.clone(), schema_path.clone(), config);
935    generator.generate_files()?;
936
937    // Count generated files
938    let file_count = count_generated_files(&schema_path)?;
939
940    Ok(file_count)
941}
942
943/// Count the number of files generated in the schema directory
944fn count_generated_files(schema_dir: &std::path::PathBuf) -> Result<usize> {
945    let mut count = 0;
946
947    if schema_dir.exists() {
948        for entry in std::fs::read_dir(schema_dir)? {
949            let entry = entry?;
950            let path = entry.path();
951
952            if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("sql") {
953                count += 1;
954            } else if path.is_dir() {
955                // Recursively count files in subdirectories
956                count += count_generated_files(&path)?;
957            }
958        }
959    }
960
961    Ok(count)
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    #[test]
969    fn test_object_management_config_default() {
970        let config = ObjectManagementConfig::default();
971        assert!(config.comments);
972        assert!(config.grants);
973        assert!(config.triggers);
974        assert!(config.extensions);
975    }
976
977    #[test]
978    fn test_count_catalog_objects() {
979        let catalog = Catalog::empty();
980        assert_eq!(count_catalog_objects(&catalog), 0);
981    }
982
983    #[test]
984    fn test_count_generated_files() {
985        use std::env;
986
987        let temp_dir = env::temp_dir().join("pgmt_test_count_files");
988        let _ = std::fs::remove_dir_all(&temp_dir);
989        std::fs::create_dir_all(&temp_dir).unwrap();
990
991        // Create test files
992        std::fs::write(temp_dir.join("test1.sql"), "SELECT 1;").unwrap();
993        std::fs::write(temp_dir.join("test2.sql"), "SELECT 2;").unwrap();
994        std::fs::write(temp_dir.join("readme.txt"), "Not SQL").unwrap();
995
996        let count = count_generated_files(&temp_dir).unwrap();
997        assert_eq!(count, 2); // Only .sql files are counted
998
999        // Clean up
1000        let _ = std::fs::remove_dir_all(&temp_dir);
1001    }
1002
1003    #[test]
1004    fn test_check_existing_config_not_found() {
1005        use std::env;
1006
1007        let temp_dir = env::temp_dir().join("pgmt_test_no_config");
1008        let _ = std::fs::remove_dir_all(&temp_dir);
1009        std::fs::create_dir_all(&temp_dir).unwrap();
1010
1011        // No config file - should return NotFound
1012        let result = check_existing_config(&temp_dir, false).unwrap();
1013        assert!(matches!(result, ExistingConfigResult::NotFound));
1014
1015        // Clean up
1016        let _ = std::fs::remove_dir_all(&temp_dir);
1017    }
1018
1019    #[test]
1020    fn test_check_existing_config_fresh_flag() {
1021        use std::env;
1022
1023        let temp_dir = env::temp_dir().join("pgmt_test_fresh_flag");
1024        let _ = std::fs::remove_dir_all(&temp_dir);
1025        std::fs::create_dir_all(&temp_dir).unwrap();
1026
1027        // Create a config file
1028        let config_content = r#"
1029databases:
1030  dev_url: postgres://localhost/test
1031"#;
1032        std::fs::write(temp_dir.join("pgmt.yaml"), config_content).unwrap();
1033
1034        // With --fresh flag, should return Fresh without prompting
1035        let result = check_existing_config(&temp_dir, true).unwrap();
1036        assert!(matches!(result, ExistingConfigResult::Fresh));
1037
1038        // Clean up
1039        let _ = std::fs::remove_dir_all(&temp_dir);
1040    }
1041}