scryer-db 0.1.0

Database models and Turso/SQLite storage layer for Scryer code intelligence
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock, broadcast};

use crate::models::Project;
use crate::time::now_rfc3339;

/// Files or directories whose presence marks a directory as a project root.
const PROJECT_MARKERS: &[&str] = &[
    ".git",
    "Cargo.toml",
    "pyproject.toml",
    "setup.py",
    "package.json",
    "tsconfig.json",
];

/// A change to the set of registered projects, from [`ProjectRegistry::subscribe`].
#[derive(Debug, Clone)]
pub enum ProjectEvent {
    /// A project was registered, or re-registered (possibly with a new root).
    Registered(Project),
    /// The project with this ID was removed.
    Removed(u64),
}

/// In-memory cache of registered workspace projects backed by Turso database.
pub struct ProjectRegistry {
    db: Arc<Mutex<toasty::Db>>,
    projects: RwLock<Vec<Project>>,
    events: broadcast::Sender<ProjectEvent>,
}

impl ProjectRegistry {
    /// Initialize a `ProjectRegistry` by loading all existing projects from the database.
    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),
        })
    }

    /// Resolve a filesystem path to the registered `Project` that contains it,
    /// using longest prefix matching on canonicalized paths.
    ///
    /// Returns `Some((Project, relative_path))` if a matching project root is found.
    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))
    }

    /// Retrieve a registered project by its unique slug identifier.
    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()
    }

    /// Look up a project by exact slug, numeric ID, or absolute root path.
    ///
    /// Unlike [`Self::resolve_path`], a path only matches a project root, never a file inside one.
    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()
    }

    /// List all currently cached projects.
    pub async fn list_projects(&self) -> Vec<Project> {
        self.projects.read().await.clone()
    }

    /// Register a new workspace project directory, or update an existing project if it already exists.
    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;

        // Check if a project with this root_path or slug already exists
        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);

        // Update in-memory cache
        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);

        // No subscribers is fine; the send only fails when nobody is listening.
        let _ = self.events.send(ProjectEvent::Registered(project.clone()));
        Ok(project)
    }

    /// Unregister a project, deleting its row and ADR records.
    ///
    /// Index data (files, symbols, scopes, references, edges) is purged separately by
    /// `EngineService::purge_project`, which serializes against in-flight indexing.
    /// Subscribers from [`Self::subscribe`] are sent [`ProjectEvent::Removed`].
    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))
    }

    /// Receive project registrations and removals as they happen.
    pub fn subscribe(&self) -> broadcast::Receiver<ProjectEvent> {
        self.events.subscribe()
    }
}

/// Whether `path` is suitable to auto-register as a project root.
///
/// Rejects the filesystem root and the user's home directory, and requires a project
/// marker such as `.git` or `Cargo.toml`. Used when a client session starts in a directory
/// no registered project contains, so editors launched from `~` don't register all of it.
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())
}