use rusqlite::Connection; 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 pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { // Step 1: Exact match let exact = conn.query_row( "SELECT id FROM projects WHERE path_with_namespace = ?1", rusqlite::params![project_str], |row| row.get::<_, i64>(0), ); if let Ok(id) = exact { return Ok(id); } // Step 2: Case-insensitive exact match let ci = conn.query_row( "SELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?1)", rusqlite::params![project_str], |row| row.get::<_, i64>(0), ); if let Ok(id) = ci { return Ok(id); } // Step 3: Suffix match (unambiguous) let mut suffix_stmt = conn.prepare( "SELECT id, path_with_namespace FROM projects WHERE path_with_namespace LIKE '%/' || ?1 OR path_with_namespace = ?1" )?; let suffix_matches: Vec<(i64, String)> = suffix_stmt .query_map(rusqlite::params![project_str], |row| { Ok((row.get(0)?, row.get(1)?)) })? .collect::, _>>()?; match suffix_matches.len() { 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!( "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 4: No match — list available projects let mut all_stmt = conn.prepare( "SELECT path_with_namespace FROM projects ORDER BY path_with_namespace" )?; let all_projects: Vec = all_stmt .query_map([], |row| row.get(0))? .collect::, _>>()?; if all_projects.is_empty() { return Err(LoreError::Other(format!( "Project '{}' not found. No projects have been synced yet.\n\nHint: Run 'lore ingest' first.", project_str ))); } Err(LoreError::Other(format!( "Project '{}' not found.\n\nAvailable projects:\n{}\n\nHint: Use the full path, e.g., --project={}", project_str, all_projects.iter().map(|p| format!(" {}", p)).collect::>().join("\n"), all_projects[0] ))) } #[cfg(test)] mod tests { use super::*; fn setup_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(" CREATE TABLE projects ( id INTEGER PRIMARY KEY, gitlab_project_id INTEGER UNIQUE NOT NULL, path_with_namespace TEXT NOT NULL, default_branch TEXT, web_url TEXT, created_at INTEGER, updated_at INTEGER, raw_payload_id INTEGER ); CREATE INDEX idx_projects_path ON projects(path_with_namespace); ").unwrap(); conn } fn insert_project(conn: &Connection, id: i64, path: &str) { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (?1, ?2, ?3)", rusqlite::params![id, id * 100, path], ).unwrap(); } #[test] fn test_exact_match() { let conn = setup_db(); insert_project(&conn, 1, "backend/auth-service"); let id = resolve_project(&conn, "backend/auth-service").unwrap(); assert_eq!(id, 1); } #[test] fn test_case_insensitive() { let conn = setup_db(); insert_project(&conn, 1, "backend/auth-service"); let id = resolve_project(&conn, "Backend/Auth-Service").unwrap(); assert_eq!(id, 1); } #[test] fn test_suffix_unambiguous() { let conn = setup_db(); insert_project(&conn, 1, "backend/auth-service"); insert_project(&conn, 2, "frontend/web-ui"); let id = resolve_project(&conn, "auth-service").unwrap(); assert_eq!(id, 1); } #[test] fn test_suffix_ambiguous() { let conn = setup_db(); insert_project(&conn, 1, "backend/auth-service"); insert_project(&conn, 2, "frontend/auth-service"); let err = resolve_project(&conn, "auth-service").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("ambiguous"), "Expected ambiguous error, got: {}", msg); assert!(msg.contains("backend/auth-service")); assert!(msg.contains("frontend/auth-service")); } #[test] fn test_no_match() { let conn = setup_db(); insert_project(&conn, 1, "backend/auth-service"); let err = resolve_project(&conn, "nonexistent").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("not found"), "Expected not found error, got: {}", msg); assert!(msg.contains("backend/auth-service")); } #[test] fn test_empty_projects() { let conn = setup_db(); let err = resolve_project(&conn, "anything").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("No projects have been synced")); } }