File: EFCoreOperationExecutor.cs
Web Access
Project: src\src\Aspire.Hosting.EntityFrameworkCore\Aspire.Hosting.EntityFrameworkCore.csproj (Aspire.Hosting.EntityFrameworkCore)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREDOTNETTOOL
 
using System.Globalization;
using System.Reflection;
using System.Text;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Executes EF Core design-time operations by running a DotnetToolResource.
/// </summary>
/// <remarks>
/// This class uses a pre-created DotnetToolResource to execute EF Core operations through Aspire's orchestration.
/// The tool is automatically downloaded and run. The target project must reference Microsoft.EntityFrameworkCore.Design.
/// </remarks>
internal sealed class EFCoreOperationExecutor : IDisposable
{
    private readonly ProjectResource _startupProjectResource;
    private readonly string? _targetProjectPath;
    private readonly string? _contextTypeName;
    private readonly ILogger _logger;
    private readonly CancellationToken _cancellationToken;
    private readonly IServiceProvider _serviceProvider;
    private readonly DotnetToolResource _toolResource;
 
    private string? _startupProjectPath;
    private string? _resolvedTargetProjectPath;
    private string? _configuration;
    private bool _initialized;
    internal const string ToolStartCommandName = "ef-tool-start";
    internal string? ResolvedFramework { get; private set;}
 
    // EF Core CLI output prefixes (used with --prefix-output). All are exactly 9 characters.
    private const int PrefixLength = 9;
    private const string ErrorPrefix = "error:   ";
    private const string WarningPrefix = "warn:    ";
    private const string InfoPrefix = "info:    ";
    private const string DataPrefix = "data:    ";
    private const string VerbosePrefix = "verbose: ";
 
    // Process-wide fallback that serializes every `dotnet-ef` invocation driven by this AppHost.
    // The pipeline-step factory already chains migration generate steps via DependsOnSteps so two
    // dotnet-ef processes won't run concurrently during `aspire publish`. This semaphore is a
    // defense-in-depth backstop for:
    //   - run-mode resource commands (Update Database / Reset Database / ...) that the user can
    //     trigger from the dashboard on different migration resources at the same time, each on
    //     its own DotnetToolResource,
    //   - shared per-user state every dotnet-ef invocation touches: the `dotnet tool exec` cache,
    //     NuGet restore caches, and MSBuild node-reuse state under %USERPROFILE%, which are not
    //     safe under concurrent `dotnet ef` runs even when the projects don't overlap.
    // Held only for the duration of a single dotnet-ef execution; never awaits any other pipeline
    // step or command, so it cannot deadlock with the pipeline scheduler.
    private static readonly SemaphoreSlim s_globalDotnetEfLock = new(1, 1);
 
    public EFCoreOperationExecutor(
        ProjectResource startupProjectResource,
        string? targetProjectPath,
        string? contextTypeName,
        ILogger logger,
        CancellationToken cancellationToken,
        IServiceProvider serviceProvider,
        DotnetToolResource toolResource)
    {
        _startupProjectResource = startupProjectResource;
        _targetProjectPath = targetProjectPath;
        _contextTypeName = contextTypeName;
        _logger = logger;
        _cancellationToken = cancellationToken;
        _serviceProvider = serviceProvider;
        _toolResource = toolResource;
    }
 
    private EFOperationResult EnsurePathsInitialized()
    {
        if (_initialized)
        {
            return _startupProjectPath != null
                ? new EFOperationResult { Success = true }
                : new EFOperationResult { Success = false, ErrorMessage = "Could not determine project path." };
        }
 
        _initialized = true;
 
        // Get build settings from the entry assembly (AppHost)
        var entryAssembly = Assembly.GetEntryAssembly();
        if (entryAssembly?.Location is { } appHostAssemblyPath && !string.IsNullOrEmpty(appHostAssemblyPath))
        {
            // Get configuration from assembly attribute
            var configAttribute = entryAssembly.GetCustomAttribute<AssemblyConfigurationAttribute>();
            if (configAttribute?.Configuration is { } configuration)
            {
                _configuration = configuration;
            }
 
            // Get target framework from assembly attribute
            var frameworkAttribute = entryAssembly.GetCustomAttribute<System.Runtime.Versioning.TargetFrameworkAttribute>();
            if (frameworkAttribute?.FrameworkName is { } frameworkName)
            {
                // FrameworkName is in format ".NETCoreApp,Version=v8.0" - extract the version part
                const string versionPrefix = ".NETCoreApp,Version=v";
                var versionIndex = frameworkName.IndexOf(versionPrefix, StringComparison.OrdinalIgnoreCase);
                if (versionIndex >= 0)
                {
                    var version = frameworkName.Substring(versionIndex + versionPrefix.Length);
                    ResolvedFramework = $"net{version}";
                }
            }
 
            // Parse configuration, framework from the assembly path (if not found from attributes)
            ParseBuildSettingsFromPath(appHostAssemblyPath);
        }
 
        _startupProjectPath = GetProjectPath(_startupProjectResource);
        if (string.IsNullOrEmpty(_startupProjectPath))
        {
            return new EFOperationResult { Success = false, ErrorMessage = "Could not determine startup project path." };
        }
 
        _resolvedTargetProjectPath = _targetProjectPath ?? _startupProjectPath;
 
        if (string.IsNullOrEmpty(_resolvedTargetProjectPath))
        {
            return new EFOperationResult { Success = false, ErrorMessage = "Could not determine target project path." };
        }
 
        _logger.LogDebug("Using startup project: {StartupProject}", _startupProjectPath);
        _logger.LogDebug("Using target project: {TargetProject}", _resolvedTargetProjectPath);
 
        var workingDir = Path.GetDirectoryName(_startupProjectPath);
        if (!string.IsNullOrEmpty(workingDir))
        {
            var execAnnotation = _toolResource.Annotations.OfType<ExecutableAnnotation>().FirstOrDefault();
            if (execAnnotation != null && execAnnotation.WorkingDirectory != workingDir)
            {
                _toolResource.Annotations.Remove(execAnnotation);
                _toolResource.Annotations.Add(new ExecutableAnnotation
                {
                    Command = execAnnotation.Command,
                    WorkingDirectory = workingDir
                });
            }
        }
 
        return new EFOperationResult { Success = true };
    }
 
    private void ParseBuildSettingsFromPath(string assemblyPath)
    {
        var segments = assemblyPath.Split(Path.DirectorySeparatorChar, '/');
        var binIndex = Array.FindLastIndex(segments, s => s.Equals("bin", StringComparison.OrdinalIgnoreCase));
 
        if (binIndex < 0 || binIndex + 2 >= segments.Length)
        {
            return;
        }
 
        // Typical layout: bin/Debug/net10.0/ or bin/Debug/net10.0/win-x64/
        var configIndex = binIndex + 1;
        var frameworkIndex = binIndex + 2;
 
        if (_configuration == null && configIndex < segments.Length)
        {
            var configCandidate = segments[configIndex];
            if (configCandidate.Equals("Debug", StringComparison.OrdinalIgnoreCase) ||
                configCandidate.Equals("Release", StringComparison.OrdinalIgnoreCase))
            {
                _configuration = configCandidate;
            }
        }
 
        if (ResolvedFramework == null && frameworkIndex < segments.Length)
        {
            var frameworkCandidate = segments[frameworkIndex];
            if (frameworkCandidate.StartsWith("net", StringComparison.OrdinalIgnoreCase))
            {
                ResolvedFramework = frameworkCandidate;
            }
        }
    }
 
    private static string? GetProjectPath(ProjectResource projectResource)
    {
        if (projectResource.TryGetLastAnnotation<IProjectMetadata>(out var metadata))
        {
            return metadata.ProjectPath;
        }
        return null;
    }
 
    private async Task<EFOperationResult> ExecuteEfCommandAsync(string command, string subCommand, Dictionary<string, string?>? additionalArgs = null, bool noBuild = true)
    {
        var initResult = EnsurePathsInitialized();
        if (!initResult.Success)
        {
            return initResult;
        }
 
        // Build the EF command arguments (these go after the -- in dotnet tool exec).
        // `--no-build` is normally added because all interactive run-mode commands assume the
        // project was already built by the AppHost. Bundle generation during `aspire publish`
        // intentionally omits it: the publish pipeline doesn't pre-build the startup project,
        // and `dotnet ef migrations bundle` needs the migrations and startup projects compiled
        // (and matching the requested target runtime) before it can package the bundle.
        var efArgs = new List<string> { command, subCommand };
        if (noBuild)
        {
            efArgs.Add("--no-build");
        }
        efArgs.Add("--no-color");
        efArgs.Add("--prefix-output");
 
        if (_logger.IsEnabled(LogLevel.Debug))
        {
            efArgs.Add("--verbose");
        }
 
        // Add project paths
        efArgs.Add("--project");
        efArgs.Add(_resolvedTargetProjectPath!);
 
        if (_startupProjectPath != _resolvedTargetProjectPath)
        {
            efArgs.Add("--startup-project");
            efArgs.Add(_startupProjectPath!);
        }
 
        // Add configuration if available
        if (!string.IsNullOrEmpty(_configuration))
        {
            efArgs.Add("--configuration");
            efArgs.Add(_configuration);
        }
 
        // Add framework if available
        if (!string.IsNullOrEmpty(ResolvedFramework))
        {
            efArgs.Add("--framework");
            efArgs.Add(ResolvedFramework);
        }
 
        // Add context if specified
        if (!string.IsNullOrEmpty(_contextTypeName))
        {
            efArgs.Add("--context");
            efArgs.Add(_contextTypeName);
        }
 
        // Add additional arguments
        if (additionalArgs != null)
        {
            foreach (var (key, value) in additionalArgs)
            {
                efArgs.Add(key);
                if (!string.IsNullOrEmpty(value))
                {
                    efArgs.Add(value);
                }
            }
        }
 
        _logger.LogDebug("Executing dotnet tool exec dotnet-ef --yes -- {Args}", string.Join(" ", efArgs));
 
        // Acquire the global dotnet-ef lock outside the try/catch that maps exceptions to a failed
        // EFOperationResult: an OperationCanceledException from WaitAsync must propagate so the
        // caller observes cancellation rather than a generic failure result. Released in `finally`
        // only when we actually acquired it (lockAcquired is set true after WaitAsync returns).
        var lockAcquired = false;
        await s_globalDotnetEfLock.WaitAsync(_cancellationToken).ConfigureAwait(false);
        lockAcquired = true;
        try
        {
            // Get required services
            var resourceCommandService = _serviceProvider.GetRequiredService<ResourceCommandService>();
            var notificationService = _serviceProvider.GetRequiredService<ResourceNotificationService>();
            var loggerService = _serviceProvider.GetRequiredService<ResourceLoggerService>();
 
            var argsAnnotation = new CommandLineArgsCallbackAnnotation(args =>
            {
                foreach (var arg in efArgs)
                {
                    args.Add(arg);
                }
            });
            _toolResource.Annotations.Add(argsAnnotation);
 
            // Capture output before starting
            var dataBuilder = new StringBuilder();
            using var logCancellation = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
 
            try
            {
                // Start a background task to capture logs
                var logTask = CaptureLogsAsync(loggerService.WatchAsync(_toolResource), _logger, dataBuilder, logCancellation.Token);
 
                // Start the hidden tool resource using the explicitly configured command when present,
                // otherwise choose the available start-like command from the resource annotations.
                var startCommandName = GetToolStartCommandName(_toolResource);
                var startResult = await resourceCommandService.ExecuteCommandAsync(_toolResource, startCommandName, _cancellationToken).ConfigureAwait(false);
                if (!startResult.Success)
                {
                    return new EFOperationResult
                    {
                        Success = false,
                        ErrorMessage = startResult.Canceled
                            ? "dotnet-ef command was canceled."
                            : string.IsNullOrWhiteSpace(startResult.Message) ? "dotnet-ef command failed" : startResult.Message
                    };
                }
 
                // Wait for the resource to finish
                await notificationService.WaitForResourceAsync(
                    _toolResource.Name,
                    r => r.Snapshot.State?.Text == KnownResourceStates.Finished ||
                         r.Snapshot.State?.Text == KnownResourceStates.Exited ||
                         r.Snapshot.State?.Text == KnownResourceStates.FailedToStart,
                    _cancellationToken).ConfigureAwait(false);
 
                // Give a moment for logs to flush, then cancel log capture
                await Task.Delay(100, _cancellationToken).ConfigureAwait(false);
                // Cancel log capture and wait for it to drain
                await logCancellation.CancelAsync().ConfigureAwait(false);
                try
                {
                    await logTask.WaitAsync(TimeSpan.FromSeconds(5), _cancellationToken).ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                    // Expected when we cancel the log capture
                }
                catch (TimeoutException)
                {
                    // Log drain took too long, proceed with what we have
                }
 
                var data = dataBuilder.ToString();
 
                // Get final state
                var resourceEvent = await notificationService.WaitForResourceAsync(
                    _toolResource.Name,
                    _ => true, // Just get current state
                    _cancellationToken).ConfigureAwait(false);
 
                // Check if the command succeeded
                var snapshot = resourceEvent.Snapshot;
                var exitCode = snapshot.ExitCode;
                if ((exitCode != null && exitCode.Value != 0) || snapshot.State?.Text == KnownResourceStates.FailedToStart)
                {
                    return new EFOperationResult
                    {
                        Success = false,
                        ErrorMessage = "dotnet-ef command failed"
                    };
                }
 
                return new EFOperationResult { Success = true, Output = !string.IsNullOrEmpty(data) ? data : "[]" };
            }
            finally
            {
                // Clean up command-specific annotations
                _toolResource.Annotations.Remove(argsAnnotation);
            }
        }
        catch (Exception ex)
        {
            return new EFOperationResult { Success = false, ErrorMessage = $"dotnet-ef command failed: {ex.Message}" };
        }
        finally
        {
            if (lockAcquired)
            {
                s_globalDotnetEfLock.Release();
            }
        }
    }
 
    private static string GetToolStartCommandName(DotnetToolResource toolResource)
    {
        // In run mode the DCP lifecycle wiring adds a standard start command — prefer that.
        // In publish mode no lifecycle commands are added, so fall back to the EF-specific one.
        if (toolResource.Annotations.OfType<ResourceCommandAnnotation>().Any(a => a.Name == KnownResourceCommands.StartCommand))
        {
            return KnownResourceCommands.StartCommand;
        }
 
        return ToolStartCommandName;
    }
 
    internal static async Task CaptureLogsAsync(
        IAsyncEnumerable<IReadOnlyList<LogLine>> logChannel,
        ILogger logger,
        StringBuilder dataBuilder,
        CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var entries in logChannel.WithCancellation(cancellationToken).ConfigureAwait(false))
            {
                foreach (var entry in entries)
                {
                    var (logLevel, normalizedContent, isData) = ParsePrefixedOutput(entry.Content, entry.IsErrorMessage);
 
                    if (isData)
                    {
                        dataBuilder.AppendLine(normalizedContent);
                    }
                    else
                    {
                        logger.Log(logLevel, "{Output}", normalizedContent);
                    }
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Expected when cancellation is requested
        }
    }
 
    internal static async Task StreamOutputAsync(
        StreamReader reader,
        ILogger logger,
        bool isErrorOutput,
        StringBuilder? captureBuilder,
        CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
            if (line is null)
            {
                break;
            }
 
            var (logLevel, normalizedContent, isData) = ParsePrefixedOutput(line, isErrorOutput);
            if (!isData)
            {
                logger.Log(logLevel, "{Output}", normalizedContent);
                captureBuilder?.AppendLine(normalizedContent);
            }
        }
    }
 
    private static (LogLevel LogLevel, string Content, bool IsData) ParsePrefixedOutput(string content, bool isErrorMessage)
    {
        content = StripDateTimePrefix(content);
        if (content.Length >= PrefixLength)
        {
            var prefix = content.AsSpan(0, PrefixLength);
            var remainder = content[PrefixLength..];
 
            if (prefix.SequenceEqual(ErrorPrefix))
            {
                return (LogLevel.Error, remainder, false);
            }
            if (prefix.SequenceEqual(WarningPrefix))
            {
                return (LogLevel.Warning, remainder, false);
            }
            if (prefix.SequenceEqual(InfoPrefix))
            {
                return (LogLevel.Information, remainder, false);
            }
            if (prefix.SequenceEqual(DataPrefix))
            {
                return (LogLevel.Debug, remainder, true);
            }
            if (prefix.SequenceEqual(VerbosePrefix))
            {
                return (LogLevel.Trace, remainder, false);
            }
        }
 
        // Lines without a recognized prefix (e.g., raw subprocess output from MSBuild
        // evaluation) are treated as trace-level so they don't clutter normal output.
        // Error stream lines without a prefix are still treated as errors.
        return (isErrorMessage ? LogLevel.Error : LogLevel.Trace, content, false);
    }
 
    /// <summary>
    /// Strips the datetime prefix from log lines.
    /// Uses format from <see cref="KnownFormats.ConsoleLogsTimestampFormat"/>: "yyyy-MM-ddTHH:mm:ss.fffffffK"
    /// Examples:
    /// - UTC: 2026-02-06T21:54:18.2290000Z (28 chars)
    /// - Non-UTC: 2026-02-06T21:54:18.2290000-07:00 (33 chars)
    /// </summary>
    private static string StripDateTimePrefix(string content)
    {
        // Format from KnownFormats.ConsoleLogsTimestampFormat: "yyyy-MM-ddTHH:mm:ss.fffffffK"
        // The K specifier produces:
        // - 'Z' for UTC (total 28 characters)
        // - '+HH:mm' or '-HH:mm' for non-UTC (total 33 characters)
        
        // First verify common separators for ISO 8601 format
        if (content.Length < 29 ||
            content[4] != '-' ||   // yyyy-
            content[7] != '-' ||   // MM-
            content[10] != 'T' ||  // ddT
            content[13] != ':' ||  // HH:
            content[16] != ':' ||  // mm:
            content[19] != '.')    // ss.
        {
            return content;
        }
        
        // Check for UTC format: ends with 'Z' at position 27
        if (content.Length > 28 && content[27] == 'Z' && content[28] == ' ')
        {
            return content[29..];
        }
        
        // Check for non-UTC format: ends with offset like '-07:00' or '+05:30'
        // Position 26 is '+' or '-', position 29 is ':', position 32 is last digit, position 33 is space
        if (content.Length > 33 && 
            (content[26] == '+' || content[26] == '-') && 
            content[29] == ':' && 
            content[33] == ' ')
        {
            return content[34..];
        }
        
        return content;
    }
 
    public async Task<EFOperationResult> UpdateDatabaseAsync()
    {
        _logger.LogInformation("Updating database...");
        return await ExecuteEfCommandAsync("database", "update").ConfigureAwait(false);
    }
 
    public async Task<EFOperationResult> DropDatabaseAsync()
    {
        _logger.LogInformation("Dropping database...");
        return await ExecuteEfCommandAsync("database", "drop", new Dictionary<string, string?>
        {
            { "--force", null }
        }).ConfigureAwait(false);
    }
 
    public async Task<EFOperationResult> ResetDatabaseAsync()
    {
        // First drop the database
        var dropResult = await DropDatabaseAsync().ConfigureAwait(false);
        if (!dropResult.Success)
        {
            // If drop fails because database doesn't exist, continue with update
            if (!dropResult.ErrorMessage?.Contains("does not exist", StringComparison.OrdinalIgnoreCase) ?? false)
            {
                return dropResult;
            }
        }
 
        // Then update (recreate with migrations)
        return await UpdateDatabaseAsync().ConfigureAwait(false);
    }
 
    public async Task<EFOperationResult> AddMigrationAsync(string? migrationName = null, string? outputDir = null, string? @namespace = null)
    {
        migrationName ??= $"Migration_{DateTime.UtcNow:yyyyMMddHHmmss}";
        _logger.LogInformation("Creating migration with name: {MigrationName}", migrationName);
 
        var args = new Dictionary<string, string?>
        {
            { migrationName, null } // Migration name is positional
        };
 
        if (!string.IsNullOrEmpty(outputDir))
        {
            args["--output-dir"] = outputDir;
        }
 
        if (!string.IsNullOrEmpty(@namespace))
        {
            args["--namespace"] = @namespace;
        }
 
        return await ExecuteEfCommandAsync("migrations", "add", args).ConfigureAwait(false);
    }
 
    public async Task<EFOperationResult> RemoveMigrationAsync()
    {
        _logger.LogInformation("Removing last migration...");
        return await ExecuteEfCommandAsync("migrations", "remove", new Dictionary<string, string?>
        {
            { "--force", null }
        }).ConfigureAwait(false);
    }
 
    public async Task<EFOperationResult> GetDatabaseStatusAsync()
    {
        _logger.LogInformation("Getting migration status...");
 
        // Use migrations list --json to get structured output
        var result = await ExecuteEfCommandAsync("migrations", "list", new Dictionary<string, string?>
        {
            { "--json", null }
        }).ConfigureAwait(false);
 
        if (!result.Success)
        {
            // Handle case where database doesn't exist
            if (result.ErrorMessage?.Contains("does not exist", StringComparison.OrdinalIgnoreCase) == true ||
                result.ErrorMessage?.Contains("Cannot open database", StringComparison.OrdinalIgnoreCase) == true)
            {
                return new EFOperationResult
                {
                    Success = true,
                    Output = "**Database Status:** Database has not been created yet."
                };
            }
            return result;
        }
 
        // Parse JSON output from dotnet ef migrations list --json
        var migrationLines = new List<string>();
        var pendingCount = 0;
        var appliedCount = 0;
 
        // The JSON output is an array of migration objects with "id", "name", and "applied" properties
        using var doc = JsonDocument.Parse(result.Output ?? "[]");
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var migration in doc.RootElement.EnumerateArray())
            {
                var id = migration.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
                var name = migration.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
                var applied = migration.TryGetProperty("applied", out var appliedProp) && appliedProp.GetBoolean();
 
                var displayName = id ?? name ?? "Unknown";
 
                if (applied)
                {
                    migrationLines.Add($"- ✅ {displayName}");
                    appliedCount++;
                }
                else
                {
                    migrationLines.Add($"- ⚠️ {displayName} (Pending)");
                    pendingCount++;
                }
            }
        }
 
        var summary = new StringBuilder();
        summary.AppendLine(CultureInfo.InvariantCulture, $"**Applied Migrations:** {appliedCount}");
        summary.AppendLine(CultureInfo.InvariantCulture, $"**Pending Migrations:** {pendingCount}");
        summary.AppendLine();
 
        if (migrationLines.Count > 0)
        {
            summary.AppendLine("**Migration History:**");
            summary.AppendLine();
            foreach (var line in migrationLines)
            {
                summary.AppendLine(line);
            }
        }
        else
        {
            summary.AppendLine("No migrations found.");
        }
 
        // Check for pending model changes
        var pendingChangesResult = await ExecuteEfCommandAsync("migrations", "has-pending-model-changes").ConfigureAwait(false);
        summary.AppendLine();
 
        if (!pendingChangesResult.Success)
        {
            summary.AppendLine("ℹ️ Unable to check for pending model changes.");
        }
        else if (pendingChangesResult.Output?.Contains("Changes have been made", StringComparison.OrdinalIgnoreCase) == true ||
                 pendingChangesResult.Output?.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) == true)
        {
            summary.AppendLine("⚠️ Pending model changes detected. A new migration is needed.");
        }
        else
        {
            summary.AppendLine("✅ No pending model changes.");
        }
 
        return new EFOperationResult { Success = true, Output = summary.ToString() };
    }
 
    /// <summary>
    /// Generates a SQL migration script.
    /// </summary>
    /// <param name="outputPath">The output path for the script file.</param>
    /// <param name="idempotent">If true, generates an idempotent script with IF NOT EXISTS checks.</param>
    /// <param name="noTransactions">If true, omits transaction statements from the script.</param>
    /// <returns>The operation result.</returns>
    public async Task<EFOperationResult> GenerateMigrationScriptAsync(string? outputPath = null, bool idempotent = true, bool noTransactions = false)
    {
        _logger.LogInformation("Generating migration script...");
 
        var args = new Dictionary<string, string?>();
 
        if (idempotent)
        {
            args["--idempotent"] = null;
        }
 
        if (noTransactions)
        {
            args["--no-transactions"] = null;
        }
 
        if (!string.IsNullOrEmpty(outputPath))
        {
            args["--output"] = outputPath;
        }
 
        return await ExecuteEfCommandAsync("migrations", "script", args).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Generates a migration bundle executable.
    /// </summary>
    /// <param name="outputPath">The output path for the bundle file.</param>
    /// <param name="targetRuntime">The target runtime identifier (e.g., "linux-x64", "win-x64").</param>
    /// <param name="selfContained">If true, creates a self-contained bundle that includes the .NET runtime.</param>
    /// <returns>The operation result.</returns>
    public async Task<EFOperationResult> GenerateMigrationBundleAsync(string? outputPath = null, string? targetRuntime = null, bool selfContained = false)
    {
        _logger.LogInformation("Generating migration bundle...");
 
        var args = new Dictionary<string, string?>();
 
        if (!string.IsNullOrEmpty(outputPath))
        {
            args["--output"] = outputPath;
        }
 
        if (!string.IsNullOrEmpty(targetRuntime))
        {
            args["--target-runtime"] = targetRuntime;
        }
 
        if (selfContained)
        {
            args["--self-contained"] = null;
        }
 
        // Overwrite existing bundle
        args["--force"] = null;
 
        // The bundle command compiles the migrations + startup project for the target runtime,
        // so `--no-build` would defeat the purpose; let dotnet-ef drive the build itself.
        return await ExecuteEfCommandAsync("migrations", "bundle", args, noBuild: false).ConfigureAwait(false);
    }
 
    public void Dispose()
    {
        // Nothing to dispose - processes are cleaned up after each operation
    }
}
 
/// <summary>
/// Result of an EF Core operation.
/// </summary>
internal sealed record EFOperationResult
{
    public bool Success { get; init; }
    public string? ErrorMessage { get; init; }
    public string? Output { get; init; }
}