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;
|
||||
|
||||
Reference in New Issue
Block a user