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>
164 lines
5.5 KiB
Rust
164 lines
5.5 KiB
Rust
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"));
|
|
}
|
|
}
|