Replace .filter_map(Result::ok).collect() with .collect::<Result<Vec<_>,_>>()? in rename chain resolution and suffix probe queries. The old pattern silently discarded database errors, making failures invisible. Now any rusqlite error propagates to the caller immediately. Affected: resolve_rename_chain (2 queries) and resolve_ambiguity (1 query). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
308 lines
12 KiB
Rust
308 lines
12 KiB
Rust
use rusqlite::Connection;
|
|
|
|
use super::error::{LoreError, Result};
|
|
use super::file_history::resolve_rename_chain;
|
|
|
|
// ─── SQL Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/// Escape LIKE metacharacters (`%`, `_`, `\`).
|
|
/// All queries using this must include `ESCAPE '\'`.
|
|
pub fn escape_like(input: &str) -> String {
|
|
input
|
|
.replace('\\', "\\\\")
|
|
.replace('%', "\\%")
|
|
.replace('_', "\\_")
|
|
}
|
|
|
|
/// Normalize user-supplied repo paths to match stored DiffNote / file-change paths.
|
|
/// - trims whitespace
|
|
/// - strips leading "./" and "/" (repo-relative paths)
|
|
/// - converts '\' to '/' when no '/' present (Windows paste)
|
|
/// - collapses repeated "//"
|
|
pub fn normalize_repo_path(input: &str) -> String {
|
|
let mut s = input.trim().to_string();
|
|
// Windows backslash normalization (only when no forward slashes present)
|
|
if s.contains('\\') && !s.contains('/') {
|
|
s = s.replace('\\', "/");
|
|
}
|
|
// Strip leading ./
|
|
while s.starts_with("./") {
|
|
s = s[2..].to_string();
|
|
}
|
|
// Strip leading /
|
|
s = s.trim_start_matches('/').to_string();
|
|
// Collapse repeated //
|
|
while s.contains("//") {
|
|
s = s.replace("//", "/");
|
|
}
|
|
s
|
|
}
|
|
|
|
// ─── Path Query Resolution ──────────────────────────────────────────────────
|
|
|
|
/// Describes how to match a user-supplied path in SQL.
|
|
#[derive(Debug)]
|
|
pub struct PathQuery {
|
|
/// The parameter value to bind.
|
|
pub value: String,
|
|
/// If true: use `LIKE value ESCAPE '\'`. If false: use `= value`.
|
|
pub is_prefix: bool,
|
|
}
|
|
|
|
/// Result of a suffix probe against the DB.
|
|
pub enum SuffixResult {
|
|
/// Suffix probe was not attempted (conditions not met).
|
|
NotAttempted,
|
|
/// No paths matched the suffix.
|
|
NoMatch,
|
|
/// Exactly one distinct path matched — auto-resolve.
|
|
Unique(String),
|
|
/// Multiple distinct paths matched — user must disambiguate.
|
|
Ambiguous(Vec<String>),
|
|
}
|
|
|
|
/// Build a path query from a user-supplied path, with project-scoped DB probes.
|
|
///
|
|
/// Resolution strategy (in priority order):
|
|
/// 1. Trailing `/` → directory prefix (LIKE `path/%`)
|
|
/// 2. Exact match probe against notes + `mr_file_changes` → exact (= `path`)
|
|
/// 3. Directory prefix probe → prefix (LIKE `path/%`)
|
|
/// 4. Suffix probe for bare filenames → auto-resolve or ambiguity error
|
|
/// 5. Heuristic fallback: `.` in last segment → file, else → directory prefix
|
|
pub fn build_path_query(
|
|
conn: &Connection,
|
|
path: &str,
|
|
project_id: Option<i64>,
|
|
) -> Result<PathQuery> {
|
|
let trimmed = path.trim_end_matches('/');
|
|
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
let is_root = !trimmed.contains('/');
|
|
let forced_dir = path.ends_with('/');
|
|
// Heuristic is now only a fallback; probes decide first when ambiguous.
|
|
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
|
|
|
// Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped)
|
|
// Checks both new_path and old_path to support querying renamed files.
|
|
let exact_exists = conn
|
|
.query_row(
|
|
"SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created
|
|
WHERE note_type = 'DiffNote'
|
|
AND is_system = 0
|
|
AND (position_new_path = ?1 OR position_old_path = ?1)
|
|
AND (?2 IS NULL OR project_id = ?2)
|
|
LIMIT 1",
|
|
rusqlite::params![trimmed, project_id],
|
|
|_| Ok(()),
|
|
)
|
|
.is_ok()
|
|
|| conn
|
|
.query_row(
|
|
"SELECT 1 FROM mr_file_changes
|
|
WHERE (new_path = ?1 OR old_path = ?1)
|
|
AND (?2 IS NULL OR project_id = ?2)
|
|
LIMIT 1",
|
|
rusqlite::params![trimmed, project_id],
|
|
|_| Ok(()),
|
|
)
|
|
.is_ok();
|
|
|
|
// Probe 2: directory prefix exists in DiffNotes OR mr_file_changes (project-scoped)
|
|
let prefix_exists = if !forced_dir && !exact_exists {
|
|
let escaped = escape_like(trimmed);
|
|
let pat = format!("{escaped}/%");
|
|
conn.query_row(
|
|
"SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created
|
|
WHERE note_type = 'DiffNote'
|
|
AND is_system = 0
|
|
AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_old_path LIKE ?1 ESCAPE '\\')
|
|
AND (?2 IS NULL OR project_id = ?2)
|
|
LIMIT 1",
|
|
rusqlite::params![pat, project_id],
|
|
|_| Ok(()),
|
|
)
|
|
.is_ok()
|
|
|| conn
|
|
.query_row(
|
|
"SELECT 1 FROM mr_file_changes
|
|
WHERE (new_path LIKE ?1 ESCAPE '\\' OR old_path LIKE ?1 ESCAPE '\\')
|
|
AND (?2 IS NULL OR project_id = ?2)
|
|
LIMIT 1",
|
|
rusqlite::params![pat, project_id],
|
|
|_| Ok(()),
|
|
)
|
|
.is_ok()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Probe 3: suffix match — user typed a bare filename or partial path that
|
|
// doesn't exist as-is. Search for full paths ending with /input (or equal to input).
|
|
// This handles "login.rs" matching "src/auth/login.rs".
|
|
let suffix_resolved = if !forced_dir && !exact_exists && !prefix_exists && looks_like_file {
|
|
suffix_probe(conn, trimmed, project_id)?
|
|
} else {
|
|
SuffixResult::NotAttempted
|
|
};
|
|
|
|
match suffix_resolved {
|
|
SuffixResult::Unique(full_path) => Ok(PathQuery {
|
|
value: full_path,
|
|
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}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
Err(LoreError::Ambiguous(format!(
|
|
"'{trimmed}' matches multiple paths. Use the full path or -p to scope:\n{list}"
|
|
)))
|
|
}
|
|
SuffixResult::NotAttempted | SuffixResult::NoMatch => {
|
|
// Original logic: exact > prefix > heuristic
|
|
let is_file = if forced_dir {
|
|
false
|
|
} else if exact_exists {
|
|
true
|
|
} else if prefix_exists {
|
|
false
|
|
} else {
|
|
looks_like_file
|
|
};
|
|
|
|
if is_file {
|
|
Ok(PathQuery {
|
|
value: trimmed.to_string(),
|
|
is_prefix: false,
|
|
})
|
|
} else {
|
|
let escaped = escape_like(trimmed);
|
|
Ok(PathQuery {
|
|
value: format!("{escaped}/%"),
|
|
is_prefix: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Probe both notes and mr_file_changes for paths ending with the given suffix.
|
|
/// Searches both new_path and old_path columns to support renamed file resolution.
|
|
/// Returns up to 11 distinct candidates (enough to detect ambiguity + show a useful list).
|
|
pub fn suffix_probe(
|
|
conn: &Connection,
|
|
suffix: &str,
|
|
project_id: Option<i64>,
|
|
) -> Result<SuffixResult> {
|
|
let escaped = escape_like(suffix);
|
|
let suffix_pat = format!("%/{escaped}");
|
|
|
|
let mut stmt = conn.prepare_cached(
|
|
"SELECT DISTINCT full_path FROM (
|
|
SELECT position_new_path AS full_path
|
|
FROM notes INDEXED BY idx_notes_diffnote_path_created
|
|
WHERE note_type = 'DiffNote'
|
|
AND is_system = 0
|
|
AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_new_path = ?2)
|
|
AND (?3 IS NULL OR project_id = ?3)
|
|
UNION
|
|
SELECT new_path AS full_path FROM mr_file_changes
|
|
WHERE (new_path LIKE ?1 ESCAPE '\\' OR new_path = ?2)
|
|
AND (?3 IS NULL OR project_id = ?3)
|
|
UNION
|
|
SELECT position_old_path AS full_path FROM notes
|
|
WHERE note_type = 'DiffNote'
|
|
AND is_system = 0
|
|
AND position_old_path IS NOT NULL
|
|
AND (position_old_path LIKE ?1 ESCAPE '\\' OR position_old_path = ?2)
|
|
AND (?3 IS NULL OR project_id = ?3)
|
|
UNION
|
|
SELECT old_path AS full_path FROM mr_file_changes
|
|
WHERE old_path IS NOT NULL
|
|
AND (old_path LIKE ?1 ESCAPE '\\' OR old_path = ?2)
|
|
AND (?3 IS NULL OR project_id = ?3)
|
|
)
|
|
ORDER BY full_path
|
|
LIMIT 11",
|
|
)?;
|
|
|
|
let candidates: Vec<String> = stmt
|
|
.query_map(rusqlite::params![suffix_pat, suffix, project_id], |row| {
|
|
row.get(0)
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
match candidates.len() {
|
|
0 => Ok(SuffixResult::NoMatch),
|
|
1 => Ok(SuffixResult::Unique(candidates.into_iter().next().unwrap())),
|
|
_ => Ok(SuffixResult::Ambiguous(candidates)),
|
|
}
|
|
}
|
|
|
|
/// 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))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// 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;
|