From 0f8ad421317b92388b53bbb5516b6eb476066210 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 09:52:25 +0200 Subject: [PATCH 1/9] Add support for file-based apps to dotnet-ef tools --- src/dotnet-ef/Project.cs | 9 ++-- src/dotnet-ef/ProjectOptions.cs | 2 + .../Properties/Resources.Designer.cs | 16 +++++- src/dotnet-ef/Properties/Resources.resx | 10 +++- src/dotnet-ef/RootCommand.cs | 17 ++++++- test/dotnet-ef.Tests/FileBasedAppTest.cs | 49 +++++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 test/dotnet-ef.Tests/FileBasedAppTest.cs diff --git a/src/dotnet-ef/Project.cs b/src/dotnet-ef/Project.cs index dc890dd55f0..e3cf94551a1 100644 --- a/src/dotnet-ef/Project.cs +++ b/src/dotnet-ef/Project.cs @@ -51,8 +51,7 @@ public static Project FromFile( { Debug.Assert(!string.IsNullOrEmpty(file), "file is null or empty."); - var args = new List { "msbuild", }; - + var args = new List { GetDotnetCommand(file), }; if (framework != null) { args.Add($"/property:TargetFramework={framework}"); @@ -146,9 +145,13 @@ private record class ProjectMetadata public Dictionary[]> Items { get; set; } = null!; } + // File-based apps (.cs) require "dotnet build" since "dotnet msbuild" cannot parse .cs files directly. + private static string GetDotnetCommand(string file) + => string.Equals(Path.GetExtension(file), ".cs", StringComparison.OrdinalIgnoreCase) ? "build" : "msbuild"; + private static bool HasMultipleTargetFrameworks(string file) { - var args = new List { "msbuild", "/getProperty:TargetFrameworks", file }; + var args = new List { GetDotnetCommand(file), "/getProperty:TargetFrameworks", file }; var output = new StringBuilder(); var exitCode = Exe.Run("dotnet", args, handleOutput: line => output.AppendLine(line)); diff --git a/src/dotnet-ef/ProjectOptions.cs b/src/dotnet-ef/ProjectOptions.cs index b2f9222cc6c..db8fe60dc75 100644 --- a/src/dotnet-ef/ProjectOptions.cs +++ b/src/dotnet-ef/ProjectOptions.cs @@ -9,6 +9,7 @@ namespace Microsoft.EntityFrameworkCore.Tools; internal class ProjectOptions { public CommandOption? Project { get; private set; } + public CommandOption? File { get; private set; } public CommandOption? StartupProject { get; private set; } public CommandOption? Framework { get; private set; } public CommandOption? Configuration { get; private set; } @@ -20,6 +21,7 @@ internal class ProjectOptions public void Configure(CommandLineApplication command) { Project = command.Option("-p|--project ", Resources.ProjectDescription); + File = command.Option("--file ", Resources.FileDescription); StartupProject = command.Option("-s|--startup-project ", Resources.StartupProjectDescription); Framework = command.Option("--framework ", Resources.FrameworkDescription); Configuration = command.Option("--configuration ", Resources.ConfigurationDescription); diff --git a/src/dotnet-ef/Properties/Resources.Designer.cs b/src/dotnet-ef/Properties/Resources.Designer.cs index d4071969249..da812a2ac1f 100644 --- a/src/dotnet-ef/Properties/Resources.Designer.cs +++ b/src/dotnet-ef/Properties/Resources.Designer.cs @@ -223,6 +223,12 @@ public static string DotNetEfConfigUnknownProperty(object? configFile, object? p public static string EFFullName => GetString("EFFullName"); + /// + /// The file to use. An alternative to --project for file-based apps. + /// + public static string FileDescription + => GetString("FileDescription"); + /// /// The target framework. Defaults to the first one in the project. /// @@ -522,7 +528,13 @@ public static string PrefixDescription => GetString("PrefixDescription"); /// - /// The project to use. Defaults to the current working directory. + /// The --project and --file options cannot be used together. + /// + public static string ProjectAndFileOptions + => GetString("ProjectAndFileOptions"); + + /// + /// The project or file to use. Defaults to the current working directory. /// public static string ProjectDescription => GetString("ProjectDescription"); @@ -566,7 +578,7 @@ public static string SelfContainedDescription => GetString("SelfContainedDescription"); /// - /// The startup project to use. Defaults to the current working directory. + /// The startup project or file to use. Defaults to the current working directory. /// public static string StartupProjectDescription => GetString("StartupProjectDescription"); diff --git a/src/dotnet-ef/Properties/Resources.resx b/src/dotnet-ef/Properties/Resources.resx index b2af87b0376..a2245b4c01e 100644 --- a/src/dotnet-ef/Properties/Resources.resx +++ b/src/dotnet-ef/Properties/Resources.resx @@ -192,6 +192,9 @@ Entity Framework Core .NET Command-line Tools + + The file to use. An alternative to --project for file-based apps. + Unable to read '{configFile}'. Fix the JSON and try again. {details} @@ -354,8 +357,11 @@ Prefix output with level. + + The --project and --file options cannot be used together. + - The project to use. Defaults to the current working directory. + The project or file to use. Defaults to the current working directory. Obsolete @@ -376,7 +382,7 @@ Also bundle the .NET runtime so it doesn't need to be installed on the machine. - The startup project to use. Defaults to the current working directory. + The startup project or file to use. Defaults to the current working directory. The suffix to attach to the name of all the generated files diff --git a/src/dotnet-ef/RootCommand.cs b/src/dotnet-ef/RootCommand.cs index 4e3180b3b40..cad270a3e20 100644 --- a/src/dotnet-ef/RootCommand.cs +++ b/src/dotnet-ef/RootCommand.cs @@ -15,6 +15,7 @@ internal class RootCommand : CommandBase { private CommandLineApplication? _command; private CommandOption? _project; + private CommandOption? _file; private CommandOption? _startupProject; private CommandOption? _framework; private CommandOption? _configuration; @@ -33,6 +34,7 @@ public override void Configure(CommandLineApplication command) options.Configure(command); _project = options.Project; + _file = options.File; _startupProject = options.StartupProject; _framework = options.Framework; _configuration = options.Configuration; @@ -60,7 +62,7 @@ protected override int Execute(string[] _) } var config = DotNetEfConfigLoader.Load(Directory.GetCurrentDirectory()); - var projectPath = _project!.Value() ?? config?.Project; + var projectPath = ResolveProjectOption(_project!, _file!, config?.Project); var startupProjectPath = _startupProject!.Value() ?? config?.StartupProject; var framework = _framework!.Value() ?? config?.Framework; var configuration = _configuration!.Value() ?? config?.Configuration; @@ -304,6 +306,19 @@ private static (string, string) ResolveProjects( return (projects[0], startupProjects[0]); } + internal static string? ResolveProjectOption( + CommandOption project, + CommandOption file, + string? configValue) + { + if (project.HasValue() && file.HasValue()) + { + throw new CommandException(Resources.ProjectAndFileOptions); + } + + return file.Value() ?? project.Value() ?? configValue; + } + private static List ResolveProjects(string? path) { if (path == null) diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/FileBasedAppTest.cs new file mode 100644 index 00000000000..b29e49448dc --- /dev/null +++ b/test/dotnet-ef.Tests/FileBasedAppTest.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Tools; + +public sealed class FileBasedAppTest +{ + [Fact] + public void Build() + { + using var directory = new TempDirectory(); + var csFile = Path.Combine(directory.Path, "MyApp.cs"); + File.WriteAllText(csFile, """Console.WriteLine("Hello");"""); + + var project = Project.FromFile(csFile); + + Assert.Equal("C#", project.Language); + Assert.Equal("MyApp", project.AssemblyName); + Assert.NotNull(project.TargetFrameworkMoniker); + Assert.NotNull(project.OutputPath); + Assert.NotNull(project.ProjectDir); + Assert.NotNull(project.TargetFileName); + + project.Build(additionalArgs: null); + + var targetDir = Path.GetFullPath(Path.Combine(project.ProjectDir!, project.OutputPath!)); + var targetPath = Path.Combine(targetDir, project.TargetFileName!); + Assert.True(File.Exists(targetPath), $"Expected build output at {targetPath}"); + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} From 74f06dcbe79e459cb6d7d0344c75f41d035e0faf Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 13:04:03 +0200 Subject: [PATCH 2/9] Make test verbose --- test/dotnet-ef.Tests/FileBasedAppTest.cs | 25 +++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/FileBasedAppTest.cs index b29e49448dc..eb6cb2bffc9 100644 --- a/test/dotnet-ef.Tests/FileBasedAppTest.cs +++ b/test/dotnet-ef.Tests/FileBasedAppTest.cs @@ -3,8 +3,20 @@ namespace Microsoft.EntityFrameworkCore.Tools; -public sealed class FileBasedAppTest +public sealed class FileBasedAppTest : IDisposable { + public FileBasedAppTest(ITestOutputHelper output) + { + Reporter.IsVerbose = true; + Reporter.SetStdOut(new TestOutputWriter(output)); + } + + public void Dispose() + { + Reporter.IsVerbose = false; + Reporter.SetStdOut(Console.Out); + } + [Fact] public void Build() { @@ -28,6 +40,17 @@ public void Build() Assert.True(File.Exists(targetPath), $"Expected build output at {targetPath}"); } + private sealed class TestOutputWriter(ITestOutputHelper output) : StringWriter + { + public override void WriteLine(string? value) + { + if (value != null) + { + output.WriteLine(value); + } + } + } + private sealed class TempDirectory : IDisposable { public TempDirectory() From 85af02714c70143e6446783a7d53d11d3813d2dc Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 15:29:35 +0200 Subject: [PATCH 3/9] Use .NET 10 in test --- test/dotnet-ef.Tests/FileBasedAppTest.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/FileBasedAppTest.cs index eb6cb2bffc9..fc56517f7e0 100644 --- a/test/dotnet-ef.Tests/FileBasedAppTest.cs +++ b/test/dotnet-ef.Tests/FileBasedAppTest.cs @@ -22,7 +22,10 @@ public void Build() { using var directory = new TempDirectory(); var csFile = Path.Combine(directory.Path, "MyApp.cs"); - File.WriteAllText(csFile, """Console.WriteLine("Hello");"""); + File.WriteAllText(csFile, """ + #:property TargetFramework=net10.0 + Console.WriteLine("Hello"); + """); var project = Project.FromFile(csFile); From 5ac7d9027ffc5ee827f73d6e070d57192febcd24 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 18:39:08 +0200 Subject: [PATCH 4/9] Improve tests --- test/dotnet-ef.Tests/FileBasedAppTest.cs | 127 ++++++++++++++++++----- 1 file changed, 99 insertions(+), 28 deletions(-) diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/FileBasedAppTest.cs index fc56517f7e0..557178bf66c 100644 --- a/test/dotnet-ef.Tests/FileBasedAppTest.cs +++ b/test/dotnet-ef.Tests/FileBasedAppTest.cs @@ -1,46 +1,117 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.DotNet.Cli.CommandLine; + namespace Microsoft.EntityFrameworkCore.Tools; -public sealed class FileBasedAppTest : IDisposable +public sealed class FileBasedAppTest(ITestOutputHelper output) { - public FileBasedAppTest(ITestOutputHelper output) + [Fact] + public void File_option_is_used_as_project() { - Reporter.IsVerbose = true; - Reporter.SetStdOut(new TestOutputWriter(output)); + var project = new CommandOption("--project ", CommandOptionType.SingleValue); + var file = new CommandOption("--file ", CommandOptionType.SingleValue); + file.TryParse("MyApp.cs"); + + var result = RootCommand.ResolveProjectOption(project, file, configValue: null); + + Assert.Equal("MyApp.cs", result); + } + + [Fact] + public void Project_option_is_used_when_file_is_not_specified() + { + var project = new CommandOption("--project ", CommandOptionType.SingleValue); + var file = new CommandOption("--file ", CommandOptionType.SingleValue); + project.TryParse("MyApp.csproj"); + + var result = RootCommand.ResolveProjectOption(project, file, configValue: null); + + Assert.Equal("MyApp.csproj", result); + } + + [Fact] + public void Config_value_is_used_when_no_options_specified() + { + var project = new CommandOption("--project ", CommandOptionType.SingleValue); + var file = new CommandOption("--file ", CommandOptionType.SingleValue); + + var result = RootCommand.ResolveProjectOption(project, file, configValue: "FromConfig"); + + Assert.Equal("FromConfig", result); + } + + [Fact] + public void File_option_takes_precedence_over_config() + { + var project = new CommandOption("--project ", CommandOptionType.SingleValue); + var file = new CommandOption("--file ", CommandOptionType.SingleValue); + file.TryParse("MyApp.cs"); + + var result = RootCommand.ResolveProjectOption(project, file, configValue: "FromConfig"); + + Assert.Equal("MyApp.cs", result); } - public void Dispose() + [Fact] + public void Project_and_file_options_together_throws() { - Reporter.IsVerbose = false; - Reporter.SetStdOut(Console.Out); + var project = new CommandOption("--project ", CommandOptionType.SingleValue); + var file = new CommandOption("--file ", CommandOptionType.SingleValue); + project.TryParse("MyApp.csproj"); + file.TryParse("MyApp.cs"); + + Assert.Throws( + () => RootCommand.ResolveProjectOption(project, file, configValue: null)); + } + + [Fact] + public void Returns_null_when_nothing_specified() + { + var project = new CommandOption("--project ", CommandOptionType.SingleValue); + var file = new CommandOption("--file ", CommandOptionType.SingleValue); + + var result = RootCommand.ResolveProjectOption(project, file, configValue: null); + + Assert.Null(result); } [Fact] public void Build() { - using var directory = new TempDirectory(); - var csFile = Path.Combine(directory.Path, "MyApp.cs"); - File.WriteAllText(csFile, """ - #:property TargetFramework=net10.0 - Console.WriteLine("Hello"); - """); - - var project = Project.FromFile(csFile); - - Assert.Equal("C#", project.Language); - Assert.Equal("MyApp", project.AssemblyName); - Assert.NotNull(project.TargetFrameworkMoniker); - Assert.NotNull(project.OutputPath); - Assert.NotNull(project.ProjectDir); - Assert.NotNull(project.TargetFileName); - - project.Build(additionalArgs: null); - - var targetDir = Path.GetFullPath(Path.Combine(project.ProjectDir!, project.OutputPath!)); - var targetPath = Path.Combine(targetDir, project.TargetFileName!); - Assert.True(File.Exists(targetPath), $"Expected build output at {targetPath}"); + var previousIsVerbose = Reporter.IsVerbose; + Reporter.IsVerbose = true; + Reporter.SetStdOut(new TestOutputWriter(output)); + try + { + using var directory = new TempDirectory(); + var csFile = Path.Combine(directory.Path, "MyApp.cs"); + File.WriteAllText(csFile, """ + #:property TargetFramework=net10.0 + Console.WriteLine("Hello"); + """); + + var project = Project.FromFile(csFile); + + Assert.Equal("C#", project.Language); + Assert.Equal("MyApp", project.AssemblyName); + Assert.NotNull(project.TargetFrameworkMoniker); + Assert.NotNull(project.OutputPath); + Assert.NotNull(project.ProjectDir); + Assert.NotNull(project.TargetFileName); + + project.Build(additionalArgs: null); + + var targetDir = Path.GetFullPath(Path.Combine(project.ProjectDir!, project.OutputPath!)); + var targetPath = Path.Combine(targetDir, project.TargetFileName!); + Assert.True(File.Exists(targetPath), $"Expected build output at {targetPath}"); + } + finally + { + Reporter.IsVerbose = previousIsVerbose; + Reporter.SetStdOut(Console.Out); + } } private sealed class TestOutputWriter(ITestOutputHelper output) : StringWriter From ffaaa26b3fbb70dd8e6e9bd61fc3facb8f6847fa Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 19:05:26 +0200 Subject: [PATCH 5/9] Just use build --- src/dotnet-ef/Project.cs | 8 ++------ test/dotnet-ef.Tests/FileBasedAppTest.cs | 2 ++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/dotnet-ef/Project.cs b/src/dotnet-ef/Project.cs index e3cf94551a1..41328190d9e 100644 --- a/src/dotnet-ef/Project.cs +++ b/src/dotnet-ef/Project.cs @@ -51,7 +51,7 @@ public static Project FromFile( { Debug.Assert(!string.IsNullOrEmpty(file), "file is null or empty."); - var args = new List { GetDotnetCommand(file), }; + var args = new List { "build", "--no-restore", }; if (framework != null) { args.Add($"/property:TargetFramework={framework}"); @@ -145,13 +145,9 @@ private record class ProjectMetadata public Dictionary[]> Items { get; set; } = null!; } - // File-based apps (.cs) require "dotnet build" since "dotnet msbuild" cannot parse .cs files directly. - private static string GetDotnetCommand(string file) - => string.Equals(Path.GetExtension(file), ".cs", StringComparison.OrdinalIgnoreCase) ? "build" : "msbuild"; - private static bool HasMultipleTargetFrameworks(string file) { - var args = new List { GetDotnetCommand(file), "/getProperty:TargetFrameworks", file }; + var args = new List { "build", "--no-restore", "/getProperty:TargetFrameworks", file }; var output = new StringBuilder(); var exitCode = Exe.Run("dotnet", args, handleOutput: line => output.AppendLine(line)); diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/FileBasedAppTest.cs index 557178bf66c..67960d0bfd7 100644 --- a/test/dotnet-ef.Tests/FileBasedAppTest.cs +++ b/test/dotnet-ef.Tests/FileBasedAppTest.cs @@ -92,6 +92,8 @@ public void Build() Console.WriteLine("Hello"); """); + Exe.Run("dotnet", ["restore", csFile], handleOutput: Reporter.WriteVerbose); + var project = Project.FromFile(csFile); Assert.Equal("C#", project.Language); From 4794a7ac4dac5ab8b493e69c870d0a13d6721a3e Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 19:11:17 +0200 Subject: [PATCH 6/9] Restore formatting --- src/dotnet-ef/Project.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dotnet-ef/Project.cs b/src/dotnet-ef/Project.cs index 41328190d9e..060434ce8c0 100644 --- a/src/dotnet-ef/Project.cs +++ b/src/dotnet-ef/Project.cs @@ -52,6 +52,7 @@ public static Project FromFile( Debug.Assert(!string.IsNullOrEmpty(file), "file is null or empty."); var args = new List { "build", "--no-restore", }; + if (framework != null) { args.Add($"/property:TargetFramework={framework}"); From 6fa98a429af58e1427c6af4d2e18872992d4f200 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 20:07:40 +0200 Subject: [PATCH 7/9] Remove file option --- src/dotnet-ef/ProjectOptions.cs | 2 - .../Properties/Resources.Designer.cs | 12 ---- src/dotnet-ef/Properties/Resources.resx | 6 -- src/dotnet-ef/RootCommand.cs | 17 +---- test/dotnet-ef.Tests/FileBasedAppTest.cs | 72 ------------------- 5 files changed, 1 insertion(+), 108 deletions(-) diff --git a/src/dotnet-ef/ProjectOptions.cs b/src/dotnet-ef/ProjectOptions.cs index db8fe60dc75..b2f9222cc6c 100644 --- a/src/dotnet-ef/ProjectOptions.cs +++ b/src/dotnet-ef/ProjectOptions.cs @@ -9,7 +9,6 @@ namespace Microsoft.EntityFrameworkCore.Tools; internal class ProjectOptions { public CommandOption? Project { get; private set; } - public CommandOption? File { get; private set; } public CommandOption? StartupProject { get; private set; } public CommandOption? Framework { get; private set; } public CommandOption? Configuration { get; private set; } @@ -21,7 +20,6 @@ internal class ProjectOptions public void Configure(CommandLineApplication command) { Project = command.Option("-p|--project ", Resources.ProjectDescription); - File = command.Option("--file ", Resources.FileDescription); StartupProject = command.Option("-s|--startup-project ", Resources.StartupProjectDescription); Framework = command.Option("--framework ", Resources.FrameworkDescription); Configuration = command.Option("--configuration ", Resources.ConfigurationDescription); diff --git a/src/dotnet-ef/Properties/Resources.Designer.cs b/src/dotnet-ef/Properties/Resources.Designer.cs index da812a2ac1f..4ccb712e99a 100644 --- a/src/dotnet-ef/Properties/Resources.Designer.cs +++ b/src/dotnet-ef/Properties/Resources.Designer.cs @@ -223,12 +223,6 @@ public static string DotNetEfConfigUnknownProperty(object? configFile, object? p public static string EFFullName => GetString("EFFullName"); - /// - /// The file to use. An alternative to --project for file-based apps. - /// - public static string FileDescription - => GetString("FileDescription"); - /// /// The target framework. Defaults to the first one in the project. /// @@ -527,12 +521,6 @@ public static string PlatformSpecificProject(object? startupProject, object? tar public static string PrefixDescription => GetString("PrefixDescription"); - /// - /// The --project and --file options cannot be used together. - /// - public static string ProjectAndFileOptions - => GetString("ProjectAndFileOptions"); - /// /// The project or file to use. Defaults to the current working directory. /// diff --git a/src/dotnet-ef/Properties/Resources.resx b/src/dotnet-ef/Properties/Resources.resx index a2245b4c01e..e4235ea1466 100644 --- a/src/dotnet-ef/Properties/Resources.resx +++ b/src/dotnet-ef/Properties/Resources.resx @@ -192,9 +192,6 @@ Entity Framework Core .NET Command-line Tools - - The file to use. An alternative to --project for file-based apps. - Unable to read '{configFile}'. Fix the JSON and try again. {details} @@ -357,9 +354,6 @@ Prefix output with level. - - The --project and --file options cannot be used together. - The project or file to use. Defaults to the current working directory. diff --git a/src/dotnet-ef/RootCommand.cs b/src/dotnet-ef/RootCommand.cs index cad270a3e20..4e3180b3b40 100644 --- a/src/dotnet-ef/RootCommand.cs +++ b/src/dotnet-ef/RootCommand.cs @@ -15,7 +15,6 @@ internal class RootCommand : CommandBase { private CommandLineApplication? _command; private CommandOption? _project; - private CommandOption? _file; private CommandOption? _startupProject; private CommandOption? _framework; private CommandOption? _configuration; @@ -34,7 +33,6 @@ public override void Configure(CommandLineApplication command) options.Configure(command); _project = options.Project; - _file = options.File; _startupProject = options.StartupProject; _framework = options.Framework; _configuration = options.Configuration; @@ -62,7 +60,7 @@ protected override int Execute(string[] _) } var config = DotNetEfConfigLoader.Load(Directory.GetCurrentDirectory()); - var projectPath = ResolveProjectOption(_project!, _file!, config?.Project); + var projectPath = _project!.Value() ?? config?.Project; var startupProjectPath = _startupProject!.Value() ?? config?.StartupProject; var framework = _framework!.Value() ?? config?.Framework; var configuration = _configuration!.Value() ?? config?.Configuration; @@ -306,19 +304,6 @@ private static (string, string) ResolveProjects( return (projects[0], startupProjects[0]); } - internal static string? ResolveProjectOption( - CommandOption project, - CommandOption file, - string? configValue) - { - if (project.HasValue() && file.HasValue()) - { - throw new CommandException(Resources.ProjectAndFileOptions); - } - - return file.Value() ?? project.Value() ?? configValue; - } - private static List ResolveProjects(string? path) { if (path == null) diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/FileBasedAppTest.cs index 67960d0bfd7..3a4cd0bad77 100644 --- a/test/dotnet-ef.Tests/FileBasedAppTest.cs +++ b/test/dotnet-ef.Tests/FileBasedAppTest.cs @@ -1,82 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Cli.CommandLine; - namespace Microsoft.EntityFrameworkCore.Tools; public sealed class FileBasedAppTest(ITestOutputHelper output) { - [Fact] - public void File_option_is_used_as_project() - { - var project = new CommandOption("--project ", CommandOptionType.SingleValue); - var file = new CommandOption("--file ", CommandOptionType.SingleValue); - file.TryParse("MyApp.cs"); - - var result = RootCommand.ResolveProjectOption(project, file, configValue: null); - - Assert.Equal("MyApp.cs", result); - } - - [Fact] - public void Project_option_is_used_when_file_is_not_specified() - { - var project = new CommandOption("--project ", CommandOptionType.SingleValue); - var file = new CommandOption("--file ", CommandOptionType.SingleValue); - project.TryParse("MyApp.csproj"); - - var result = RootCommand.ResolveProjectOption(project, file, configValue: null); - - Assert.Equal("MyApp.csproj", result); - } - - [Fact] - public void Config_value_is_used_when_no_options_specified() - { - var project = new CommandOption("--project ", CommandOptionType.SingleValue); - var file = new CommandOption("--file ", CommandOptionType.SingleValue); - - var result = RootCommand.ResolveProjectOption(project, file, configValue: "FromConfig"); - - Assert.Equal("FromConfig", result); - } - - [Fact] - public void File_option_takes_precedence_over_config() - { - var project = new CommandOption("--project ", CommandOptionType.SingleValue); - var file = new CommandOption("--file ", CommandOptionType.SingleValue); - file.TryParse("MyApp.cs"); - - var result = RootCommand.ResolveProjectOption(project, file, configValue: "FromConfig"); - - Assert.Equal("MyApp.cs", result); - } - - [Fact] - public void Project_and_file_options_together_throws() - { - var project = new CommandOption("--project ", CommandOptionType.SingleValue); - var file = new CommandOption("--file ", CommandOptionType.SingleValue); - project.TryParse("MyApp.csproj"); - file.TryParse("MyApp.cs"); - - Assert.Throws( - () => RootCommand.ResolveProjectOption(project, file, configValue: null)); - } - - [Fact] - public void Returns_null_when_nothing_specified() - { - var project = new CommandOption("--project ", CommandOptionType.SingleValue); - var file = new CommandOption("--file ", CommandOptionType.SingleValue); - - var result = RootCommand.ResolveProjectOption(project, file, configValue: null); - - Assert.Null(result); - } - [Fact] public void Build() { From fe15fd47fc0ea4e1529da9194048ec88cf710002 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 23 Apr 2026 20:09:06 +0200 Subject: [PATCH 8/9] Clarify resource strings --- src/dotnet-ef/Properties/Resources.Designer.cs | 4 ++-- src/dotnet-ef/Properties/Resources.resx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dotnet-ef/Properties/Resources.Designer.cs b/src/dotnet-ef/Properties/Resources.Designer.cs index 4ccb712e99a..e8714245063 100644 --- a/src/dotnet-ef/Properties/Resources.Designer.cs +++ b/src/dotnet-ef/Properties/Resources.Designer.cs @@ -522,7 +522,7 @@ public static string PrefixDescription => GetString("PrefixDescription"); /// - /// The project or file to use. Defaults to the current working directory. + /// The project or file-based app to use. Defaults to the current working directory. /// public static string ProjectDescription => GetString("ProjectDescription"); @@ -566,7 +566,7 @@ public static string SelfContainedDescription => GetString("SelfContainedDescription"); /// - /// The startup project or file to use. Defaults to the current working directory. + /// The startup project or file-based app to use. Defaults to the current working directory. /// public static string StartupProjectDescription => GetString("StartupProjectDescription"); diff --git a/src/dotnet-ef/Properties/Resources.resx b/src/dotnet-ef/Properties/Resources.resx index e4235ea1466..9a68c50fee2 100644 --- a/src/dotnet-ef/Properties/Resources.resx +++ b/src/dotnet-ef/Properties/Resources.resx @@ -355,7 +355,7 @@ Prefix output with level. - The project or file to use. Defaults to the current working directory. + The project or file-based app to use. Defaults to the current working directory. Obsolete @@ -376,7 +376,7 @@ Also bundle the .NET runtime so it doesn't need to be installed on the machine. - The startup project or file to use. Defaults to the current working directory. + The startup project or file-based app to use. Defaults to the current working directory. The suffix to attach to the name of all the generated files From 94390d5e924e5c9f17e05139e239b4dfc26651d5 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 24 Apr 2026 07:34:19 +0200 Subject: [PATCH 9/9] Improve tests --- test/dotnet-ef.Tests/DotNetEfConfigTest.cs | 18 +---- .../{FileBasedAppTest.cs => ProjectTest.cs} | 75 ++++++++++++------- test/dotnet-ef.Tests/TempDirectory.cs | 23 ++++++ 3 files changed, 71 insertions(+), 45 deletions(-) rename test/dotnet-ef.Tests/{FileBasedAppTest.cs => ProjectTest.cs} (53%) create mode 100644 test/dotnet-ef.Tests/TempDirectory.cs diff --git a/test/dotnet-ef.Tests/DotNetEfConfigTest.cs b/test/dotnet-ef.Tests/DotNetEfConfigTest.cs index c194190a2bd..46685d2f18c 100644 --- a/test/dotnet-ef.Tests/DotNetEfConfigTest.cs +++ b/test/dotnet-ef.Tests/DotNetEfConfigTest.cs @@ -264,25 +264,9 @@ private static string CreateConfig(string directory, string contents) return configFile; } - private sealed class TestDirectory : IDisposable + private sealed class TestDirectory : TempDirectory { - public TestDirectory() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - public string CreateConfig(string contents) => DotNetEfConfigTest.CreateConfig(Path, contents); - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } } } diff --git a/test/dotnet-ef.Tests/FileBasedAppTest.cs b/test/dotnet-ef.Tests/ProjectTest.cs similarity index 53% rename from test/dotnet-ef.Tests/FileBasedAppTest.cs rename to test/dotnet-ef.Tests/ProjectTest.cs index 3a4cd0bad77..87685e56767 100644 --- a/test/dotnet-ef.Tests/FileBasedAppTest.cs +++ b/test/dotnet-ef.Tests/ProjectTest.cs @@ -3,20 +3,44 @@ namespace Microsoft.EntityFrameworkCore.Tools; -public sealed class FileBasedAppTest(ITestOutputHelper output) +public sealed class ProjectTest(ITestOutputHelper output) { + private const string TargetFramework = "net10.0"; + [Fact] - public void Build() + public void Csproj_metadata_can_be_extracted() { - var previousIsVerbose = Reporter.IsVerbose; - Reporter.IsVerbose = true; - Reporter.SetStdOut(new TestOutputWriter(output)); - try + using var directory = new TempDirectory(); + var csprojFile = Path.Combine(directory.Path, "MyApp.csproj"); + File.WriteAllText(csprojFile, $""" + + + {TargetFramework} + + + """); + + Exe.Run("dotnet", ["restore", csprojFile], handleOutput: _ => { }); + + var project = Project.FromFile(csprojFile); + + Assert.Equal("C#", project.Language); + Assert.Equal("MyApp", project.AssemblyName); + Assert.Equal(TargetFramework, project.TargetFramework); + Assert.NotNull(project.OutputPath); + Assert.NotNull(project.ProjectDir); + Assert.Equal("MyApp.dll", project.TargetFileName); + } + + [Fact] + public void File_based_app_can_be_built() + { + WithVerboseOutput(() => { using var directory = new TempDirectory(); var csFile = Path.Combine(directory.Path, "MyApp.cs"); - File.WriteAllText(csFile, """ - #:property TargetFramework=net10.0 + File.WriteAllText(csFile, $""" + #:property TargetFramework={TargetFramework} Console.WriteLine("Hello"); """); @@ -26,7 +50,7 @@ public void Build() Assert.Equal("C#", project.Language); Assert.Equal("MyApp", project.AssemblyName); - Assert.NotNull(project.TargetFrameworkMoniker); + Assert.Equal(TargetFramework, project.TargetFramework); Assert.NotNull(project.OutputPath); Assert.NotNull(project.ProjectDir); Assert.NotNull(project.TargetFileName); @@ -36,10 +60,24 @@ public void Build() var targetDir = Path.GetFullPath(Path.Combine(project.ProjectDir!, project.OutputPath!)); var targetPath = Path.Combine(targetDir, project.TargetFileName!); Assert.True(File.Exists(targetPath), $"Expected build output at {targetPath}"); + }); + } + + private void WithVerboseOutput(Action action) + { + var previousIsVerbose = Reporter.IsVerbose; + var previousPrefixOutput = Reporter.PrefixOutput; + Reporter.IsVerbose = true; + Reporter.PrefixOutput = true; + Reporter.SetStdOut(new TestOutputWriter(output)); + try + { + action(); } finally { Reporter.IsVerbose = previousIsVerbose; + Reporter.PrefixOutput = previousPrefixOutput; Reporter.SetStdOut(Console.Out); } } @@ -54,23 +92,4 @@ public override void WriteLine(string? value) } } } - - private sealed class TempDirectory : IDisposable - { - public TempDirectory() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } - } } diff --git a/test/dotnet-ef.Tests/TempDirectory.cs b/test/dotnet-ef.Tests/TempDirectory.cs new file mode 100644 index 00000000000..ca92aeee97f --- /dev/null +++ b/test/dotnet-ef.Tests/TempDirectory.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Tools; + +internal class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } +}