refactor(core): Rename GiError to LoreError and add search infrastructure
Mechanical rename of GiError -> LoreError across the core module to match the project's rebranding from gitlab-inbox to gitlore/lore. Updates the error enum name, all From impls, and the Result type alias. Additionally introduces: - New error variants for embedding pipeline: OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt. Each includes actionable suggestions (e.g., "ollama serve", "ollama pull nomic-embed-text") to guide users through recovery. - New error codes 14-16 for programmatic handling of Ollama failures. - Savepoint-based migration execution in db.rs: each migration now runs inside a SQLite SAVEPOINT so a failed migration rolls back cleanly without corrupting the schema_version tracking. Previously a partial migration could leave the database in an inconsistent state. - core::backoff module: exponential backoff with jitter utility for retry loops in the embedding pipeline and discussion queues. - core::project module: helper for resolving project IDs and paths from the local database, used by the document regenerator and search filters. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
163
src/core/project.rs
Normal file
163
src/core/project.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
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<i64> {
|
||||
// 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::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
match suffix_matches.len() {
|
||||
1 => return Ok(suffix_matches[0].0),
|
||||
n if n > 1 => {
|
||||
let matching: Vec<String> = 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::<Vec<_>>().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<String> = all_stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
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::<Vec<_>>().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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user