use std::path::{Path, PathBuf};
use anyhow::Result;
use vcs_runner::Cmd;
use super::{run_step, test_db_name, DbEngine, ProvisionCtx, Provisioner, Setup};
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 {
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));
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 let Err(e) = engine.create(&db) {
return Ok(Setup::database_not_created(&db, &e));
}
let resource = engine.resource(&db);
let conn = npgsql_connection_string(&db);
let env_key = format!("ConnectionStrings__{CONN_KEY}");
let mut restore = Cmd::new("dotnet").args(["tool", "restore"]).in_dir(ctx.ws_dir);
for (k, v) in ctx.mise_vars {
restore = restore.env(k, v);
}
let restored = run_step(restore, "dotnet tool restore");
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);
}
let failed_steps = restored.into_iter().chain(run_step(cmd, "dotnet ef database update")).collect();
Ok(Setup { resources: vec![resource], env: vec![(env_key, conn)], failed_steps, ..Setup::default() })
}
}
fn csprojs(ws_dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
collect_csprojs(ws_dir, 0, &mut out);
out
}
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()
}
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) }
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);
}
}
}
fn npgsql_connection_string(name: &str) -> String {
npgsql_connection_string_in(name, &|var| std::env::var(var).ok())
}
fn npgsql_connection_string_in(name: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
let host = env("PGHOST").unwrap_or_default();
let host = if host.is_empty() || host.starts_with('/') { "localhost".to_string() } else { host };
let port = env("PGPORT").unwrap_or_default();
let user = env("PGUSER").or_else(|| env("USER")).unwrap_or_default();
npgsql_connection_string_from(&host, &port, name, &user, env("PGPASSWORD").as_deref())
}
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
}
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");
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"
);
}
fn env_of<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string())
}
#[test]
fn npgsql_connection_string_reads_the_pg_environment() {
let env = [("PGHOST", "db.local"), ("PGPORT", "5433"), ("PGUSER", "app"), ("PGPASSWORD", "pw")];
assert_eq!(
npgsql_connection_string_in("t", &env_of(&env)),
"Host=db.local;Port=5433;Database=t;Username=app;Password=pw"
);
let socket = [("PGHOST", "/tmp"), ("USER", "os")];
assert_eq!(
npgsql_connection_string_in("t", &env_of(&socket)),
"Host=localhost;Port=5432;Database=t;Username=os",
"a socket directory is reached over TCP on localhost"
);
assert_eq!(npgsql_connection_string_in("t", &env_of(&[])), "Host=localhost;Port=5432;Database=t;Username=''");
let process = |var: &str| std::env::var(var).ok();
assert_eq!(npgsql_connection_string("t"), npgsql_connection_string_in("t", &process));
}
#[test]
fn collect_csprojs_looks_three_levels_down() {
let tmp = tempfile::tempdir().unwrap();
for dir in ["a/b/c", "a/b/c/d"] {
std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
std::fs::write(tmp.path().join(dir).join("App.csproj"), "").unwrap();
}
let mut found = Vec::new();
collect_csprojs(tmp.path(), 0, &mut found);
assert_eq!(found, [tmp.path().join("a/b/c/App.csproj")]);
}
#[test]
fn npgsql_value_quotes_any_surrounding_whitespace_and_control_characters() {
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()]);
assert!(!pkgs.iter().any(|p| p.contains("Testcontainers") || p.contains("InMemory")));
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() {
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() {
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());
}
#[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; }
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:?}");
}
proptest::proptest! {
#[test]
fn npgsql_value_reads_back_as_the_original(v in "\\PC{0,20}") {
let out = npgsql_value(&v);
let back = if let Some(inner) = out.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
inner.replace("''", "'")
} else {
proptest::prop_assert!(!out.contains([';', '=', '\'', '"']), "{out:?}");
proptest::prop_assert_eq!(out.trim(), out.as_str());
proptest::prop_assert!(!out.is_empty());
out.clone()
};
proptest::prop_assert_eq!(back, v);
}
#[test]
fn include_value_reads_back_in_either_quote(name in "[A-Za-z0-9._-]{1,30}", single in proptest::bool::ANY) {
let q = if single { '\'' } else { '"' };
let tag = format!("<PackageReference Include={q}{name}{q} Version=\"8.0.0\" />");
proptest::prop_assert_eq!(include_value(&tag), Some(name));
}
}
}