Skip to main content

migrate_guard/
apply.rs

1//! Floor computation, reference rewriting, and renumber application.
2
3use 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
16/// The floor above which this branch's new files must be numbered, for one dir.
17/// `dir` is RELATIVE to the repo root. Max version across (a) base_ref's dir,
18/// (b) every OTHER local branch's dir, (c) the working tree minus the topic's
19/// own new files.
20pub 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    // (a) base ref
32    for f in git::branch_migration_files(git, base_ref, dir)? {
33        floor = floor.max(f.version);
34    }
35    // (b) other branches
36    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    // (c) working tree minus topic's own new files
45    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
60/// Replace whole-filename occurrences of `old` with `new`. A match counts only
61/// when it is not preceded by a filename char ([0-9A-Za-z_]) and not followed
62/// by a filename-continuation char ([0-9A-Za-z_.]) — so `10002_x.sql` (digit
63/// before) and `0002_x.sql.bak` (`.` after) are NOT matched when
64/// old = `0002_x.sql`. Returns (rewritten_text, match_count).
65fn 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
85/// Rewrite exact-filename references in files matched by `globs`, replacing
86/// `old` with `new`. Returns (path, line_number) of each rewrite. In DryRun no
87/// file is written.
88pub 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
121/// The full plan for one directory: (renames, reference-rewrite hits).
122pub struct DirPlan {
123    pub role: String,
124    pub renames: Vec<Rename>,
125    pub reference_hits: Vec<(String, usize)>,
126}
127
128/// Compute + optionally apply the renumber across all configured dirs.
129pub 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; // tolerated-absent, like build.rs
145        }
146        // git functions take a repo-relative dir (Task 5 contract).
147        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                    // Stage the rename so it isn't left half-tracked.
181                    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}