From 51c370fac28493bcceea130850484b02e949eb5b Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Fri, 30 Jan 2026 16:55:23 -0500 Subject: [PATCH] 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) --- src/core/project.rs | 76 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/src/core/project.rs b/src/core/project.rs index fe76a98..033d050 100644 --- a/src/core/project.rs +++ b/src/core/project.rs @@ -5,8 +5,9 @@ use super::error::{LoreError, Result}; /// Resolve a project string to a project_id using cascading match: /// 1. Exact match on path_with_namespace /// 2. Case-insensitive exact match -/// 3. Suffix match (only if unambiguous) -/// 4. Error with available projects list +/// 3. Suffix match (e.g., "auth-service" matches "group/auth-service") — only if unambiguous +/// 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 { // Step 1: Exact match let exact = conn.query_row( @@ -44,7 +45,7 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { 1 => return Ok(suffix_matches[0].0), n if n > 1 => { let matching: Vec = 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_str, matching.iter().map(|p| format!(" {}", p)).collect::>().join("\n"), @@ -54,7 +55,32 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { _ => {} } - // 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::, _>>()?; + + match substr_matches.len() { + 1 => return Ok(substr_matches[0].0), + n if n > 1 => { + let matching: Vec = 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::>().join("\n"), + matching[0] + ))); + } + _ => {} + } + + // Step 5: No match — list available projects let mut all_stmt = conn.prepare( "SELECT path_with_namespace FROM projects ORDER BY path_with_namespace" )?; @@ -143,6 +169,48 @@ mod tests { 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] fn test_no_match() { let conn = setup_db();