udb 0.4.14

Universal Data Broker — a Rust gRPC broker over multiple databases (Postgres, MySQL, SQLite, MongoDB, ClickHouse, Cassandra, MSSQL, Redis, Qdrant, S3, Neo4j, …) with per-tenant RLS, 2PC, sagas, and CDC.
Documentation
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RootKind {
    Empty,
    Application,
    UdbRepo,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectedLanguage {
    Php,
    Python,
    TypeScript,
    JavaScript,
    Go,
    Java,
    CSharp,
    Rust,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectedFramework {
    Laravel,
    Symfony,
    FastApi,
    Starlette,
    ReactVite,
    Next,
    Express,
    Fastify,
    GoNetHttp,
    GoGrpc,
    SpringBoot,
    AspNetCore,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackageManager {
    Composer,
    Uv,
    Npm,
    Pnpm,
    Yarn,
    Go,
    Maven,
    Gradle,
    Dotnet,
    Cargo,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceScan {
    pub root: PathBuf,
    pub root_kind: RootKind,
    pub languages: Vec<DetectedLanguage>,
    pub frameworks: Vec<DetectedFramework>,
    pub package_managers: Vec<PackageManager>,
    pub existing_udb_files: Vec<PathBuf>,
    pub proto_roots: Vec<PathBuf>,
    pub config_files: Vec<PathBuf>,
    pub docker_files: Vec<PathBuf>,
    pub sdk_dirs: Vec<PathBuf>,
    pub dbops_dirs: Vec<PathBuf>,
}

pub fn scan_workspace(cwd: impl AsRef<Path>) -> std::io::Result<WorkspaceScan> {
    let root = cwd.as_ref().to_path_buf();
    let mut scan = WorkspaceScan {
        root: root.clone(),
        root_kind: RootKind::Empty,
        languages: Vec::new(),
        frameworks: Vec::new(),
        package_managers: Vec::new(),
        existing_udb_files: Vec::new(),
        proto_roots: Vec::new(),
        config_files: Vec::new(),
        docker_files: Vec::new(),
        sdk_dirs: Vec::new(),
        dbops_dirs: Vec::new(),
    };

    let entries = fs::read_dir(&root)?.collect::<Result<Vec<_>, _>>()?;
    if !entries.is_empty() {
        scan.root_kind = RootKind::Application;
    }

    detect_file_signals(&root, &mut scan);
    detect_directory_signals(&root, &mut scan);
    dedupe_scan(&mut scan);

    if has_file(&root, "Cargo.toml")
        && has_dir(&root, "src")
        && has_file(&root, "buf.yaml")
        && has_file(&root, "VERSIONING.md")
    {
        scan.root_kind = RootKind::UdbRepo;
    }

    Ok(scan)
}

impl WorkspaceScan {
    pub fn primary_framework_id(&self) -> Option<&'static str> {
        self.frameworks.first().map(|framework| match framework {
            DetectedFramework::Laravel => "laravel",
            DetectedFramework::Symfony => "symfony",
            DetectedFramework::FastApi => "fastapi",
            DetectedFramework::Starlette => "starlette",
            DetectedFramework::ReactVite => "react-vite",
            DetectedFramework::Next => "next",
            DetectedFramework::Express => "express",
            DetectedFramework::Fastify => "fastify",
            DetectedFramework::GoNetHttp => "go-net-http",
            DetectedFramework::GoGrpc => "go-grpc",
            DetectedFramework::SpringBoot => "spring-boot",
            DetectedFramework::AspNetCore => "aspnet-core",
        })
    }
}

fn detect_file_signals(root: &Path, scan: &mut WorkspaceScan) {
    if has_file(root, "composer.json") {
        push_unique(&mut scan.languages, DetectedLanguage::Php);
        push_unique(&mut scan.package_managers, PackageManager::Composer);
        if has_file(root, "artisan") {
            push_unique(&mut scan.frameworks, DetectedFramework::Laravel);
        } else {
            push_unique(&mut scan.frameworks, DetectedFramework::Symfony);
        }
    }

    if has_file(root, "pyproject.toml") {
        push_unique(&mut scan.languages, DetectedLanguage::Python);
        if has_file(root, "uv.lock") {
            push_unique(&mut scan.package_managers, PackageManager::Uv);
        }
        push_unique(&mut scan.frameworks, DetectedFramework::FastApi);
    }

    if has_file(root, "package.json") {
        push_unique(&mut scan.languages, DetectedLanguage::TypeScript);
        push_unique(&mut scan.package_managers, PackageManager::Npm);
        if has_file(root, "pnpm-lock.yaml") {
            push_unique(&mut scan.package_managers, PackageManager::Pnpm);
        }
        if has_file(root, "yarn.lock") {
            push_unique(&mut scan.package_managers, PackageManager::Yarn);
        }
        if has_glob_prefix(root, "next.config.") {
            push_unique(&mut scan.frameworks, DetectedFramework::Next);
        } else if has_glob_prefix(root, "vite.config.") || has_file(root, "src/App.tsx") {
            push_unique(&mut scan.frameworks, DetectedFramework::ReactVite);
        } else {
            push_unique(&mut scan.frameworks, DetectedFramework::Express);
        }
    }

    if has_file(root, "go.mod") {
        push_unique(&mut scan.languages, DetectedLanguage::Go);
        push_unique(&mut scan.package_managers, PackageManager::Go);
        push_unique(&mut scan.frameworks, DetectedFramework::GoNetHttp);
    }

    if has_file(root, "pom.xml") {
        push_unique(&mut scan.languages, DetectedLanguage::Java);
        push_unique(&mut scan.package_managers, PackageManager::Maven);
        push_unique(&mut scan.frameworks, DetectedFramework::SpringBoot);
    }

    if has_glob_prefix(root, "build.gradle") {
        push_unique(&mut scan.languages, DetectedLanguage::Java);
        push_unique(&mut scan.package_managers, PackageManager::Gradle);
        push_unique(&mut scan.frameworks, DetectedFramework::SpringBoot);
    }

    if has_glob_suffix(root, ".csproj") || has_file(root, "global.json") {
        push_unique(&mut scan.languages, DetectedLanguage::CSharp);
        push_unique(&mut scan.package_managers, PackageManager::Dotnet);
        push_unique(&mut scan.frameworks, DetectedFramework::AspNetCore);
    }

    if has_file(root, "Cargo.toml") {
        push_unique(&mut scan.languages, DetectedLanguage::Rust);
        push_unique(&mut scan.package_managers, PackageManager::Cargo);
    }

    for file in ["buf.yaml", "buf.gen.yaml"] {
        if has_file(root, file) {
            scan.proto_roots.push(PathBuf::from(file));
        }
    }
    for file in [
        "udb.config.json",
        ".udb/native-services.json",
        "configs/database.yaml",
        "configs/backends.yaml",
        "configs/services.yaml",
    ] {
        if has_file(root, file) {
            scan.config_files.push(PathBuf::from(file));
        }
    }
    for file in ["docker-compose.yml", "docker-compose.yaml"] {
        if has_file(root, file) {
            scan.docker_files.push(PathBuf::from(file));
        }
    }
}

fn detect_directory_signals(root: &Path, scan: &mut WorkspaceScan) {
    for dir in [".udb", "proto", "sdk", "sdk-templates"] {
        if has_dir(root, dir) {
            match dir {
                ".udb" => scan.existing_udb_files.push(PathBuf::from(dir)),
                "proto" => scan.proto_roots.push(PathBuf::from(dir)),
                "sdk" | "sdk-templates" => scan.sdk_dirs.push(PathBuf::from(dir)),
                _ => {}
            }
        }
    }
    for dir in ["db/migration", "db/seeders", "db/bootstrap"] {
        if has_dir(root, dir) {
            scan.dbops_dirs.push(PathBuf::from(dir));
        }
    }
}

fn dedupe_scan(scan: &mut WorkspaceScan) {
    scan.existing_udb_files.sort();
    scan.existing_udb_files.dedup();
    scan.proto_roots.sort();
    scan.proto_roots.dedup();
    scan.config_files.sort();
    scan.config_files.dedup();
    scan.docker_files.sort();
    scan.docker_files.dedup();
    scan.sdk_dirs.sort();
    scan.sdk_dirs.dedup();
    scan.dbops_dirs.sort();
    scan.dbops_dirs.dedup();
}

fn has_file(root: &Path, relative: &str) -> bool {
    root.join(relative).is_file()
}

fn has_dir(root: &Path, relative: &str) -> bool {
    root.join(relative).is_dir()
}

fn has_glob_prefix(root: &Path, prefix: &str) -> bool {
    fs::read_dir(root)
        .ok()
        .into_iter()
        .flatten()
        .filter_map(Result::ok)
        .any(|entry| entry.file_name().to_string_lossy().starts_with(prefix))
}

fn has_glob_suffix(root: &Path, suffix: &str) -> bool {
    fs::read_dir(root)
        .ok()
        .into_iter()
        .flatten()
        .filter_map(Result::ok)
        .any(|entry| entry.file_name().to_string_lossy().ends_with(suffix))
}

fn push_unique<T: PartialEq>(items: &mut Vec<T>, item: T) {
    if !items.contains(&item) {
        items.push(item);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{self, File};
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn detects_laravel_workspace() {
        let root = temp_root("laravel");
        File::create(root.join("composer.json")).unwrap();
        File::create(root.join("artisan")).unwrap();

        let scan = scan_workspace(&root).unwrap();
        assert_eq!(scan.root_kind, RootKind::Application);
        assert!(scan.languages.contains(&DetectedLanguage::Php));
        assert!(scan.frameworks.contains(&DetectedFramework::Laravel));
        assert!(scan.package_managers.contains(&PackageManager::Composer));

        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn detects_udb_repo_shape() {
        let root = temp_root("udb-repo");
        fs::create_dir(root.join("src")).unwrap();
        File::create(root.join("Cargo.toml")).unwrap();
        File::create(root.join("buf.yaml")).unwrap();
        File::create(root.join("VERSIONING.md")).unwrap();

        let scan = scan_workspace(&root).unwrap();
        assert_eq!(scan.root_kind, RootKind::UdbRepo);

        fs::remove_dir_all(root).unwrap();
    }

    fn temp_root(name: &str) -> PathBuf {
        let suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let root = std::env::temp_dir().join(format!("udb-init-{name}-{suffix}"));
        fs::create_dir_all(&root).unwrap();
        root
    }
}