use crate::global::DEFAULT_DB_PATH;
use crate::storage::UniqueProjectId;
use rusqlite::{params, Connection};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GlobalRegistryError {
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Project not found: {0}")]
NotFound(String),
#[error("Invalid project ID: {0}")]
InvalidId(String),
}
pub type Result<T> = std::result::Result<T, GlobalRegistryError>;
#[derive(Debug, Clone)]
pub struct ProjectInfo {
pub unique_id: UniqueProjectId,
pub base_name: String,
pub path: PathBuf,
pub language: Option<String>,
pub file_count: usize,
pub content_fingerprint: String,
pub is_clone: bool,
pub cloned_from: Option<String>,
pub registered_at: i64,
pub last_modified: Option<i64>,
}
pub struct GlobalRegistry {
conn: Connection,
db_path: PathBuf,
}
impl GlobalRegistry {
pub fn init<P: AsRef<Path>>(db_path: P) -> Result<Self> {
let db_path = db_path.as_ref().to_path_buf();
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&db_path)?;
let mut registry = Self { conn, db_path };
registry.initialize_schema()?;
Ok(registry)
}
pub fn init_default() -> Result<Self> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
let path = PathBuf::from(home).join(DEFAULT_DB_PATH.trim_start_matches('.'));
Self::init(path)
}
fn initialize_schema(&mut self) -> Result<()> {
self.conn.pragma_update(None, "journal_mode", "WAL")?;
self.conn.pragma_update(None, "cache_size", 10000i64)?;
self.conn.execute(
r#"
CREATE TABLE IF NOT EXISTS global_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unique_project_id TEXT UNIQUE NOT NULL,
base_name TEXT NOT NULL,
canonical_path TEXT NOT NULL UNIQUE,
language TEXT,
file_count INTEGER DEFAULT 0,
content_fingerprint TEXT NOT NULL,
is_clone BOOLEAN DEFAULT 0,
cloned_from TEXT,
registered_at INTEGER NOT NULL,
last_modified INTEGER,
path_hash TEXT NOT NULL,
instance INTEGER DEFAULT 0
)
"#,
[],
)?;
let indexes = [
"CREATE INDEX IF NOT EXISTS idx_global_projects_unique_id ON global_projects(unique_project_id)",
"CREATE INDEX IF NOT EXISTS idx_global_projects_base_name ON global_projects(base_name)",
"CREATE INDEX IF NOT EXISTS idx_global_projects_fingerprint ON global_projects(content_fingerprint)",
"CREATE INDEX IF NOT EXISTS idx_global_projects_language ON global_projects(language)",
];
for index_sql in indexes {
self.conn.execute(index_sql, [])?;
}
Ok(())
}
pub fn register_project(
&mut self,
path: &Path,
language: Option<String>,
file_count: usize,
content_fingerprint: &str,
) -> Result<String> {
let base_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let existing_ids = self.load_existing_ids(base_name)?;
let unique_id = UniqueProjectId::generate(path, &existing_ids);
let is_clone;
let cloned_from;
if let Some(existing) = self.find_by_fingerprint(content_fingerprint)? {
is_clone = true;
cloned_from = Some(existing.unique_id.to_string());
} else {
is_clone = false;
cloned_from = None;
}
let canonical_path = path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string_lossy().to_string());
let path_hash = blake3::hash(canonical_path.as_bytes()).to_hex()[..8].to_string();
let registered_now = chrono::Utc::now();
let registered_at = registered_now.timestamp();
let last_modified = path
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
});
self.conn.execute(
r#"
INSERT INTO global_projects
(unique_project_id, base_name, canonical_path, language, file_count,
content_fingerprint, is_clone, cloned_from, registered_at, last_modified,
path_hash, instance)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
"#,
params![
unique_id.to_string(),
base_name,
canonical_path,
language,
file_count as i64,
content_fingerprint,
is_clone,
cloned_from,
registered_at,
last_modified,
path_hash,
unique_id.instance,
],
)?;
Ok(unique_id.to_string())
}
pub fn list_projects(&self) -> Result<Vec<ProjectInfo>> {
let mut stmt = self.conn.prepare(
r#"
SELECT unique_project_id, base_name, canonical_path, language, file_count,
content_fingerprint, is_clone, cloned_from, registered_at, last_modified
FROM global_projects
ORDER BY base_name
"#,
)?;
let projects = stmt
.query_map([], |row| {
let id_str: String = row.get(0)?;
let unique_id = UniqueProjectId::parse_id(&id_str)
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
Ok(ProjectInfo {
unique_id,
base_name: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
language: row.get(3)?,
file_count: row.get::<_, i64>(4)? as usize,
content_fingerprint: row.get(5)?,
is_clone: row.get(6)?,
cloned_from: row.get(7)?,
registered_at: row.get(8)?,
last_modified: row.get::<_, Option<i64>>(9)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(projects)
}
pub fn get_project(&self, id: &str) -> Result<Option<ProjectInfo>> {
let mut stmt = self.conn.prepare(
r#"
SELECT unique_project_id, base_name, canonical_path, language, file_count,
content_fingerprint, is_clone, cloned_from, registered_at, last_modified
FROM global_projects
WHERE unique_project_id = ?1
"#,
)?;
let result = stmt.query_row(params![id], |row| {
let id_str: String = row.get(0)?;
let unique_id =
UniqueProjectId::parse_id(&id_str).ok_or_else(|| rusqlite::Error::InvalidQuery)?;
Ok(ProjectInfo {
unique_id,
base_name: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
language: row.get(3)?,
file_count: row.get::<_, i64>(4)? as usize,
content_fingerprint: row.get(5)?,
is_clone: row.get(6)?,
cloned_from: row.get(7)?,
registered_at: row.get(8)?,
last_modified: row.get::<_, Option<i64>>(9)?,
})
});
match result {
Ok(p) => Ok(Some(p)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn delete_project(&mut self, id: &str) -> Result<()> {
let rows_affected = self.conn.execute(
"DELETE FROM global_projects WHERE unique_project_id = ?1",
params![id],
)?;
if rows_affected == 0 {
return Err(GlobalRegistryError::NotFound(id.to_string()));
}
Ok(())
}
fn find_by_fingerprint(&self, fingerprint: &str) -> Result<Option<ProjectInfo>> {
let mut stmt = self.conn.prepare(
r#"
SELECT unique_project_id, base_name, canonical_path, language, file_count,
content_fingerprint, is_clone, cloned_from, registered_at, last_modified
FROM global_projects
WHERE content_fingerprint = ?1
LIMIT 1
"#,
)?;
let result = stmt.query_row(params![fingerprint], |row| {
let id_str: String = row.get(0)?;
let unique_id =
UniqueProjectId::parse_id(&id_str).ok_or_else(|| rusqlite::Error::InvalidQuery)?;
Ok(ProjectInfo {
unique_id,
base_name: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
language: row.get(3)?,
file_count: row.get::<_, i64>(4)? as usize,
content_fingerprint: row.get(5)?,
is_clone: row.get(6)?,
cloned_from: row.get(7)?,
registered_at: row.get(8)?,
last_modified: row.get::<_, Option<i64>>(9)?,
})
});
match result {
Ok(p) => Ok(Some(p)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn load_existing_ids(&self, base_name: &str) -> Result<Vec<UniqueProjectId>> {
let mut stmt = self
.conn
.prepare("SELECT unique_project_id FROM global_projects WHERE base_name = ?1")?;
let ids: Vec<UniqueProjectId> = stmt
.query_map(params![base_name], |row| {
let id_str: String = row.get(0)?;
UniqueProjectId::parse_id(&id_str).ok_or_else(|| rusqlite::Error::InvalidQuery)
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(ids)
}
#[must_use]
pub fn is_healthy(&self) -> bool {
self.conn
.query_row("SELECT 1", [], |_row| Ok(()))
.map(|_| true)
.unwrap_or(false)
}
#[must_use]
pub const fn db_path(&self) -> &PathBuf {
&self.db_path
}
#[must_use]
pub fn conn(&self) -> &Connection {
&self.conn
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_registry_init() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let registry = GlobalRegistry::init(&db_path);
assert!(registry.is_ok());
assert!(registry.unwrap().is_healthy());
}
#[test]
fn test_registry_init_default() {
let result = GlobalRegistry::init_default();
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_register_project() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let mut registry = GlobalRegistry::init(&db_path).unwrap();
let project_path = temp_dir.path().join("test-project");
std::fs::create_dir_all(project_path.join(".git")).unwrap();
let id = registry.register_project(
&project_path,
Some("rust".to_string()),
42,
"test-fingerprint",
);
assert!(id.is_ok());
let project_id = id.unwrap();
assert!(project_id.contains("test-project"));
}
#[test]
fn test_register_and_get_project() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let mut registry = GlobalRegistry::init(&db_path).unwrap();
let project_path = temp_dir.path().join("myproject");
std::fs::create_dir_all(project_path.join(".git")).unwrap();
let id = registry
.register_project(&project_path, Some("rust".to_string()), 100, "fp123")
.unwrap();
let project = registry.get_project(&id).unwrap();
assert!(project.is_some());
let info = project.unwrap();
assert_eq!(info.base_name, "myproject");
assert_eq!(info.language, Some("rust".to_string()));
assert_eq!(info.file_count, 100);
assert!(!info.is_clone);
}
#[test]
fn test_list_projects() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let mut registry = GlobalRegistry::init(&db_path).unwrap();
for i in 0..3 {
let path = temp_dir.path().join(format!("project{}", i));
std::fs::create_dir_all(path.join(".git")).unwrap();
registry
.register_project(&path, Some("rust".to_string()), 10 + i, &format!("fp{}", i))
.unwrap();
}
let projects = registry.list_projects().unwrap();
assert_eq!(projects.len(), 3);
assert!(projects
.iter()
.all(|p| p.language == Some("rust".to_string())));
}
#[test]
fn test_list_projects_propagates_invalid_row() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let registry = GlobalRegistry::init(&db_path).unwrap();
registry
.conn
.execute(
r#"
INSERT INTO global_projects (
unique_project_id, base_name, canonical_path, language, file_count,
content_fingerprint, is_clone, cloned_from, registered_at, last_modified,
path_hash, instance
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
"#,
params![
"invalid-id",
"broken",
"/tmp/broken",
Option::<String>::None,
0i64,
"fp",
false,
Option::<String>::None,
0i64,
Option::<i64>::None,
"deadbeef",
0i64,
],
)
.unwrap();
let err = registry.list_projects().unwrap_err();
assert!(matches!(err, GlobalRegistryError::Database(_)));
}
#[test]
fn test_load_existing_ids_propagates_invalid_row() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let registry = GlobalRegistry::init(&db_path).unwrap();
registry
.conn
.execute(
r#"
INSERT INTO global_projects (
unique_project_id, base_name, canonical_path, language, file_count,
content_fingerprint, is_clone, cloned_from, registered_at, last_modified,
path_hash, instance
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
"#,
params![
"invalid-id",
"broken",
"/tmp/broken",
Option::<String>::None,
0i64,
"fp",
false,
Option::<String>::None,
0i64,
Option::<i64>::None,
"deadbeef",
0i64,
],
)
.unwrap();
let err = registry.load_existing_ids("broken").unwrap_err();
assert!(matches!(err, GlobalRegistryError::Database(_)));
}
#[test]
fn test_delete_project() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let mut registry = GlobalRegistry::init(&db_path).unwrap();
let project_path = temp_dir.path().join("todelete");
std::fs::create_dir_all(project_path.join(".git")).unwrap();
let id = registry
.register_project(&project_path, None, 5, "fp")
.unwrap();
assert!(registry.delete_project(&id).is_ok());
let result = registry.get_project(&id).unwrap();
assert!(result.is_none());
}
#[test]
fn test_clone_detection() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let mut registry = GlobalRegistry::init(&db_path).unwrap();
let path1 = temp_dir.path().join("original");
std::fs::create_dir_all(path1.join(".git")).unwrap();
registry
.register_project(&path1, Some("rust".to_string()), 50, "same-fp")
.unwrap();
let path2 = temp_dir.path().join("clone");
std::fs::create_dir_all(path2.join(".git")).unwrap();
registry
.register_project(&path2, Some("rust".to_string()), 50, "same-fp")
.unwrap();
let projects = registry.list_projects().unwrap();
let clone = projects.iter().find(|p| p.base_name == "clone").unwrap();
assert!(clone.is_clone);
assert!(clone.cloned_from.is_some());
}
#[test]
fn test_is_healthy() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let registry = GlobalRegistry::init(&db_path).unwrap();
assert!(registry.is_healthy());
}
}