migrate-guard 0.1.0

Detect and auto-renumber colliding sqlx migration version numbers across git branches.
Documentation
use clap::{Parser, Subcommand};
use migrate_guard::apply::{apply, ApplyMode};
use migrate_guard::config::Config;
use migrate_guard::git::{self, GitCmd};
use migrate_guard::model::{detect_collisions, Observation, Source};
use std::path::PathBuf;
use std::process::ExitCode;

/// Invoked as `cargo migrate-guard <cmd>`, so argv[1] is "migrate-guard".
#[derive(Parser)]
#[command(bin_name = "cargo migrate-guard")]
struct Cargo {
    #[command(subcommand)]
    inner: Inner,
}

#[derive(Subcommand)]
enum Inner {
    MigrateGuard(Cli),
}

#[derive(Parser)]
struct Cli {
    /// Repo root (defaults to CWD).
    #[arg(long, default_value = ".")]
    root: PathBuf,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Detect version collisions across the working tree + local branches.
    Check,
    /// Renumber this branch's new migrations above the global max.
    Renumber {
        #[arg(long)]
        base: Option<String>,
        #[arg(long)]
        apply: bool,
    },
    /// Print the current max version per role.
    Max,
}

fn main() -> ExitCode {
    let Cargo {
        inner: Inner::MigrateGuard(cli),
    } = Cargo::parse();
    match run(cli) {
        Ok(code) => code,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::from(2)
        }
    }
}

fn current_branch(git: &GitCmd) -> String {
    std::process::Command::new(&git.program)
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .current_dir(&git.repo)
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "HEAD".into())
}

fn run(cli: Cli) -> migrate_guard::error::Result<ExitCode> {
    let cfg = Config::load(&cli.root)?;
    let gitcmd = GitCmd {
        program: cfg.git.clone(),
        repo: cli.root.clone(),
    };
    let cur = current_branch(&gitcmd);

    match cli.cmd {
        Cmd::Max => {
            for d in &cfg.dirs {
                let files = migrate_guard::scan_dir(&d.role, &cli.root.join(&d.path))?;
                println!(
                    "{}: {}",
                    d.role,
                    migrate_guard::model::max_version(&files.files)
                );
            }
            Ok(ExitCode::SUCCESS)
        }
        Cmd::Check => {
            let mut obs: Vec<Observation> = Vec::new();
            let branches = git::local_branches(&gitcmd)?;
            for d in &cfg.dirs {
                // scan_dir wants the real filesystem path (absolute);
                // branch_migration_files wants the repo-RELATIVE pathspec.
                let abs_dir = cli.root.join(&d.path);
                for f in migrate_guard::scan_dir(&d.role, &abs_dir)?.files {
                    obs.push(Observation {
                        role: d.role.clone(),
                        version: f.version,
                        source: Source::WorkingTree,
                        file: f,
                    });
                }
                for b in branches.iter().filter(|b| **b != cur) {
                    for f in git::branch_migration_files(&gitcmd, b, &d.path)? {
                        obs.push(Observation {
                            role: d.role.clone(),
                            version: f.version,
                            source: Source::Branch(b.clone()),
                            file: f,
                        });
                    }
                }
            }
            let collisions = detect_collisions(&obs);
            if collisions.is_empty() {
                println!("no migration version collisions");
                Ok(ExitCode::SUCCESS)
            } else {
                for c in &collisions {
                    eprintln!("COLLISION [{}] version {}:", c.role, c.version);
                    for o in &c.occurrences {
                        eprintln!("  {:?}  {}", o.source, o.path.display());
                    }
                }
                Ok(ExitCode::from(1))
            }
        }
        Cmd::Renumber {
            base,
            apply: do_apply,
        } => {
            let mut cfg = cfg;
            if let Some(b) = base {
                cfg.base_ref = b;
            }
            let mode = if do_apply {
                ApplyMode::Commit
            } else {
                ApplyMode::DryRun
            };
            let plans = apply(&cli.root, &cfg, &cur, mode)?;
            let mut any = false;
            for p in &plans {
                for r in &p.renames {
                    any = true;
                    println!("[{}] {} -> {}", p.role, r.from.display(), r.to.display());
                }
                for (f, line) in &p.reference_hits {
                    println!("    ref {f}:{line}");
                }
            }
            if !any {
                println!("nothing to renumber");
            } else if !do_apply {
                println!("(dry run — pass --apply to write)");
            }
            Ok(ExitCode::SUCCESS)
        }
    }
}