feat(core): add file rename chain resolver with depth-bounded BFS
New module: core::file_history with resolve_rename_chain() that traces a file path through its rename history in mr_file_changes using bidirectional BFS (forward: old_path->new_path, backward: new_path->old_path). Key design decisions: - Depth-bounded BFS: each queue entry carries its distance from the origin, so max_hops correctly limits by graph distance (not by total nodes discovered). This matters for branching rename graphs where a file was renamed differently in parallel MRs. - Cycle-safe: visited set prevents infinite loops from circular renames. - Project-scoped: queries are always scoped to a single project_id. - Deterministic: output is sorted for stable results. Tests cover: linear chains (forward/backward), cycles, max_hops=0, depth-bounded linear chains, branching renames, diamond patterns, and cross-project isolation (9 tests total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
71
src/core/file_history.rs
Normal file
71
src/core/file_history.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::error::Result;
|
||||
|
||||
/// Resolves a file path through its rename history in `mr_file_changes`.
|
||||
///
|
||||
/// BFS in both directions: forward (`old_path` -> `new_path`) and backward
|
||||
/// (`new_path` -> `old_path`). Returns all equivalent paths including the
|
||||
/// original, sorted for determinism. Cycles are detected via a visited set.
|
||||
///
|
||||
/// `max_hops` limits the BFS depth (distance from the starting path).
|
||||
pub fn resolve_rename_chain(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
path: &str,
|
||||
max_hops: usize,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
visited.insert(path.to_string());
|
||||
|
||||
if max_hops == 0 {
|
||||
return Ok(vec![path.to_string()]);
|
||||
}
|
||||
|
||||
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
|
||||
queue.push_back((path.to_string(), 0));
|
||||
|
||||
let forward_sql = "\
|
||||
SELECT DISTINCT mfc.new_path FROM mr_file_changes mfc \
|
||||
WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'";
|
||||
let backward_sql = "\
|
||||
SELECT DISTINCT mfc.old_path FROM mr_file_changes mfc \
|
||||
WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'";
|
||||
|
||||
while let Some((current, depth)) = queue.pop_front() {
|
||||
if depth >= max_hops {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Forward: current was the old name -> discover new names
|
||||
let mut fwd_stmt = conn.prepare_cached(forward_sql)?;
|
||||
let forward: Vec<String> = fwd_stmt
|
||||
.query_map(rusqlite::params![project_id, ¤t], |row| row.get(0))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
|
||||
// Backward: current was the new name -> discover old names
|
||||
let mut bwd_stmt = conn.prepare_cached(backward_sql)?;
|
||||
let backward: Vec<String> = bwd_stmt
|
||||
.query_map(rusqlite::params![project_id, ¤t], |row| row.get(0))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
|
||||
for discovered in forward.into_iter().chain(backward) {
|
||||
if visited.insert(discovered.clone()) {
|
||||
queue.push_back((discovered, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut paths: Vec<String> = visited.into_iter().collect();
|
||||
paths.sort();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_history_tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user