feat(path): rename-aware ambiguity resolution for suffix probe
When a bare filename like 'operators.ts' matches multiple full paths, check if they are the same file connected by renames (via BFS on mr_file_changes). If so, auto-resolve to the newest path instead of erroring. Also wires path resolution into file-history and trace commands so bare filenames work everywhere. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::error::{LoreError, Result};
|
||||
use super::file_history::resolve_rename_chain;
|
||||
|
||||
// ─── SQL Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -149,6 +150,16 @@ pub fn build_path_query(
|
||||
is_prefix: false,
|
||||
}),
|
||||
SuffixResult::Ambiguous(candidates) => {
|
||||
// Check if all candidates are the same file connected by renames.
|
||||
// resolve_rename_chain requires a concrete project_id.
|
||||
if let Some(pid) = project_id
|
||||
&& let Some(resolved) = try_resolve_rename_ambiguity(conn, pid, &candidates)?
|
||||
{
|
||||
return Ok(PathQuery {
|
||||
value: resolved,
|
||||
is_prefix: false,
|
||||
});
|
||||
}
|
||||
let list = candidates
|
||||
.iter()
|
||||
.map(|p| format!(" {p}"))
|
||||
@@ -239,6 +250,59 @@ pub fn suffix_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum rename hops when resolving ambiguity.
|
||||
const AMBIGUITY_MAX_RENAME_HOPS: usize = 10;
|
||||
|
||||
/// When suffix probe returns multiple paths, check if they are all the same file
|
||||
/// connected by renames. If so, return the "newest" path (the leaf of the chain
|
||||
/// that is never renamed away from). Returns `None` if truly ambiguous.
|
||||
fn try_resolve_rename_ambiguity(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
candidates: &[String],
|
||||
) -> Result<Option<String>> {
|
||||
// BFS from the first candidate to discover the full rename chain.
|
||||
let chain = resolve_rename_chain(conn, project_id, &candidates[0], AMBIGUITY_MAX_RENAME_HOPS)?;
|
||||
|
||||
// If any candidate is NOT in the chain, these are genuinely different files.
|
||||
if !candidates.iter().all(|c| chain.contains(c)) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// All candidates are the same file. Find the "newest" path: the one that
|
||||
// appears as new_path in a rename but is never old_path in a subsequent rename
|
||||
// (within the chain). This is the leaf of the rename DAG.
|
||||
let placeholders: Vec<String> = (0..chain.len()).map(|i| format!("?{}", i + 2)).collect();
|
||||
let in_clause = placeholders.join(", ");
|
||||
|
||||
// Find paths that are old_path in a rename where new_path is also in the chain.
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT old_path FROM mr_file_changes \
|
||||
WHERE project_id = ?1 \
|
||||
AND change_type = 'renamed' \
|
||||
AND old_path IN ({in_clause}) \
|
||||
AND new_path IN ({in_clause})"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
params.push(Box::new(project_id));
|
||||
for p in &chain {
|
||||
params.push(Box::new(p.clone()));
|
||||
}
|
||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let old_paths: Vec<String> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| row.get(0))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
|
||||
// The newest path is a candidate that is NOT an old_path in any intra-chain rename.
|
||||
let newest = candidates.iter().find(|c| !old_paths.contains(c));
|
||||
|
||||
Ok(newest.cloned().or_else(|| Some(candidates[0].clone())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_resolver_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -288,3 +288,80 @@ fn test_exact_match_preferred_over_suffix() {
|
||||
assert_eq!(pq.value, "README.md");
|
||||
assert!(!pq.is_prefix);
|
||||
}
|
||||
|
||||
fn seed_rename(conn: &Connection, mr_id: i64, project_id: i64, old_path: &str, new_path: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
|
||||
VALUES (?1, ?2, ?3, ?4, 'renamed')",
|
||||
rusqlite::params![mr_id, project_id, old_path, new_path],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ─── rename-aware ambiguity resolution ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ambiguity_resolved_by_rename_chain() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn, 1);
|
||||
seed_mr(&conn, 1, 1);
|
||||
seed_mr(&conn, 2, 1);
|
||||
|
||||
// File was at src/old/operators.ts, then renamed to src/new/operators.ts
|
||||
seed_file_change(&conn, 1, 1, "src/old/operators.ts");
|
||||
seed_rename(&conn, 2, 1, "src/old/operators.ts", "src/new/operators.ts");
|
||||
|
||||
// Bare "operators.ts" matches both paths via suffix probe, but they're
|
||||
// connected by a rename — should auto-resolve to the newest path.
|
||||
let pq = build_path_query(&conn, "operators.ts", Some(1)).unwrap();
|
||||
assert_eq!(pq.value, "src/new/operators.ts");
|
||||
assert!(!pq.is_prefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ambiguity_not_resolved_when_genuinely_different_files() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn, 1);
|
||||
seed_mr(&conn, 1, 1);
|
||||
|
||||
// Two genuinely different files with the same name (no rename connecting them)
|
||||
seed_file_change(&conn, 1, 1, "src/utils/helpers.ts");
|
||||
seed_file_change(&conn, 1, 1, "tests/utils/helpers.ts");
|
||||
|
||||
let err = build_path_query(&conn, "helpers.ts", Some(1)).unwrap_err();
|
||||
assert!(err.to_string().contains("matches multiple paths"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ambiguity_rename_chain_with_three_hops() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn, 1);
|
||||
seed_mr(&conn, 1, 1);
|
||||
seed_mr(&conn, 2, 1);
|
||||
seed_mr(&conn, 3, 1);
|
||||
|
||||
// File named "config.ts" moved twice: lib/ -> src/ -> src/core/
|
||||
seed_file_change(&conn, 1, 1, "lib/config.ts");
|
||||
seed_rename(&conn, 2, 1, "lib/config.ts", "src/config.ts");
|
||||
seed_rename(&conn, 3, 1, "src/config.ts", "src/core/config.ts");
|
||||
|
||||
// "config.ts" matches lib/config.ts, src/config.ts, src/core/config.ts via suffix
|
||||
let pq = build_path_query(&conn, "config.ts", Some(1)).unwrap();
|
||||
assert_eq!(pq.value, "src/core/config.ts");
|
||||
assert!(!pq.is_prefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ambiguity_rename_without_project_id_stays_ambiguous() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn, 1);
|
||||
seed_mr(&conn, 1, 1);
|
||||
seed_mr(&conn, 2, 1);
|
||||
|
||||
seed_file_change(&conn, 1, 1, "src/old/utils.ts");
|
||||
seed_rename(&conn, 2, 1, "src/old/utils.ts", "src/new/utils.ts");
|
||||
|
||||
// Without project_id, rename resolution is skipped → stays ambiguous
|
||||
let err = build_path_query(&conn, "utils.ts", None).unwrap_err();
|
||||
assert!(err.to_string().contains("matches multiple paths"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user