use std::path::PathBuf;
use tracing::{debug, warn};
use tga::collect::identity::IdentityResolver;
use tga::core::db::Database;
use super::error::{ProfileError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedIdentity {
pub canonical_email: String,
pub canonical_name: String,
}
pub struct ContributorSelector {
db: Database,
resolver: IdentityResolver,
}
impl ContributorSelector {
pub fn open(db_path: &std::path::Path) -> Result<Self> {
let db = Database::open(db_path).map_err(ProfileError::Db)?;
let resolver = build_resolver_from_db(&db)?;
Ok(Self { db, resolver })
}
#[cfg(test)]
pub(crate) fn open_in_memory() -> Result<Self> {
let db = Database::open_in_memory().map_err(ProfileError::Db)?;
let resolver = build_resolver_from_db(&db)?;
Ok(Self { db, resolver })
}
pub fn database(&self) -> &Database {
&self.db
}
pub fn resolve(&self, query: &str) -> Result<ResolvedIdentity> {
let query_lc = query.to_lowercase();
debug!(query, "ContributorSelector::resolve");
if let Some(id) = self.lookup_by_email(&query_lc)? {
return Ok(id);
}
if let Some(id) = self.lookup_by_name(query)? {
return Ok(id);
}
if let Some(id) = self.lookup_by_alias_json(query)? {
return Ok(id);
}
let (resolved_name, resolved_email) = self.resolver.resolve(query, query);
if resolved_email != query || resolved_name != query {
debug!(
resolved_name,
resolved_email, "ContributorSelector: fuzzy match"
);
return Ok(ResolvedIdentity {
canonical_email: resolved_email,
canonical_name: resolved_name,
});
}
warn!(query, "ContributorSelector: no identity found");
Err(ProfileError::ContributorNotFound {
query: query.to_string(),
})
}
fn lookup_by_email(&self, email_lc: &str) -> Result<Option<ResolvedIdentity>> {
let conn = self.db.connection();
let result: rusqlite::Result<(String, String)> = conn.query_row(
"SELECT canonical_email, canonical_name \
FROM authors WHERE LOWER(canonical_email) = ?1 LIMIT 1",
[email_lc],
|row| Ok((row.get(0)?, row.get(1)?)),
);
match result {
Ok((email, name)) => Ok(Some(ResolvedIdentity {
canonical_email: email,
canonical_name: name,
})),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(ProfileError::Db(tga::core::TgaError::from(e))),
}
}
fn lookup_by_name(&self, name: &str) -> Result<Option<ResolvedIdentity>> {
let conn = self.db.connection();
let result: rusqlite::Result<(String, String)> = conn.query_row(
"SELECT canonical_email, canonical_name \
FROM authors WHERE LOWER(canonical_name) = LOWER(?1) LIMIT 1",
[name],
|row| Ok((row.get(0)?, row.get(1)?)),
);
match result {
Ok((email, name_out)) => Ok(Some(ResolvedIdentity {
canonical_email: email,
canonical_name: name_out,
})),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(ProfileError::Db(tga::core::TgaError::from(e))),
}
}
fn lookup_by_alias_json(&self, query: &str) -> Result<Option<ResolvedIdentity>> {
let escaped = query.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{}%", escaped);
let conn = self.db.connection();
let result: rusqlite::Result<(String, String)> = conn.query_row(
"SELECT canonical_email, canonical_name \
FROM authors WHERE aliases LIKE ?1 ESCAPE '\\' LIMIT 1",
[&pattern],
|row| Ok((row.get(0)?, row.get(1)?)),
);
match result {
Ok((email, name)) => Ok(Some(ResolvedIdentity {
canonical_email: email,
canonical_name: name,
})),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(ProfileError::Db(tga::core::TgaError::from(e))),
}
}
}
fn build_resolver_from_db(db: &Database) -> Result<IdentityResolver> {
use std::collections::HashMap;
let conn = db.connection();
let mut stmt = conn
.prepare("SELECT canonical_name, canonical_email, aliases FROM authors")
.map_err(|e| ProfileError::Db(tga::core::TgaError::from(e)))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| ProfileError::Db(tga::core::TgaError::from(e)))?;
let mut alias_map: HashMap<String, Vec<String>> = HashMap::new();
for r in rows {
let (name, email, aliases_json) =
r.map_err(|e| ProfileError::Db(tga::core::TgaError::from(e)))?;
let mut aliases: Vec<String> = serde_json::from_str(&aliases_json).unwrap_or_default();
if !email.is_empty() {
aliases.insert(0, email);
}
alias_map.insert(name, aliases);
}
Ok(IdentityResolver::from_alias_map(&alias_map))
}
pub fn resolve_contributor(db_path: &std::path::Path, query: &str) -> Result<ResolvedIdentity> {
let sel = ContributorSelector::open(db_path)?;
sel.resolve(query)
}
pub fn resolve_db_path(explicit_path: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(p) = explicit_path {
return Ok(p.to_path_buf());
}
if let Ok(val) = std::env::var("TRUSTY_REVIEW_TGA_DB") {
let p = PathBuf::from(val.trim());
if !p.as_os_str().is_empty() {
return Ok(p);
}
}
if let Some(data_dir) = dirs::data_dir() {
return Ok(data_dir.join("tga").join("tga.db"));
}
Err(ProfileError::DbNotConfigured)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use serial_test::serial;
fn seed_author(db: &Database, name: &str, email: &str, aliases_json: &str) {
db.connection()
.execute(
"INSERT INTO authors (canonical_name, canonical_email, aliases) \
VALUES (?1, ?2, ?3)",
params![name, email, aliases_json],
)
.expect("insert author");
}
fn make_selector_with_authors() -> ContributorSelector {
let mut sel = ContributorSelector::open_in_memory().expect("open");
seed_author(sel.database(), "Alice Smith", "alice@example.com", r#"[]"#);
seed_author(
sel.database(),
"Bob Jones",
"bob@example.com",
r#"["bob-gh", "bob.jones@old.example.com"]"#,
);
sel.resolver = build_resolver_from_db(sel.database()).expect("rebuild resolver");
sel
}
#[test]
fn selector_resolves_canonical_email() {
let sel = make_selector_with_authors();
let id = sel.resolve("alice@example.com").expect("resolve");
assert_eq!(id.canonical_email, "alice@example.com");
assert_eq!(id.canonical_name, "Alice Smith");
}
#[test]
fn selector_resolves_by_name() {
let sel = make_selector_with_authors();
let id = sel.resolve("Bob Jones").expect("resolve by name");
assert_eq!(id.canonical_email, "bob@example.com");
assert_eq!(id.canonical_name, "Bob Jones");
}
#[test]
fn selector_resolves_via_alias() {
let sel = make_selector_with_authors();
let id = sel.resolve("bob-gh").expect("resolve via alias");
assert_eq!(id.canonical_email, "bob@example.com");
assert_eq!(id.canonical_name, "Bob Jones");
}
#[test]
fn selector_not_found_returns_error() {
let sel = make_selector_with_authors();
let err = sel.resolve("nobody@nowhere.test").expect_err("should fail");
assert!(
matches!(err, ProfileError::ContributorNotFound { .. }),
"expected ContributorNotFound, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("tga aliases list"),
"error message should mention 'tga aliases list': {msg}"
);
}
#[test]
#[serial]
fn resolve_db_path_uses_env_var() {
let expected = "/tmp/test-tga.db";
unsafe {
std::env::set_var("TRUSTY_REVIEW_TGA_DB", expected);
}
let path = resolve_db_path(None).expect("resolve path");
assert_eq!(path, std::path::PathBuf::from(expected));
unsafe {
std::env::remove_var("TRUSTY_REVIEW_TGA_DB");
}
}
#[test]
#[serial]
fn resolve_db_path_explicit_beats_env() {
unsafe {
std::env::set_var("TRUSTY_REVIEW_TGA_DB", "/tmp/env-tga.db");
}
let explicit = std::path::Path::new("/tmp/explicit-tga.db");
let path = resolve_db_path(Some(explicit)).expect("resolve path");
assert_eq!(path, explicit.to_path_buf());
unsafe {
std::env::remove_var("TRUSTY_REVIEW_TGA_DB");
}
}
}