migrate 0.5.1

Generic file migration tool for applying ordered transformations to a project directory
Documentation
use anyhow::Result;
use chrono::Utc;
use std::path::Path;

use crate::baseline::{delete_baselined_migrations, DeletedItem};
use crate::executor::execute;
use crate::loader::discover_migrations;
use crate::state::{append_baseline, append_history, get_pending, read_history, Baseline};
use crate::ExecutionContext;

/// Apply all pending migrations
pub fn run(
    project_root: &Path,
    migrations_dir: &Path,
    dry_run: bool,
    create_baseline: bool,
    keep: bool,
) -> Result<()> {
    let project_root = if project_root.is_absolute() {
        project_root.to_path_buf()
    } else {
        std::env::current_dir()?.join(project_root)
    };

    let migrations_path = if migrations_dir.is_absolute() {
        migrations_dir.to_path_buf()
    } else {
        project_root.join(migrations_dir)
    };

    if !migrations_path.exists() {
        println!(
            "No migrations directory found at: {}",
            migrations_path.display()
        );
        return Ok(());
    }

    let available = discover_migrations(&migrations_path)?;
    let state = read_history(&migrations_path)?;
    let pending = get_pending(&available, &state);

    if pending.is_empty() {
        println!("No pending migrations.");

        // Even with no pending migrations, --baseline should clean up stale
        // migration files that are at or below the existing baseline version.
        if create_baseline && !keep {
            if let Some(baseline) = &state.baseline {
                let stale: Vec<_> = available
                    .iter()
                    .filter(|m| m.version.as_str() <= baseline.version.as_str())
                    .collect();

                if !stale.is_empty() {
                    if dry_run {
                        let asset_dir_count = stale
                            .iter()
                            .filter(|m| {
                                m.file_path
                                    .parent()
                                    .map(|p| p.join(&m.id).is_dir())
                                    .unwrap_or(false)
                            })
                            .count();
                        if asset_dir_count > 0 {
                            println!(
                                "Would delete {} stale migration file(s) and {} asset directory(ies)",
                                stale.len(),
                                asset_dir_count
                            );
                        } else {
                            println!("Would delete {} stale migration file(s)", stale.len());
                        }
                    } else {
                        let deleted = delete_baselined_migrations(&baseline.version, &available)?;
                        let (files, dirs): (Vec<&DeletedItem>, Vec<&DeletedItem>) =
                            deleted.iter().partition(|d| !d.is_directory);
                        if !files.is_empty() {
                            println!("Deleted {} stale migration file(s)", files.len());
                        }
                        if !dirs.is_empty() {
                            println!("Deleted {} stale asset directory(ies)", dirs.len());
                        }
                    }
                }
            }
        }

        return Ok(());
    }

    println!(
        "{} {} migration(s)...",
        if dry_run { "Would apply" } else { "Applying" },
        pending.len()
    );
    println!();

    let mut last_applied_version: Option<String> = None;

    for migration in &pending {
        println!("{}", migration.id);

        if dry_run {
            println!("  (dry run - skipped)");
            last_applied_version = Some(migration.version.clone());
            continue;
        }

        let ctx = ExecutionContext {
            project_root: project_root.clone(),
            migrations_dir: migrations_path.clone(),
            migration_id: migration.id.clone(),
            dry_run,
        };

        let result = execute(migration, &ctx)?;

        if result.success {
            let applied_at = Utc::now();
            append_history(&migrations_path, &migration.id, applied_at)?;
            last_applied_version = Some(migration.version.clone());
            println!("  ✓ completed");
        } else {
            println!("  ✗ failed (exit code {})", result.exit_code);
            if let Some(error) = result.error {
                println!("    {}", error);
            }
            return Err(anyhow::anyhow!(
                "Migration {} failed with exit code {}",
                migration.id,
                result.exit_code
            ));
        }
    }

    println!();
    println!("All migrations applied successfully.");

    // Handle --baseline flag
    if create_baseline {
        if let Some(version) = last_applied_version {
            println!();
            if dry_run {
                println!("Would create baseline at version '{}'", version);
                if !keep {
                    let to_delete: Vec<_> = available
                        .iter()
                        .filter(|m| m.version.as_str() <= version.as_str())
                        .collect();
                    if !to_delete.is_empty() {
                        let asset_dir_count = to_delete
                            .iter()
                            .filter(|m| {
                                m.file_path
                                    .parent()
                                    .map(|p| p.join(&m.id).is_dir())
                                    .unwrap_or(false)
                            })
                            .count();
                        if asset_dir_count > 0 {
                            println!(
                                "Would delete {} migration file(s) and {} asset directory(ies)",
                                to_delete.len(),
                                asset_dir_count
                            );
                        } else {
                            println!("Would delete {} migration file(s)", to_delete.len());
                        }
                    }
                }
            } else {
                let new_baseline = Baseline {
                    version: version.clone(),
                    created: Utc::now(),
                    summary: None,
                };

                append_baseline(&migrations_path, &new_baseline)?;
                println!("Created baseline at version '{}'", version);

                if !keep {
                    let deleted = delete_baselined_migrations(&version, &available)?;
                    let (files, dirs): (Vec<&DeletedItem>, Vec<&DeletedItem>) =
                        deleted.iter().partition(|d| !d.is_directory);
                    if !files.is_empty() {
                        println!("Deleted {} migration file(s)", files.len());
                    }
                    if !dirs.is_empty() {
                        println!("Deleted {} asset directory(ies)", dirs.len());
                    }
                }
            }
        }
    }

    Ok(())
}