workon 0.27.0

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! 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 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}");

        // Restore the local dotnet-ef tool if a manifest declares one, then apply. Without a
        // manifest the restore restores nothing and exits 0 (SDK 10.0.400).
        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() })
    }
}

/// `*.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 {
    npgsql_connection_string_in(name, &|var| std::env::var(var).ok())
}

/// [`npgsql_connection_string`] with the variables read through `env`, so a test can supply them.
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())
}

/// 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"
        );
    }

    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));
    }

    /// `.csproj` files are found down to three directories below the root, and no deeper.
    #[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() {
        // 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:?}");
    }

    proptest::proptest! {
        /// A value reads back under the ADO.NET rules: a quoted one by stripping the quotes
        /// and undoubling `''`; an unquoted one is the value itself and holds nothing the
        /// connection-string parser would split on or trim.
        #[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);
        }

        /// A package name reads back from `Include=` in either quote style.
        #[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));
        }
    }
}