migrate-guard 0.1.0

Detect and auto-renumber colliding sqlx migration version numbers across git branches.
Documentation
//! Floor computation, reference rewriting, and renumber application.

use crate::config::{Config, DirSpec};
use crate::error::Result;
use crate::git::{self, GitCmd};
use crate::model::{plan_renumber, Rename};
use crate::MigrationFile;
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyMode {
    DryRun,
    Commit,
}

/// The floor above which this branch's new files must be numbered, for one dir.
/// `dir` is RELATIVE to the repo root. Max version across (a) base_ref's dir,
/// (b) every OTHER local branch's dir, (c) the working tree minus the topic's
/// own new files.
pub fn compute_floor(
    git: &GitCmd,
    base_ref: &str,
    current_branch: &str,
    dir: &Path,
    topic_new: &[MigrationFile],
) -> Result<u64> {
    let topic_names: std::collections::HashSet<_> =
        topic_new.iter().map(|f| f.filename.clone()).collect();

    let mut floor = 0u64;
    // (a) base ref
    for f in git::branch_migration_files(git, base_ref, dir)? {
        floor = floor.max(f.version);
    }
    // (b) other branches
    for b in git::local_branches(git)? {
        if b == current_branch {
            continue;
        }
        for f in git::branch_migration_files(git, &b, dir)? {
            floor = floor.max(f.version);
        }
    }
    // (c) working tree minus topic's own new files
    for f in crate::scan_dir("", &git.repo.join(dir))?.files {
        if !topic_names.contains(&f.filename) {
            floor = floor.max(f.version);
        }
    }
    Ok(floor)
}

fn is_fname_char(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}
fn is_fname_cont(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
}

/// Replace whole-filename occurrences of `old` with `new`. A match counts only
/// when it is not preceded by a filename char ([0-9A-Za-z_]) and not followed
/// by a filename-continuation char ([0-9A-Za-z_.]) — so `10002_x.sql` (digit
/// before) and `0002_x.sql.bak` (`.` after) are NOT matched when
/// old = `0002_x.sql`. Returns (rewritten_text, match_count).
fn replace_filename_token(text: &str, old: &str, new: &str) -> (String, usize) {
    let mut out = String::with_capacity(text.len());
    let mut last = 0usize;
    let mut count = 0usize;
    let bytes = text.as_bytes();
    for (idx, _) in text.match_indices(old) {
        let before_ok = idx == 0 || !is_fname_char(bytes[idx - 1]);
        let after = idx + old.len();
        let after_ok = after >= bytes.len() || !is_fname_cont(bytes[after]);
        if before_ok && after_ok {
            out.push_str(&text[last..idx]);
            out.push_str(new);
            last = after;
            count += 1;
        }
    }
    out.push_str(&text[last..]);
    (out, count)
}

/// Rewrite exact-filename references in files matched by `globs`, replacing
/// `old` with `new`. Returns (path, line_number) of each rewrite. In DryRun no
/// file is written.
pub fn rewrite_references(
    root: &Path,
    globs: &[String],
    old: &str,
    new: &str,
    mode: ApplyMode,
) -> Result<Vec<(String, usize)>> {
    let mut hits = Vec::new();
    for pattern in globs {
        let full = root.join(pattern);
        let entries = match glob::glob(&full.to_string_lossy()) {
            Ok(e) => e,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let text = std::fs::read_to_string(&entry)?;
            let (new_text, count) = replace_filename_token(&text, old, new);
            if count == 0 {
                continue;
            }
            for (i, line) in text.lines().enumerate() {
                if replace_filename_token(line, old, new).1 > 0 {
                    hits.push((entry.display().to_string(), i + 1));
                }
            }
            if mode == ApplyMode::Commit {
                std::fs::write(&entry, new_text)?;
            }
        }
    }
    Ok(hits)
}

/// The full plan for one directory: (renames, reference-rewrite hits).
pub struct DirPlan {
    pub role: String,
    pub renames: Vec<Rename>,
    pub reference_hits: Vec<(String, usize)>,
}

/// Compute + optionally apply the renumber across all configured dirs.
pub fn apply(
    root: &Path,
    cfg: &Config,
    current_branch: &str,
    mode: ApplyMode,
) -> Result<Vec<DirPlan>> {
    let gitcmd = GitCmd {
        program: cfg.git.clone(),
        repo: root.to_path_buf(),
    };
    let mut plans = Vec::new();

    for DirSpec { role, path } in &cfg.dirs {
        let abs_dir = root.join(path);
        if !abs_dir.exists() {
            continue; // tolerated-absent, like build.rs
        }
        // git functions take a repo-relative dir (Task 5 contract).
        let topic_new = git::topic_new_files(&gitcmd, &cfg.base_ref, path)?;
        let floor = compute_floor(&gitcmd, &cfg.base_ref, current_branch, path, &topic_new)?;
        let renames = plan_renumber(&topic_new, floor);

        let mut reference_hits = Vec::new();
        for r in &renames {
            let old = r.from.file_name().unwrap().to_string_lossy().into_owned();
            let new = r.to.file_name().unwrap().to_string_lossy().into_owned();
            reference_hits.extend(rewrite_references(
                root,
                &cfg.reference_globs,
                &old,
                &new,
                mode,
            )?);
            if mode == ApplyMode::Commit {
                let from = r
                    .from
                    .strip_prefix(root)
                    .unwrap_or(&r.from)
                    .to_string_lossy()
                    .into_owned();
                let to =
                    r.to.strip_prefix(root)
                        .unwrap_or(&r.to)
                        .to_string_lossy()
                        .into_owned();
                let out = std::process::Command::new(&cfg.git)
                    .args(["mv", &from, &to])
                    .current_dir(root)
                    .output()?;
                if !out.status.success() {
                    std::fs::rename(&r.from, &r.to)?;
                    // Stage the rename so it isn't left half-tracked.
                    let _ = std::process::Command::new(&cfg.git)
                        .args(["add", "-A"])
                        .current_dir(root)
                        .output();
                }
            }
        }
        plans.push(DirPlan {
            role: role.clone(),
            renames,
            reference_hits,
        });
    }
    Ok(plans)
}

#[cfg(test)]
mod tests {
    use super::replace_filename_token;

    #[test]
    fn rewrite_is_filename_boundary_aware() {
        let input = "a 0002_x.sql and 10002_x.sql and 0002_x.sql.bak end";
        let (out, n) = replace_filename_token(input, "0002_x.sql", "0003_x.sql");
        assert_eq!(n, 1, "only the standalone filename should match");
        assert!(
            out.contains("0003_x.sql and 10002_x.sql and 0002_x.sql.bak"),
            "got: {out}"
        );
    }

    #[test]
    fn rewrite_matches_in_quotes_and_paths() {
        let input = "include_str!(\"../../migrations/0002_x.sql\")";
        let (out, n) = replace_filename_token(input, "0002_x.sql", "0003_x.sql");
        assert_eq!(n, 1);
        assert_eq!(out, "include_str!(\"../../migrations/0003_x.sql\")");
    }
}