use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock, broadcast};
use crate::models::Project;
use crate::time::now_rfc3339;
const PROJECT_MARKERS: &[&str] = &[
".git",
"Cargo.toml",
"pyproject.toml",
"setup.py",
"package.json",
"tsconfig.json",
];
#[derive(Debug, Clone)]
pub enum ProjectEvent {
Registered(Project),
Removed(u64),
}
pub struct ProjectRegistry {
db: Arc<Mutex<toasty::Db>>,
projects: RwLock<Vec<Project>>,
events: broadcast::Sender<ProjectEvent>,
}
impl ProjectRegistry {
pub async fn new(db: Arc<Mutex<toasty::Db>>) -> anyhow::Result<Self> {
let projects = {
let mut db_guard = db.lock().await;
Project::all().exec(&mut *db_guard).await?
};
Ok(Self {
db,
projects: RwLock::new(projects),
events: broadcast::Sender::new(64),
})
}
pub async fn resolve_path(&self, path: &Path) -> Option<(Project, PathBuf)> {
let canonical_target = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let projects = self.projects.read().await;
let mut best_match: Option<(Project, PathBuf, usize)> = None;
for proj in projects.iter() {
let proj_root = dunce::canonicalize(&proj.root_path)
.unwrap_or_else(|_| PathBuf::from(&proj.root_path));
if let Ok(relative) = canonical_target.strip_prefix(&proj_root) {
let match_len = proj_root.as_os_str().len();
match &best_match {
Some((_, _, best_len)) if *best_len >= match_len => {}
_ => {
best_match = Some((proj.clone(), relative.to_path_buf(), match_len));
}
}
}
}
best_match.map(|(proj, rel, _)| (proj, rel))
}
pub async fn get_by_slug(&self, slug: &str) -> Option<Project> {
let projects = self.projects.read().await;
projects.iter().find(|p| p.slug == slug).cloned()
}
pub async fn find(&self, slug_id_or_root: &str) -> Option<Project> {
let target = slug_id_or_root.trim();
let id = target.parse::<u64>().ok();
let root = Path::new(target)
.is_absolute()
.then(|| dunce::canonicalize(target).unwrap_or_else(|_| PathBuf::from(target)));
let projects = self.projects.read().await;
projects
.iter()
.find(|p| {
p.slug == target
|| Some(p.id) == id
|| root.as_deref() == Some(Path::new(&p.root_path))
})
.cloned()
}
pub async fn list_projects(&self) -> Vec<Project> {
self.projects.read().await.clone()
}
pub async fn register_or_update(
&self,
root_path: &Path,
slug: Option<&str>,
) -> anyhow::Result<Project> {
let canonical_root =
dunce::canonicalize(root_path).unwrap_or_else(|_| root_path.to_path_buf());
let root_str = canonical_root.to_string_lossy().to_string();
let derived_slug = match slug {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => canonical_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.to_string(),
};
let now = now_rfc3339();
let mut db_guard = self.db.lock().await;
let existing = match Project::filter_by_root_path(&root_str)
.first()
.exec(&mut *db_guard)
.await?
{
Some(p) => Some(p),
None => {
Project::filter_by_slug(&derived_slug)
.first()
.exec(&mut *db_guard)
.await?
}
};
let project = if let Some(mut existing) = existing {
existing
.update()
.root_path(root_str.clone())
.slug(derived_slug.clone())
.is_active(true)
.updated_at(now)
.exec(&mut *db_guard)
.await?;
existing
} else {
Project::create()
.slug(derived_slug)
.root_path(root_str)
.default_branch("main".to_string())
.is_active(true)
.created_at(now.clone())
.updated_at(now)
.exec(&mut *db_guard)
.await?
};
drop(db_guard);
let mut projects_write = self.projects.write().await;
if let Some(pos) = projects_write.iter().position(|p| p.id == project.id) {
projects_write[pos] = project.clone();
} else {
projects_write.push(project.clone());
}
drop(projects_write);
let _ = self.events.send(ProjectEvent::Registered(project.clone()));
Ok(project)
}
pub async fn remove(&self, project_id: u64) -> anyhow::Result<Option<Project>> {
let Some(project) = self
.projects
.read()
.await
.iter()
.find(|p| p.id == project_id)
.cloned()
else {
return Ok(None);
};
{
let mut db_guard = self.db.lock().await;
let mut tx = db_guard.transaction().await?;
for stmt in [
format!("DELETE FROM architectural_decision WHERE project_id = {project_id};"),
format!("DELETE FROM project WHERE id = {project_id};"),
] {
toasty::sql::statement(&stmt).exec(&mut tx).await?;
}
tx.commit().await?;
}
self.projects.write().await.retain(|p| p.id != project_id);
let _ = self.events.send(ProjectEvent::Removed(project_id));
Ok(Some(project))
}
pub fn subscribe(&self) -> broadcast::Receiver<ProjectEvent> {
self.events.subscribe()
}
}
pub fn is_registrable_root(path: &Path) -> bool {
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if canonical.parent().is_none() {
return false;
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
if let Some(home) = home {
let home = PathBuf::from(home);
let home = dunce::canonicalize(&home).unwrap_or(home);
if canonical == home {
return false;
}
}
PROJECT_MARKERS
.iter()
.any(|marker| canonical.join(marker).exists())
}