feat(project): Add substring matching and use Ambiguous error for resolution

Extend resolve_project() with a 4th cascade step: case-insensitive
substring match when exact, case-insensitive, and suffix matches all
fail. This allows shorthand like "typescript" to match
"vs/typescript-code" when unambiguous. Multi-match still returns an
error with all candidates listed.

Also change ambiguity errors from LoreError::Other to LoreError::Ambiguous
so they get the proper AMBIGUOUS error code (exit 18) instead of
INTERNAL_ERROR.

Includes tests for unambiguous substring, case-insensitive substring,
ambiguous substring, and suffix-preferred-over-substring ordering.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-30 16:55:23 -05:00
parent 7b7d781a19
commit 51c370fac2

View File

@@ -5,8 +5,9 @@ use super::error::{LoreError, Result};
/// Resolve a project string to a project_id using cascading match: /// Resolve a project string to a project_id using cascading match:
/// 1. Exact match on path_with_namespace /// 1. Exact match on path_with_namespace
/// 2. Case-insensitive exact match /// 2. Case-insensitive exact match
/// 3. Suffix match (only if unambiguous) /// 3. Suffix match (e.g., "auth-service" matches "group/auth-service") — only if unambiguous
/// 4. Error with available projects list /// 4. Substring match (e.g., "typescript" matches "vs/typescript-code") — only if unambiguous
/// 5. Error with available projects list
pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> { pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
// Step 1: Exact match // Step 1: Exact match
let exact = conn.query_row( let exact = conn.query_row(
@@ -44,7 +45,7 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
1 => return Ok(suffix_matches[0].0), 1 => return Ok(suffix_matches[0].0),
n if n > 1 => { n if n > 1 => {
let matching: Vec<String> = suffix_matches.iter().map(|(_, p)| p.clone()).collect(); let matching: Vec<String> = suffix_matches.iter().map(|(_, p)| p.clone()).collect();
return Err(LoreError::Other(format!( return Err(LoreError::Ambiguous(format!(
"Project '{}' is ambiguous. Matching projects:\n{}\n\nHint: Use the full path, e.g., --project={}", "Project '{}' is ambiguous. Matching projects:\n{}\n\nHint: Use the full path, e.g., --project={}",
project_str, project_str,
matching.iter().map(|p| format!(" {}", p)).collect::<Vec<_>>().join("\n"), matching.iter().map(|p| format!(" {}", p)).collect::<Vec<_>>().join("\n"),
@@ -54,7 +55,32 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
_ => {} _ => {}
} }
// Step 4: No match — list available projects // Step 4: Case-insensitive substring match (unambiguous)
let mut substr_stmt = conn.prepare(
"SELECT id, path_with_namespace FROM projects
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%'"
)?;
let substr_matches: Vec<(i64, String)> = substr_stmt
.query_map(rusqlite::params![project_str], |row| {
Ok((row.get(0)?, row.get(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
match substr_matches.len() {
1 => return Ok(substr_matches[0].0),
n if n > 1 => {
let matching: Vec<String> = substr_matches.iter().map(|(_, p)| p.clone()).collect();
return Err(LoreError::Ambiguous(format!(
"Project '{}' is ambiguous. Matching projects:\n{}\n\nHint: Use the full path, e.g., --project={}",
project_str,
matching.iter().map(|p| format!(" {}", p)).collect::<Vec<_>>().join("\n"),
matching[0]
)));
}
_ => {}
}
// Step 5: No match — list available projects
let mut all_stmt = conn.prepare( let mut all_stmt = conn.prepare(
"SELECT path_with_namespace FROM projects ORDER BY path_with_namespace" "SELECT path_with_namespace FROM projects ORDER BY path_with_namespace"
)?; )?;
@@ -143,6 +169,48 @@ mod tests {
assert!(msg.contains("frontend/auth-service")); assert!(msg.contains("frontend/auth-service"));
} }
#[test]
fn test_substring_unambiguous() {
let conn = setup_db();
insert_project(&conn, 1, "vs/python-code");
insert_project(&conn, 2, "vs/typescript-code");
let id = resolve_project(&conn, "typescript").unwrap();
assert_eq!(id, 2);
}
#[test]
fn test_substring_case_insensitive() {
let conn = setup_db();
insert_project(&conn, 1, "vs/python-code");
insert_project(&conn, 2, "vs/typescript-code");
let id = resolve_project(&conn, "TypeScript").unwrap();
assert_eq!(id, 2);
}
#[test]
fn test_substring_ambiguous() {
let conn = setup_db();
insert_project(&conn, 1, "vs/python-code");
insert_project(&conn, 2, "vs/typescript-code");
// "code" matches both projects
let err = resolve_project(&conn, "code").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("ambiguous"), "Expected ambiguous error, got: {}", msg);
assert!(msg.contains("vs/python-code"));
assert!(msg.contains("vs/typescript-code"));
}
#[test]
fn test_suffix_preferred_over_substring() {
// Suffix match (step 3) should resolve before substring (step 4)
let conn = setup_db();
insert_project(&conn, 1, "backend/auth-service");
insert_project(&conn, 2, "backend/auth-service-v2");
// "auth-service" is an exact suffix of project 1
let id = resolve_project(&conn, "auth-service").unwrap();
assert_eq!(id, 1);
}
#[test] #[test]
fn test_no_match() { fn test_no_match() {
let conn = setup_db(); let conn = setup_db();