From c1a0c86d4542242e03f01ebe993c3905a8f4400f Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 10 Apr 2026 14:06:14 -0700 Subject: [PATCH 1/2] feat: implement C#/.NET hook executor (#7623) Add dotnetExecutor implementing HookExecutor for .NET (C#) hooks with two execution modes: - Project mode: discovers .csproj/.fsproj/.vbproj via walk-up, runs dotnet restore + build during Prepare, then dotnet run --project - Single-file mode: .cs scripts without a project file, validated against .NET 10+ SDK requirement Changes: - pkg/tools/language/dotnet_executor.go: dotnetExecutor implementation - pkg/tools/language/dotnet_executor_test.go: 14 unit tests (project mode, single-file mode, error cases, table-driven) - pkg/tools/dotnet/dotnet.go: add SdkVersion() for version detection - pkg/tools/language/project_discovery.go: add DiscoverDotNetProject() to avoid Python/Node.js project files shadowing .NET project files - cmd/container.go: register HookKindDotNet in hookExecutorMap - test/mocks/mocktools/hook_executors.go: register for test IoC - docs/language-hooks.md: add .NET examples, update status table Closes #7623 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 1 + cli/azd/docs/language-hooks.md | 52 +- cli/azd/pkg/tools/dotnet/dotnet.go | 14 + cli/azd/pkg/tools/language/dotnet_executor.go | 234 ++++++++ .../tools/language/dotnet_executor_test.go | 566 ++++++++++++++++++ cli/azd/pkg/tools/language/executor.go | 1 - .../pkg/tools/language/project_discovery.go | 64 ++ .../test/mocks/mocktools/hook_executors.go | 6 + 8 files changed, 934 insertions(+), 4 deletions(-) create mode 100644 cli/azd/pkg/tools/language/dotnet_executor.go create mode 100644 cli/azd/pkg/tools/language/dotnet_executor_test.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 920be836ddc..7ce2d9c97b8 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -822,6 +822,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { language.HookKindPython: language.NewPythonExecutor, language.HookKindJavaScript: language.NewJavaScriptExecutor, language.HookKindTypeScript: language.NewTypeScriptExecutor, + language.HookKindDotNet: language.NewDotNetExecutor, } for kind, constructor := range hookExecutorMap { diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md index 24eea5ee59f..e7275bef77e 100644 --- a/cli/azd/docs/language-hooks.md +++ b/cli/azd/docs/language-hooks.md @@ -13,7 +13,7 @@ unified lifecycle regardless of its executor: **Prepare → Execute → Cleanup* | Python | `python` | `.py` | ✅ Phase 1 | | JavaScript | `js` | `.js` | ✅ Phase 2 | | TypeScript | `ts` | `.ts` | ✅ Phase 3 | -| .NET (C#) | `dotnet` | `.cs` | 🔜 Planned | +| .NET (C#) | `dotnet` | `.cs` | ✅ Phase 4 | ## Configuration @@ -224,6 +224,49 @@ hooks: shell: sh ``` +### .NET hook with project — auto-detected from .cs extension + +When a `.csproj` (or `.fsproj`/`.vbproj`) is found near the script, azd +automatically runs `dotnet restore` and `dotnet build` during preparation, +then executes via `dotnet run --project`. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.cs + # .csproj in ./hooks/ → restore + build run automatically +``` + +### .NET single-file hook (.NET 10+) + +On .NET 10 or later, single `.cs` files can run without a project file. +azd detects the SDK version and runs `dotnet run script.cs` directly. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.cs + # No .csproj nearby + .NET 10+ SDK → single-file execution +``` + +### .NET hook — explicit kind + +```yaml +hooks: + postprovision: + run: ./hooks/setup + kind: dotnet +``` + +### .NET hook with working directory override + +```yaml +hooks: + postprovision: + run: ./tools/scripts/seed.cs + dir: ./tools # .csproj is in ./tools, not ./tools/scripts +``` + ## How It Works Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle: @@ -254,11 +297,14 @@ Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle: - **Inline scripts** are only supported for Bash and PowerShell hooks. All other executor types must reference a file path. - **Phase 1** supports Python as a non-shell executor. - **Phase 2** adds JavaScript and **Phase 3** adds TypeScript. - .NET support is planned for a future phase. + **Phase 2** adds JavaScript, **Phase 3** adds TypeScript, + and **Phase 4** adds .NET (C#). - **Virtual environments** (Python) are created in the project directory alongside the dependency file, following the naming convention `{dirName}_env`. - **TypeScript** hooks require Node.js 18+ and use `npx tsx` for execution. If `tsx` is not installed locally, `npx` will download it automatically. - **Package manager** for JS/TS hooks currently uses npm for dependency installation. Support for pnpm and yarn may be added in a future release. +- **.NET single-file** execution (`.cs` without a `.csproj`) requires .NET SDK + 10.0.0 or later. On older SDKs, create a `.csproj` project file alongside + the script. diff --git a/cli/azd/pkg/tools/dotnet/dotnet.go b/cli/azd/pkg/tools/dotnet/dotnet.go index 789c4455c5b..98ec0dcf3fb 100644 --- a/cli/azd/pkg/tools/dotnet/dotnet.go +++ b/cli/azd/pkg/tools/dotnet/dotnet.go @@ -90,6 +90,20 @@ func (cli *Cli) CheckInstalled(ctx context.Context) error { return nil } +// SdkVersion returns the installed .NET SDK version by running +// `dotnet --version` and parsing the output as semver. +func (cli *Cli) SdkVersion(ctx context.Context) (semver.Version, error) { + res, err := cli.commandRunner.Run(ctx, newDotNetRunArgs("--version")) + if err != nil { + return semver.Version{}, fmt.Errorf("checking %s version: %w", cli.Name(), err) + } + ver, err := tools.ExtractVersion(res.Stdout) + if err != nil { + return semver.Version{}, fmt.Errorf("parsing .NET SDK version from %q: %w", res.Stdout, err) + } + return ver, nil +} + func (cli *Cli) Restore(ctx context.Context, project string, env []string) error { runArgs := newDotNetRunArgs("restore", project) // Append user env vars to preserve base env set by newDotNetRunArgs (DOTNET_NOLOGO, etc.) diff --git a/cli/azd/pkg/tools/language/dotnet_executor.go b/cli/azd/pkg/tools/language/dotnet_executor.go new file mode 100644 index 00000000000..bb881138c0b --- /dev/null +++ b/cli/azd/pkg/tools/language/dotnet_executor.go @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" + "github.com/blang/semver/v4" +) + +// dotnetTools abstracts the .NET CLI operations needed by +// dotnetExecutor, decoupling it from the concrete [dotnet.Cli] +// for testability. [dotnet.Cli] satisfies this interface. +type dotnetTools interface { + CheckInstalled(ctx context.Context) error + SdkVersion(ctx context.Context) (semver.Version, error) + Restore(ctx context.Context, project string, env []string) error + Build( + ctx context.Context, + project string, configuration string, + output string, env []string, + ) error +} + +// minSingleFileVersion is the minimum .NET SDK version that +// supports running single .cs files without a project file. +var minSingleFileVersion = semver.Version{ + Major: 10, Minor: 0, Patch: 0, +} + +// dotnetExecutor implements [tools.HookExecutor] for .NET (C#) +// scripts. It supports two execution modes: +// - Project mode: when a .csproj/.fsproj/.vbproj is discovered +// near the script, runs dotnet restore → build → run --project. +// - Single-file mode: when no project file is found and the SDK +// is .NET 10+, runs dotnet run script.cs directly. +type dotnetExecutor struct { + commandRunner exec.CommandRunner + dotnetCli dotnetTools + + // projectPath is set by Prepare when a .NET project file is + // discovered. Empty means single-file mode. + projectPath string +} + +// NewDotNetExecutor creates a .NET HookExecutor. +// Takes only IoC-injectable deps. +func NewDotNetExecutor( + commandRunner exec.CommandRunner, + dotnetCli *dotnet.Cli, +) tools.HookExecutor { + return newDotNetExecutorInternal(commandRunner, dotnetCli) +} + +// newDotNetExecutorInternal creates a dotnetExecutor using the +// dotnetTools interface. This allows tests to inject mocks. +func newDotNetExecutorInternal( + commandRunner exec.CommandRunner, + dotnetCli dotnetTools, +) *dotnetExecutor { + return &dotnetExecutor{ + commandRunner: commandRunner, + dotnetCli: dotnetCli, + } +} + +// Prepare verifies that the .NET SDK is installed and, when a +// project file (.csproj/.fsproj/.vbproj) is found near the script, +// runs dotnet restore and dotnet build. +// +// For single-file .cs scripts (no project file), Prepare validates +// that the installed SDK supports single-file execution (.NET 10+). +func (e *dotnetExecutor) Prepare( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) error { + // 1. Verify .NET SDK is installed. + if err := e.dotnetCli.CheckInstalled(ctx); err != nil { + return &errorhandler.ErrorWithSuggestion{ + Err: err, + Message: ".NET SDK is required to run " + + ".NET hooks.", + Suggestion: "Install the .NET SDK from " + + "https://dotnet.microsoft.com/download", + Links: []errorhandler.ErrorLink{{ + Title: "Download .NET SDK", + URL: "https://dotnet.microsoft.com/download", + }}, + } + } + + // 2. Discover .NET project context (.csproj/.fsproj/.vbproj). + // Uses DiscoverDotNetProject instead of the generic + // DiscoverProjectFile to avoid Python/Node.js project files + // shadowing the .NET project file in mixed-language directories. + projCtx, err := DiscoverDotNetProject( + scriptPath, execCtx.BoundaryDir, + ) + if err != nil { + return fmt.Errorf( + "discovering .NET project file: %w", err, + ) + } + + // 3a. Project mode: restore and build. + if projCtx != nil { + if err := e.dotnetCli.Restore( + ctx, projCtx.DependencyFile, execCtx.EnvVars, + ); err != nil { + return fmt.Errorf( + "dotnet restore failed for %q: %w", + projCtx.DependencyFile, err, + ) + } + + if err := e.dotnetCli.Build( + ctx, projCtx.DependencyFile, "", "", + execCtx.EnvVars, + ); err != nil { + return fmt.Errorf( + "dotnet build failed for %q: %w", + projCtx.DependencyFile, err, + ) + } + + e.projectPath = projCtx.DependencyFile + return nil + } + + // 3b. Single-file mode: validate SDK version >= 10. + sdkVer, err := e.dotnetCli.SdkVersion(ctx) + if err != nil { + return fmt.Errorf( + "detecting .NET SDK version: %w", err, + ) + } + + if sdkVer.LT(minSingleFileVersion) { + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + ".NET SDK %s does not support single-file "+ + "C# execution (requires .NET 10+)", + sdkVer, + ), + Message: fmt.Sprintf( + "Single-file .cs hooks require .NET SDK "+ + "10.0.0 or later (installed: %s).", + sdkVer, + ), + Suggestion: "Create a .csproj project file " + + "alongside your script, or upgrade to " + + ".NET 10 or later.", + Links: []errorhandler.ErrorLink{{ + Title: "Download .NET 10", + URL: "https://dotnet.microsoft.com/" + + "download/dotnet/10.0", + }}, + } + } + + return nil +} + +// Execute runs the .NET hook at the given path. +// +// In project mode (Prepare found a project file): +// +// dotnet run --project +// +// In single-file mode: +// +// dotnet run +func (e *dotnetExecutor) Execute( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + var runArgs exec.RunArgs + + if e.projectPath != "" { + // Project mode. + runArgs = exec.NewRunArgs( + "dotnet", "run", + "--project", e.projectPath, + ) + } else { + // Single-file mode. + runArgs = exec.NewRunArgs( + "dotnet", "run", scriptPath, + ) + } + + // Set standard dotnet env vars to suppress noisy output, + // then append user-provided env vars. + runArgs = runArgs.WithEnv(append( + []string{ + "DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE=1", + "DOTNET_NOLOGO=1", + }, + execCtx.EnvVars..., + )) + + // Prefer configured cwd; fall back to script's directory. + cwd := execCtx.Cwd + if cwd == "" { + cwd = filepath.Dir(scriptPath) + } + runArgs = runArgs.WithCwd(cwd) + + if execCtx.Interactive != nil { + runArgs = runArgs.WithInteractive( + *execCtx.Interactive, + ) + } + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) + } + + return e.commandRunner.Run(ctx, runArgs) +} + +// Cleanup is a no-op for the .NET executor — no temporary +// resources are created during Prepare. +func (e *dotnetExecutor) Cleanup(_ context.Context) error { + return nil +} diff --git a/cli/azd/pkg/tools/language/dotnet_executor_test.go b/cli/azd/pkg/tools/language/dotnet_executor_test.go new file mode 100644 index 00000000000..88b85efaaba --- /dev/null +++ b/cli/azd/pkg/tools/language/dotnet_executor_test.go @@ -0,0 +1,566 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/blang/semver/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockDotNetTools — test double for the dotnetTools interface +// --------------------------------------------------------------------------- + +type mockDotNetTools struct { + checkInstalledErr error + sdkVersionResult semver.Version + sdkVersionErr error + restoreErr error + buildErr error + + restoreCalled bool + restoreProject string + buildCalled bool + buildProject string +} + +func (m *mockDotNetTools) CheckInstalled( + _ context.Context, +) error { + return m.checkInstalledErr +} + +func (m *mockDotNetTools) SdkVersion( + _ context.Context, +) (semver.Version, error) { + return m.sdkVersionResult, m.sdkVersionErr +} + +func (m *mockDotNetTools) Restore( + _ context.Context, + project string, + _ []string, +) error { + m.restoreCalled = true + m.restoreProject = project + return m.restoreErr +} + +func (m *mockDotNetTools) Build( + _ context.Context, + project string, _ string, + _ string, _ []string, +) error { + m.buildCalled = true + m.buildProject = project + return m.buildErr +} + +// --------------------------------------------------------------------------- +// Prepare tests — project mode +// --------------------------------------------------------------------------- + +func TestDotNetPrepare_DotNetNotInstalled(t *testing.T) { + cli := &mockDotNetTools{ + checkInstalledErr: errors.New("dotnet not found"), + } + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + err := e.Prepare( + t.Context(), "/any/hook.cs", execCtx, + ) + + require.Error(t, err) + + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains(t, sugErr.Message, + ".NET SDK is required") + assert.NotEmpty(t, sugErr.Suggestion) + assert.NotEmpty(t, sugErr.Links) + assert.False(t, cli.restoreCalled) + assert.False(t, cli.buildCalled) +} + +func TestDotNetPrepare_WithCsproj(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "MyProject.csproj"), + "", + ) + + cli := &mockDotNetTools{} + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(hooksDir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + + assert.True(t, cli.restoreCalled, + "should run dotnet restore") + assert.Contains(t, cli.restoreProject, + "MyProject.csproj") + assert.True(t, cli.buildCalled, + "should run dotnet build") + assert.Contains(t, cli.buildProject, + "MyProject.csproj") + assert.NotEmpty(t, e.projectPath, + "should set projectPath for Execute") +} + +func TestDotNetPrepare_RestoreFails(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "App.csproj"), + "", + ) + + cli := &mockDotNetTools{ + restoreErr: errors.New("restore failed"), + } + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "dotnet restore failed") + assert.False(t, cli.buildCalled, + "should not build when restore fails") +} + +func TestDotNetPrepare_BuildFails(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "App.csproj"), + "", + ) + + cli := &mockDotNetTools{ + buildErr: errors.New("build failed"), + } + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "dotnet build failed") + assert.True(t, cli.restoreCalled, + "should restore before failing on build") +} + +// --------------------------------------------------------------------------- +// Prepare tests — single-file mode +// --------------------------------------------------------------------------- + +func TestDotNetPrepare_SingleFile_Net10(t *testing.T) { + dir := t.TempDir() + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 10, Minor: 0, Patch: 100, + }, + } + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + } + scriptPath := filepath.Join(dir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.False(t, cli.restoreCalled, + "single-file mode should not restore") + assert.False(t, cli.buildCalled, + "single-file mode should not build") + assert.Empty(t, e.projectPath, + "projectPath should be empty for single-file") +} + +func TestDotNetPrepare_SingleFile_OldSdk(t *testing.T) { + dir := t.TempDir() + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 8, Minor: 0, Patch: 300, + }, + } + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + } + scriptPath := filepath.Join(dir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.Error(t, err) + + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains(t, sugErr.Message, + "Single-file .cs hooks require") + assert.Contains(t, sugErr.Suggestion, + "Create a .csproj project file") + assert.NotEmpty(t, sugErr.Links) +} + +func TestDotNetPrepare_SingleFile_VersionDetectFails( + t *testing.T, +) { + dir := t.TempDir() + cli := &mockDotNetTools{ + sdkVersionErr: errors.New("version parse error"), + } + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + } + scriptPath := filepath.Join(dir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), + "detecting .NET SDK version") +} + +// --------------------------------------------------------------------------- +// Execute tests +// --------------------------------------------------------------------------- + +func TestDotNetExecute_ProjectMode(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "App.csproj"), + "", + ) + + cli := &mockDotNetTools{} + runner := &mockCommandRunner{} + e := newDotNetExecutorInternal(runner, cli) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + Cwd: projectDir, + } + scriptPath := filepath.Join(hooksDir, "hook.cs") + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, "dotnet", runner.lastRunArgs.Cmd) + assert.Contains(t, runner.lastRunArgs.Args, "run") + assert.Contains(t, runner.lastRunArgs.Args, "--project") + assert.Equal(t, projectDir, runner.lastRunArgs.Cwd) +} + +func TestDotNetExecute_SingleFileMode(t *testing.T) { + dir := t.TempDir() + runner := &mockCommandRunner{} + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 10, Minor: 0, Patch: 0, + }, + } + e := newDotNetExecutorInternal(runner, cli) + + scriptPath := filepath.Join(dir, "hook.cs") + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Cwd: dir, + EnvVars: []string{"FOO=bar"}, + } + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, "dotnet", runner.lastRunArgs.Cmd) + assert.Equal(t, []string{"run", scriptPath}, + runner.lastRunArgs.Args) + assert.Equal(t, dir, runner.lastRunArgs.Cwd) + // Should include dotnet env vars + user env vars. + assert.Contains(t, runner.lastRunArgs.Env, + "DOTNET_NOLOGO=1") + assert.Contains(t, runner.lastRunArgs.Env, "FOO=bar") +} + +func TestDotNetExecute_FallbackCwd(t *testing.T) { + runner := &mockCommandRunner{} + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 10, Minor: 0, Patch: 0, + }, + } + e := newDotNetExecutorInternal(runner, cli) + + scriptDir := filepath.Join(t.TempDir(), "hooks") + scriptPath := filepath.Join(scriptDir, "hook.cs") + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + // Cwd intentionally empty + } + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd, + "should fall back to script directory") +} + +func TestDotNetExecute_InteractiveMode(t *testing.T) { + runner := &mockCommandRunner{} + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 10, Minor: 0, Patch: 0, + }, + } + e := newDotNetExecutorInternal(runner, cli) + + interactive := true + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Interactive: &interactive, + } + scriptPath := filepath.Join(t.TempDir(), "hook.cs") + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.True(t, runner.lastRunArgs.Interactive) +} + +func TestDotNetExecute_ScriptError(t *testing.T) { + runner := &mockCommandRunner{ + runResult: exec.NewRunResult(1, "", "error output"), + runErr: errors.New("exit code 1"), + } + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 10, Minor: 0, Patch: 0, + }, + } + e := newDotNetExecutorInternal(runner, cli) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + scriptPath := filepath.Join(t.TempDir(), "hook.cs") + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + res, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.Error(t, err) + assert.Equal(t, 1, res.ExitCode) +} + +func TestDotNetCleanup_NoOp(t *testing.T) { + e := newDotNetExecutorInternal( + &mockCommandRunner{}, &mockDotNetTools{}, + ) + require.NoError(t, e.Cleanup(t.Context())) +} + +// --------------------------------------------------------------------------- +// Table-driven comprehensive tests +// --------------------------------------------------------------------------- + +func TestDotNetExecutor_TableDriven(t *testing.T) { + tests := []struct { + name string + withCsproj bool + dotnetMiss bool + oldSdk bool + restoreErr error + buildErr error + runErr error + runExitCode int + wantPrepErr bool + wantExecErr bool + }{ + { + name: "ProjectMode_HappyPath", + withCsproj: true, + wantPrepErr: false, + wantExecErr: false, + }, + { + name: "SingleFile_Net10", + withCsproj: false, + wantPrepErr: false, + wantExecErr: false, + }, + { + name: "DotNetMissing", + dotnetMiss: true, + wantPrepErr: true, + }, + { + name: "SingleFile_OldSdk", + withCsproj: false, + oldSdk: true, + wantPrepErr: true, + }, + { + name: "RestoreFails", + withCsproj: true, + restoreErr: errors.New("restore failed"), + wantPrepErr: true, + }, + { + name: "BuildFails", + withCsproj: true, + buildErr: errors.New("build failed"), + wantPrepErr: true, + }, + { + name: "ScriptNonZeroExit", + runErr: errors.New("exit 1"), + runExitCode: 1, + wantExecErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "proj") + require.NoError(t, + os.MkdirAll(projectDir, 0o700), + ) + + if tt.withCsproj { + writeFile(t, + filepath.Join( + projectDir, "App.csproj", + ), + "", + ) + } + + var checkErr error + if tt.dotnetMiss { + checkErr = errors.New("dotnet not found") + } + + sdkVer := semver.Version{ + Major: 10, Minor: 0, Patch: 0, + } + if tt.oldSdk { + sdkVer = semver.Version{ + Major: 8, Minor: 0, Patch: 300, + } + } + + cli := &mockDotNetTools{ + checkInstalledErr: checkErr, + sdkVersionResult: sdkVer, + restoreErr: tt.restoreErr, + buildErr: tt.buildErr, + } + + runner := &mockCommandRunner{ + runResult: exec.NewRunResult( + tt.runExitCode, "", "", + ), + runErr: tt.runErr, + } + + e := newDotNetExecutorInternal(runner, cli) + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join( + projectDir, "hook.cs", + ) + + err := e.Prepare( + t.Context(), scriptPath, execCtx, + ) + if tt.wantPrepErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + _, err = e.Execute( + t.Context(), scriptPath, execCtx, + ) + if tt.wantExecErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go index 0c3323e8564..730c77bc80e 100644 --- a/cli/azd/pkg/tools/language/executor.go +++ b/cli/azd/pkg/tools/language/executor.go @@ -28,7 +28,6 @@ const ( // HookKindPython identifies Python scripts (.py files). HookKindPython HookKind = "python" // HookKindDotNet identifies .NET (C#) scripts (.cs files). - // Not yet supported — IoC resolution will fail with a descriptive error. HookKindDotNet HookKind = "dotnet" ) diff --git a/cli/azd/pkg/tools/language/project_discovery.go b/cli/azd/pkg/tools/language/project_discovery.go index 22413fd9442..ed6f5b086a8 100644 --- a/cli/azd/pkg/tools/language/project_discovery.go +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -192,6 +192,70 @@ func DiscoverNodeProject( } } +// DiscoverDotNetProject walks up the directory tree from the directory +// containing scriptPath, looking specifically for .NET project files +// (*.*proj: .csproj, .fsproj, .vbproj). +// +// Unlike [DiscoverProjectFile] which searches for all known project +// files in priority order, this function only matches .NET project +// files. This avoids false negatives in mixed-language directories +// where a Python or Node.js project file (higher priority in the +// generic list) would shadow the .NET project file. +// +// The search stops at boundaryDir. Returns nil without error when +// no project file is found. +func DiscoverDotNetProject( + scriptPath string, boundaryDir string, +) (*ProjectContext, error) { + scriptDir := filepath.Dir(scriptPath) + + absScript, err := filepath.Abs(scriptDir) + if err != nil { + return nil, fmt.Errorf( + "resolving script directory %q: %w", scriptDir, err, + ) + } + + absBoundary, err := filepath.Abs(boundaryDir) + if err != nil { + return nil, fmt.Errorf( + "resolving boundary directory %q: %w", + boundaryDir, err, + ) + } + + current := absScript + for { + pattern := filepath.Join(current, "*.*proj") + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf( + "glob %q in %q: %w", + "*.*proj", current, err, + ) + } + if len(matches) > 0 { + return &ProjectContext{ + ProjectDir: current, + DependencyFile: matches[0], + Language: HookKindDotNet, + }, nil + } + + // Stop when we've reached the boundary directory. + if pathsEqual(current, absBoundary) { + return nil, nil + } + + parent := filepath.Dir(current) + // Stop at filesystem root (parent == current). + if parent == current { + return nil, nil + } + current = parent + } +} + // pathsEqual compares two cleaned absolute paths for equality. // On Windows the comparison is case-insensitive to match the // filesystem behavior. diff --git a/cli/azd/test/mocks/mocktools/hook_executors.go b/cli/azd/test/mocks/mocktools/hook_executors.go index 64158e4b631..4a8322cef05 100644 --- a/cli/azd/test/mocks/mocktools/hook_executors.go +++ b/cli/azd/test/mocks/mocktools/hook_executors.go @@ -5,6 +5,7 @@ package mocktools import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" + "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/azure/azure-dev/cli/azd/pkg/tools/language" "github.com/azure/azure-dev/cli/azd/pkg/tools/node" "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" @@ -35,4 +36,9 @@ func RegisterHookExecutors(mockCtx *mocks.MockContext) { string(language.HookKindTypeScript), language.NewTypeScriptExecutor, ) + mockCtx.Container.MustRegisterSingleton(dotnet.NewCli) + mockCtx.Container.MustRegisterNamedTransient( + string(language.HookKindDotNet), + language.NewDotNetExecutor, + ) } From 3ef113d78b445c54c98b0f58a42b5a786a6bf573 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 10 Apr 2026 15:50:23 -0700 Subject: [PATCH 2/2] fix: address PR review feedback - Add --no-build to dotnet run in project mode since Prepare already runs restore + build, avoiding redundant work - Error on ambiguous project discovery when multiple *.*proj files exist in the same directory, guiding users to set the hook dir field - Add tests for both fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/tools/language/dotnet_executor.go | 4 ++- .../tools/language/dotnet_executor_test.go | 32 +++++++++++++++++++ .../pkg/tools/language/project_discovery.go | 11 ++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/tools/language/dotnet_executor.go b/cli/azd/pkg/tools/language/dotnet_executor.go index bb881138c0b..cec5cc48379 100644 --- a/cli/azd/pkg/tools/language/dotnet_executor.go +++ b/cli/azd/pkg/tools/language/dotnet_executor.go @@ -186,10 +186,12 @@ func (e *dotnetExecutor) Execute( var runArgs exec.RunArgs if e.projectPath != "" { - // Project mode. + // Project mode — skip restore/build since Prepare + // already ran them. runArgs = exec.NewRunArgs( "dotnet", "run", "--project", e.projectPath, + "--no-build", ) } else { // Single-file mode. diff --git a/cli/azd/pkg/tools/language/dotnet_executor_test.go b/cli/azd/pkg/tools/language/dotnet_executor_test.go index 88b85efaaba..89fb865df21 100644 --- a/cli/azd/pkg/tools/language/dotnet_executor_test.go +++ b/cli/azd/pkg/tools/language/dotnet_executor_test.go @@ -263,6 +263,36 @@ func TestDotNetPrepare_SingleFile_VersionDetectFails( "detecting .NET SDK version") } +func TestDotNetPrepare_AmbiguousProjectFiles(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "App.csproj"), + "", + ) + writeFile(t, + filepath.Join(projectDir, "Tests.csproj"), + "", + ) + + cli := &mockDotNetTools{} + e := newDotNetExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "hook.cs") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), + "found 2 .NET project files") + assert.Contains(t, err.Error(), "dir") + assert.False(t, cli.restoreCalled, + "should not restore when ambiguous") +} + // --------------------------------------------------------------------------- // Execute tests // --------------------------------------------------------------------------- @@ -299,6 +329,8 @@ func TestDotNetExecute_ProjectMode(t *testing.T) { assert.Equal(t, "dotnet", runner.lastRunArgs.Cmd) assert.Contains(t, runner.lastRunArgs.Args, "run") assert.Contains(t, runner.lastRunArgs.Args, "--project") + assert.Contains(t, runner.lastRunArgs.Args, "--no-build", + "should skip rebuild since Prepare already built") assert.Equal(t, projectDir, runner.lastRunArgs.Cwd) } diff --git a/cli/azd/pkg/tools/language/project_discovery.go b/cli/azd/pkg/tools/language/project_discovery.go index ed6f5b086a8..9c9ea1f68da 100644 --- a/cli/azd/pkg/tools/language/project_discovery.go +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -234,13 +234,22 @@ func DiscoverDotNetProject( "*.*proj", current, err, ) } - if len(matches) > 0 { + if len(matches) == 1 { return &ProjectContext{ ProjectDir: current, DependencyFile: matches[0], Language: HookKindDotNet, }, nil } + if len(matches) > 1 { + return nil, fmt.Errorf( + "found %d .NET project files in %q; "+ + "set the hook 'dir' field to the "+ + "directory containing the intended "+ + "project file", + len(matches), current, + ) + } // Stop when we've reached the boundary directory. if pathsEqual(current, absBoundary) {