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), } /// 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, ) -> Result { 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::>() .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, ) -> Result { 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 = stmt .query_map(rusqlite::params![suffix_pat, suffix, project_id], |row| { row.get(0) })? .collect::, _>>()?; 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> { // 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 = (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> = 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 = stmt .query_map(param_refs.as_slice(), |row| row.get(0))? .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;