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,
}
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;
for f in git::branch_migration_files(git, base_ref, dir)? {
floor = floor.max(f.version);
}
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);
}
}
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'.'
}
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)
}
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)
}
pub struct DirPlan {
pub role: String,
pub renames: Vec<Rename>,
pub reference_hits: Vec<(String, usize)>,
}
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; }
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)?;
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\")");
}
}