workon 0.24.6

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
//! Entity Framework Core provisioner (.NET). An isolated Postgres test database
//! per workspace with `dotnet ef database update` applied.
//!
//! Detected by a `*.csproj` referencing EF Core. Self-managing test setups
//! (`Microsoft.EntityFrameworkCore.InMemory`, a `:memory:` SQLite, or
//! `Testcontainers`) are a no-op — those own their DB. For a real provider
//! (Npgsql), workon creates the DB and runs `dotnet ef database update` with the
//! connection injected via `ConnectionStrings__<Name>` (EF reads it over
//! appsettings). EF wants a Npgsql key=value connection string, not a URL.

use std::path::{Path, PathBuf};

use anyhow::Result;
use vcs_runner::Cmd;

use super::{run_step, test_db_name, DbEngine, ProvisionCtx, Provisioner, Setup};

/// The connection-string key EF's `ConnectionStrings__<Name>` overrides. Real
/// projects vary; `Default` is the common convention.
const CONN_KEY: &str = "Default";

pub struct EfCore;

impl Provisioner for EfCore {
    fn name(&self) -> &'static str {
        "ef-core"
    }

    fn detect(&self, ws_dir: &Path) -> bool {
        // Match any `*.EntityFrameworkCore*` package (Microsoft.*, Npgsql.*, …) —
        // a provider-only csproj may not name the Microsoft package explicitly.
        // Keyed on actual package references, not raw text, so a comment can't
        // trigger it.
        package_refs(ws_dir).iter().any(|p| p.contains("EntityFrameworkCore"))
    }

    fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup> {
        let packages = package_refs(ctx.ws_dir);
        let references = |needle: &str| packages.iter().any(|p| p.contains(needle));
        // Self-managing test setups own their DB — stay out.
        if references("EntityFrameworkCore.InMemory")
            || references("EntityFrameworkCore.Sqlite")
            || references("Testcontainers")
        {
            return Ok(Setup::default());
        }
        if !references("Npgsql") {
            eprintln!("EF Core: no supported provider (Npgsql) detected; skipping DB setup");
            return Ok(Setup::default());
        }

        let engine = DbEngine::Postgres;
        let db = test_db_name(ctx.project_name, ctx.ws_id);
        eprintln!("Creating test database {db}...");
        if engine.create(&db).is_err() {
            eprintln!("Warning: could not create test database {db}");
            return Ok(Setup::default());
        }
        let resource = engine.resource(&db);
        let conn = npgsql_connection_string(&db);
        let env_key = format!("ConnectionStrings__{CONN_KEY}");

        // Restore the local dotnet-ef tool if a manifest declares one, then apply.
        let _ = Cmd::new("dotnet").arg("tool").arg("restore").in_dir(ctx.ws_dir).run();
        eprintln!("Applying migrations (dotnet ef database update)...");
        let mut cmd = Cmd::new("dotnet").args(["ef", "database", "update"]).env(&env_key, &conn).in_dir(ctx.ws_dir);
        for (k, v) in ctx.mise_vars {
            cmd = cmd.env(k, v);
        }
        run_step(cmd, "dotnet ef database update");

        Ok(Setup { resources: vec![resource], env: vec![(env_key, conn)], ..Setup::default() })
    }
}

/// `*.csproj` files within a few levels of the repo root. The canonical .NET
/// solution layout nests projects two deep (`src/MyApp.Api/MyApp.Api.csproj`,
/// `tests/MyApp.Tests/…`), so a root-only or one-deep scan misses most real
/// repos. Build output (`bin`/`obj`), vendored trees, and directories that
/// conventionally hold *sample* projects rather than the repo's own (`fixtures`,
/// `examples`, …) are skipped — otherwise a bundled sample .NET project (like
/// workon's own EF test fixture) is mistaken for the workspace's project.
fn csprojs(ws_dir: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    collect_csprojs(ws_dir, 0, &mut out);
    out
}

/// Package IDs referenced across the repo's csproj files — the `Include` value of
/// each `<PackageReference>` (`Npgsql.EntityFrameworkCore.PostgreSQL`). Scoped to
/// the `PackageReference` element and its tag so a `<ProjectReference>` path (which
/// may embed one of these library names) or a comment isn't read as a dependency;
/// handles both quote styles MSBuild allows.
fn package_refs(ws_dir: &Path) -> Vec<String> {
    csprojs(ws_dir)
        .iter()
        .filter_map(|p| std::fs::read_to_string(p).ok())
        .flat_map(|c| {
            c.split("<PackageReference")
                .skip(1)
                .filter_map(|el| include_value(el.split('>').next().unwrap_or(el)))
                .collect::<Vec<_>>()
        })
        .collect()
}

/// The `Include="…"` / `Include='…'` attribute value within a single element's tag.
pub(crate) fn include_value(tag: &str) -> Option<String> {
    let after = tag.split("Include=").nth(1)?.trim_start();
    let quote = after.chars().next()?;
    if quote != '"' && quote != '\'' {
        return None;
    }
    after[1..].split(quote).next().map(str::to_string) // `after` starts with an ASCII quote, so [1..] is char-safe
}

/// Directory names that hold vendored deps or sample/fixture projects, never the
/// repo's own project — the recursive scan doesn't descend into them.
fn skip_dir(name: &str) -> bool {
    matches!(
        name,
        "bin"
            | "obj"
            | ".git"
            | "node_modules"
            | "vendor"
            | "fixtures"
            | "fixture"
            | "testdata"
            | "test-data"
            | "__fixtures__"
            | "examples"
            | "example"
            | "samples"
            | "sample"
    )
}

fn collect_csprojs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
    if depth > 3 {
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for e in entries.flatten() {
        let path = e.path();
        if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            if skip_dir(&e.file_name().to_string_lossy()) {
                continue;
            }
            collect_csprojs(&path, depth + 1, out);
        } else if path.extension().is_some_and(|x| x == "csproj") {
            out.push(path);
        }
    }
}

/// A Npgsql `Host=…;Port=…;Database=…;Username=…[;Password=…]` string from the
/// `PG*` environment (OS-user fallback).
fn npgsql_connection_string(name: &str) -> String {
    let host = std::env::var("PGHOST").unwrap_or_default();
    let host = if host.is_empty() || host.starts_with('/') { "localhost".to_string() } else { host };
    let port = std::env::var("PGPORT").unwrap_or_default();
    let user = std::env::var("PGUSER").ok().or_else(|| std::env::var("USER").ok()).unwrap_or_default();
    npgsql_connection_string_from(&host, &port, name, &user, std::env::var("PGPASSWORD").ok().as_deref())
}

/// Every value goes through [`npgsql_value`], host and port included: they come
/// from the environment as much as the credentials do. An empty port means 5432.
pub(crate) fn npgsql_connection_string_from(
    host: &str,
    port: &str,
    name: &str,
    user: &str,
    password: Option<&str>,
) -> String {
    let port = if port.is_empty() { "5432" } else { port };
    let mut conn = format!(
        "Host={};Port={};Database={};Username={}",
        npgsql_value(host),
        npgsql_value(port),
        npgsql_value(name),
        npgsql_value(user)
    );
    if let Some(pass) = password.filter(|p| !p.is_empty()) {
        conn.push_str(&format!(";Password={}", npgsql_value(pass)));
    }
    conn
}

/// Quote a value for an ADO.NET/Npgsql `key=value;…` connection string. A value
/// with `;`, `=`, a quote or a control character, or with surrounding whitespace
/// of any kind (ADO.NET trims with `char.IsWhiteSpace`), would otherwise
/// terminate the pair early or be trimmed; wrap it in single quotes (doubling any
/// embedded single quote), which Npgsql parses back verbatim.
pub(crate) fn npgsql_value(v: &str) -> String {
    let needs_quoting = v.is_empty()
        || v.starts_with(char::is_whitespace)
        || v.ends_with(char::is_whitespace)
        || v.chars().any(|c| c.is_control() || matches!(c, ';' | '=' | '\'' | '"'));
    if needs_quoting {
        format!("'{}'", v.replace('\'', "''"))
    } else {
        v.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn npgsql_value_quotes_only_when_needed() {
        assert_eq!(npgsql_value("simplepass"), "simplepass");
        // A `;` would otherwise start a new key; a `'` is doubled inside quotes.
        assert_eq!(npgsql_value("pa;ss"), "'pa;ss'");
        assert_eq!(npgsql_value("pa'ss"), "'pa''ss'");
        assert_eq!(npgsql_value(" leading"), "' leading'");
        assert_eq!(npgsql_value("a=b"), "'a=b'");
    }

    #[test]
    fn npgsql_connection_string_quotes_host_and_port_like_credentials() {
        assert_eq!(
            npgsql_connection_string_from("db.local", "5433", "app_test", "u", Some("p")),
            "Host=db.local;Port=5433;Database=app_test;Username=u;Password=p"
        );
        assert_eq!(
            npgsql_connection_string_from("a;b", " 5432", "d", "u", None),
            "Host='a;b';Port=' 5432';Database=d;Username=u"
        );
        assert_eq!(
            npgsql_connection_string_from("h", "", "d", "u", Some("")),
            "Host=h;Port=5432;Database=d;Username=u"
        );
    }

    #[test]
    fn npgsql_value_quotes_any_surrounding_whitespace_and_control_characters() {
        // ADO.NET trims an unquoted value with char.IsWhiteSpace, not just ' ', and
        // ends one at a control character. Found by the `encoders` fuzz target.
        assert_eq!(npgsql_value("\u{a0}pw"), "'\u{a0}pw'");
        assert_eq!(npgsql_value("pw\t"), "'pw\t'");
        assert_eq!(npgsql_value("\u{2003}pw"), "'\u{2003}pw'");
        assert_eq!(npgsql_value("p\u{1}w"), "'p\u{1}w'");
        assert_eq!(npgsql_value("inner space"), "inner space");
    }

    #[test]
    fn package_refs_only_read_packagereference_across_quote_styles() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(
            tmp.path().join("app.csproj"),
            "<Project>\n\
             <!-- considered Testcontainers and EntityFrameworkCore.InMemory -->\n\
             <ItemGroup>\n\
             <ProjectReference Include=\"../Acme.Testcontainers.Data/Acme.csproj\" />\n\
             <PackageReference Include='Npgsql.EntityFrameworkCore.PostgreSQL' Version=\"9.0\" />\n\
             </ItemGroup>\n</Project>",
        )
        .unwrap();
        let pkgs = package_refs(tmp.path());
        assert_eq!(pkgs, vec!["Npgsql.EntityFrameworkCore.PostgreSQL".to_string()]);
        // Neither the comment nor the ProjectReference path (which embeds
        // "Testcontainers") is a dependency — matching either would wrongly make
        // setup a no-op.
        assert!(!pkgs.iter().any(|p| p.contains("Testcontainers") || p.contains("InMemory")));
        // The real package is single-quoted, which MSBuild allows.
        assert!(EfCore.detect(tmp.path()), "a single-quoted EF package reference is detected");
    }

    #[test]
    fn detects_by_efcore_csproj() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(!EfCore.detect(tmp.path()));
        std::fs::write(
            tmp.path().join("app.csproj"),
            "<Project><PackageReference Include=\"Microsoft.EntityFrameworkCore.Design\"/></Project>",
        )
        .unwrap();
        assert!(EfCore.detect(tmp.path()));
    }

    #[test]
    fn detects_efcore_in_nested_src_layout() {
        // The default `dotnet new` solution layout nests two deep.
        let tmp = tempfile::tempdir().unwrap();
        let proj = tmp.path().join("src/MyApp.Api");
        std::fs::create_dir_all(&proj).unwrap();
        std::fs::write(
            proj.join("MyApp.Api.csproj"),
            "<Project><PackageReference Include=\"Npgsql.EntityFrameworkCore.PostgreSQL\"/></Project>",
        )
        .unwrap();
        assert!(EfCore.detect(tmp.path()), "src/App/App.csproj should be detected");
    }

    #[test]
    fn ignores_csproj_under_a_fixtures_dir() {
        // A repo (like workon itself) that bundles a sample EF project under
        // tests/fixtures must not be mistaken for a .NET project.
        let tmp = tempfile::tempdir().unwrap();
        let fixture = tmp.path().join("tests/fixtures/efcore");
        std::fs::create_dir_all(&fixture).unwrap();
        std::fs::write(
            fixture.join("efcore.csproj"),
            "<Project><PackageReference Include=\"Npgsql.EntityFrameworkCore.PostgreSQL\"/></Project>",
        )
        .unwrap();
        assert!(!EfCore.detect(tmp.path()), "a fixture-nested csproj must not trigger detection");
    }

    #[test]
    fn inmemory_is_a_noop() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(
            tmp.path().join("app.csproj"),
            "<Project><PackageReference Include=\"Microsoft.EntityFrameworkCore.InMemory\"/></Project>",
        )
        .unwrap();
        let mise = std::collections::HashMap::new();
        let ctx = ProvisionCtx {
            project_dir: tmp.path(),
            project_name: "p",
            ws_id: "ws-x",
            ws_dir: tmp.path(),
            mise_vars: &mise,
        };
        assert!(EfCore.setup(&ctx).unwrap().resources.is_empty());
    }

    /// Full cycle against the real EF fixture: provision it and assert
    /// `dotnet ef database update` created the `Widgets` table in the isolated
    /// DB. Gated on Postgres AND the project being restored (obj/ present).
    #[test]
    fn ef_core_cycle_updates_isolated_db() {
        use crate::provision::Resource;
        use std::collections::HashMap;
        use std::process::Command;

        let name = test_db_name("effix", "ws-cycle");
        Resource::PostgresDb { name: name.clone() }.teardown();
        if DbEngine::Postgres.create(&name).is_err() {
            return;
        }
        Resource::PostgresDb { name: name.clone() }.teardown();

        let fixture = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/efcore"));
        if !fixture.join("obj").exists() {
            return; // `dotnet restore` not run
        }

        let mise = HashMap::new();
        let ctx = ProvisionCtx {
            project_dir: fixture,
            project_name: "effix",
            ws_id: "ws-cycle",
            ws_dir: fixture,
            mise_vars: &mise,
        };
        let setup = EfCore.setup(&ctx).unwrap();
        assert_eq!(setup.resources, vec![Resource::PostgresDb { name: name.clone() }]);

        let out =
            Command::new("psql").args(["-tAc", "select to_regclass('public.\"Widgets\"')", &name]).output().unwrap();
        let table = String::from_utf8_lossy(&out.stdout).trim().to_string();
        Resource::PostgresDb { name: name.clone() }.teardown();
        assert!(table.contains("Widgets"), "database update should have created Widgets, got {table:?}");
    }
}