1use crate::config::{Config, DirSpec};
4use crate::error::Result;
5use crate::git::{self, GitCmd};
6use crate::model::{plan_renumber, Rename};
7use crate::MigrationFile;
8use std::path::Path;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ApplyMode {
12 DryRun,
13 Commit,
14}
15
16pub fn compute_floor(
21 git: &GitCmd,
22 base_ref: &str,
23 current_branch: &str,
24 dir: &Path,
25 topic_new: &[MigrationFile],
26) -> Result<u64> {
27 let topic_names: std::collections::HashSet<_> =
28 topic_new.iter().map(|f| f.filename.clone()).collect();
29
30 let mut floor = 0u64;
31 for f in git::branch_migration_files(git, base_ref, dir)? {
33 floor = floor.max(f.version);
34 }
35 for b in git::local_branches(git)? {
37 if b == current_branch {
38 continue;
39 }
40 for f in git::branch_migration_files(git, &b, dir)? {
41 floor = floor.max(f.version);
42 }
43 }
44 for f in crate::scan_dir("", &git.repo.join(dir))?.files {
46 if !topic_names.contains(&f.filename) {
47 floor = floor.max(f.version);
48 }
49 }
50 Ok(floor)
51}
52
53fn is_fname_char(b: u8) -> bool {
54 b.is_ascii_alphanumeric() || b == b'_'
55}
56fn is_fname_cont(b: u8) -> bool {
57 b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
58}
59
60fn replace_filename_token(text: &str, old: &str, new: &str) -> (String, usize) {
66 let mut out = String::with_capacity(text.len());
67 let mut last = 0usize;
68 let mut count = 0usize;
69 let bytes = text.as_bytes();
70 for (idx, _) in text.match_indices(old) {
71 let before_ok = idx == 0 || !is_fname_char(bytes[idx - 1]);
72 let after = idx + old.len();
73 let after_ok = after >= bytes.len() || !is_fname_cont(bytes[after]);
74 if before_ok && after_ok {
75 out.push_str(&text[last..idx]);
76 out.push_str(new);
77 last = after;
78 count += 1;
79 }
80 }
81 out.push_str(&text[last..]);
82 (out, count)
83}
84
85pub fn rewrite_references(
89 root: &Path,
90 globs: &[String],
91 old: &str,
92 new: &str,
93 mode: ApplyMode,
94) -> Result<Vec<(String, usize)>> {
95 let mut hits = Vec::new();
96 for pattern in globs {
97 let full = root.join(pattern);
98 let entries = match glob::glob(&full.to_string_lossy()) {
99 Ok(e) => e,
100 Err(_) => continue,
101 };
102 for entry in entries.flatten() {
103 let text = std::fs::read_to_string(&entry)?;
104 let (new_text, count) = replace_filename_token(&text, old, new);
105 if count == 0 {
106 continue;
107 }
108 for (i, line) in text.lines().enumerate() {
109 if replace_filename_token(line, old, new).1 > 0 {
110 hits.push((entry.display().to_string(), i + 1));
111 }
112 }
113 if mode == ApplyMode::Commit {
114 std::fs::write(&entry, new_text)?;
115 }
116 }
117 }
118 Ok(hits)
119}
120
121pub struct DirPlan {
123 pub role: String,
124 pub renames: Vec<Rename>,
125 pub reference_hits: Vec<(String, usize)>,
126}
127
128pub fn apply(
130 root: &Path,
131 cfg: &Config,
132 current_branch: &str,
133 mode: ApplyMode,
134) -> Result<Vec<DirPlan>> {
135 let gitcmd = GitCmd {
136 program: cfg.git.clone(),
137 repo: root.to_path_buf(),
138 };
139 let mut plans = Vec::new();
140
141 for DirSpec { role, path } in &cfg.dirs {
142 let abs_dir = root.join(path);
143 if !abs_dir.exists() {
144 continue; }
146 let topic_new = git::topic_new_files(&gitcmd, &cfg.base_ref, path)?;
148 let floor = compute_floor(&gitcmd, &cfg.base_ref, current_branch, path, &topic_new)?;
149 let renames = plan_renumber(&topic_new, floor);
150
151 let mut reference_hits = Vec::new();
152 for r in &renames {
153 let old = r.from.file_name().unwrap().to_string_lossy().into_owned();
154 let new = r.to.file_name().unwrap().to_string_lossy().into_owned();
155 reference_hits.extend(rewrite_references(
156 root,
157 &cfg.reference_globs,
158 &old,
159 &new,
160 mode,
161 )?);
162 if mode == ApplyMode::Commit {
163 let from = r
164 .from
165 .strip_prefix(root)
166 .unwrap_or(&r.from)
167 .to_string_lossy()
168 .into_owned();
169 let to =
170 r.to.strip_prefix(root)
171 .unwrap_or(&r.to)
172 .to_string_lossy()
173 .into_owned();
174 let out = std::process::Command::new(&cfg.git)
175 .args(["mv", &from, &to])
176 .current_dir(root)
177 .output()?;
178 if !out.status.success() {
179 std::fs::rename(&r.from, &r.to)?;
180 let _ = std::process::Command::new(&cfg.git)
182 .args(["add", "-A"])
183 .current_dir(root)
184 .output();
185 }
186 }
187 }
188 plans.push(DirPlan {
189 role: role.clone(),
190 renames,
191 reference_hits,
192 });
193 }
194 Ok(plans)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::replace_filename_token;
200
201 #[test]
202 fn rewrite_is_filename_boundary_aware() {
203 let input = "a 0002_x.sql and 10002_x.sql and 0002_x.sql.bak end";
204 let (out, n) = replace_filename_token(input, "0002_x.sql", "0003_x.sql");
205 assert_eq!(n, 1, "only the standalone filename should match");
206 assert!(
207 out.contains("0003_x.sql and 10002_x.sql and 0002_x.sql.bak"),
208 "got: {out}"
209 );
210 }
211
212 #[test]
213 fn rewrite_matches_in_quotes_and_paths() {
214 let input = "include_str!(\"../../migrations/0002_x.sql\")";
215 let (out, n) = replace_filename_token(input, "0002_x.sql", "0003_x.sql");
216 assert_eq!(n, 1);
217 assert_eq!(out, "include_str!(\"../../migrations/0003_x.sql\")");
218 }
219}