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