From 1ae15fe463988038871254afa43f9c3befad58e2 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Wed, 17 Jun 2026 10:58:52 -0700 Subject: [PATCH 1/8] Improve RootCommand executable name handling (fix #2812) --- .../CompilationTests.cs | 109 ++++++++++++++- .../ParserTests.RootCommandAndArg0.cs | 130 ++++++++++++++---- .../RootCommandTests.cs | 12 ++ .../NativeLibrary/NativeLibrary.csproj | 32 +++++ .../TestApps/NativeLibrary/Program.cs | 32 +++++ .../Parsing/StringExtensions.cs | 20 ++- src/System.CommandLine/RootCommand.cs | 27 +++- .../System.CommandLine.csproj | 3 +- .../build/System.CommandLine.targets | 46 +++++++ 9 files changed, 382 insertions(+), 29 deletions(-) create mode 100644 src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj create mode 100644 src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs create mode 100644 src/System.CommandLine/build/System.CommandLine.targets diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index a5daaeec0a..a7ff1dee58 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -6,6 +6,7 @@ using System.CommandLine.Suggest; using System.CommandLine.Tests.Utility; using System.IO; +using System.Runtime.InteropServices; using System.Text; using FluentAssertions; using Microsoft.DotNet.PlatformAbstractions; @@ -42,6 +43,112 @@ public void App_referencing_system_commandline_can_be_compiled_ahead_of_time() } } + [ReleaseBuildOnlyFact] + public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a_native_library() + { + // When System.CommandLine is hosted inside a NativeAOT shared library there is no + // managed entry point, so Environment.GetCommandLineArgs() returns an empty array. + // RootCommand must then fall back to the executable name injected into AppContext by + // the build targets (the assembly name) rather than throwing. This exercises that + // path end-to-end through a real native library build. + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // TODO: Re-enable OSX validation when TFM is upgraded to net8.0. + return; + } + + var workingDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestApps", "NativeLibrary"); + var publishDirectory = Path.Combine(Path.GetTempPath(), "scl-nativelib-" + Guid.NewGuid().ToString("N")); + string rId = GetPortableRuntimeIdentifier(); + + Process.RunToCompletion( + DotnetMuxer.Path.FullName, + $"clean -c Release -r {rId}", + workingDirectory: workingDirectory); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + try + { + var exitCode = Process.RunToCompletion( + DotnetMuxer.Path.FullName, + string.Format( + "publish -c Release -r {0} --self-contained -o \"{1}\" -p:SystemCommandLineDllPath=\"{2}\" -p:TreatWarningsAsErrors=true", + rId, + publishDirectory, + _systemCommandLineDllPath), + s => + { + _output.WriteLine(s); + stdOut.Append(s); + }, + s => + { + _output.WriteLine(s); + stdErr.Append(s); + }, + workingDirectory); + + stdOut.ToString().Should().NotContain(": error CS"); + exitCode.Should().Be(0); + + string nativeLibraryPath = Path.Combine(publishDirectory, NativeLibraryFileName("NativeLibrary")); + File.Exists(nativeLibraryPath).Should().BeTrue($"the published native library should exist at {nativeLibraryPath}"); + + string executableName = InvokeGetExecutableName(nativeLibraryPath); + + // Equality to the assembly name proves the AppContext fallback was taken: had + // GetCommandLineArgs() been non-empty, the name would derive from the host path. + executableName.Should().Be("NativeLibrary"); + } + finally + { + if (Directory.Exists(publishDirectory)) + { + Directory.Delete(publishDirectory, recursive: true); + } + } + } + + private static string NativeLibraryFileName(string assemblyName) => + OperatingSystem.IsWindows() ? $"{assemblyName}.dll" + : OperatingSystem.IsMacOS() ? $"lib{assemblyName}.dylib" + : $"lib{assemblyName}.so"; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int GetExecutableNameDelegate(IntPtr buffer, int bufferLength); + + private static string InvokeGetExecutableName(string nativeLibraryPath) + { + IntPtr handle = NativeLibrary.Load(nativeLibraryPath); + + try + { + IntPtr export = NativeLibrary.GetExport(handle, "get_executable_name"); + var getExecutableName = Marshal.GetDelegateForFunctionPointer(export); + + int length = getExecutableName(IntPtr.Zero, 0); + byte[] buffer = new byte[length]; + + GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + getExecutableName(pinned.AddrOfPinnedObject(), length); + } + finally + { + pinned.Free(); + } + + return Encoding.UTF8.GetString(buffer); + } + finally + { + NativeLibrary.Free(handle); + } + } + private void PublishAndValidate(string appName, string warningText, string additionalArgs = null) { var stdOut = new StringBuilder(); @@ -86,7 +193,7 @@ private void PublishAndValidate(string appName, string warningText, string addit private static string GetPortableRuntimeIdentifier() { string osPart = OperatingSystem.IsWindows() ? "win" : (OperatingSystem.IsMacOS() ? "osx" : "linux"); - return $"{osPart}-{RuntimeEnvironment.RuntimeArchitecture}"; + return $"{osPart}-{Microsoft.DotNet.PlatformAbstractions.RuntimeEnvironment.RuntimeArchitecture}"; } } diff --git a/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs b/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs index 7776ff0c32..2b4bece456 100644 --- a/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs +++ b/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs @@ -2,8 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.CommandLine.Parsing; +using System.IO; using System.Linq; using FluentAssertions; +using FluentAssertions.Execution; using Xunit; namespace System.CommandLine.Tests; @@ -15,18 +17,29 @@ public partial class RootCommandAndArg0 [Fact] public void When_parsing_a_string_array_a_root_command_can_be_omitted_from_the_parsed_args() { + var option = new Option("-x"); var command = new Command("outer") { new Command("inner") { - new Option("-x") + option } }; var result1 = command.Parse(Split("inner -x hello")); var result2 = command.Parse(Split("outer inner -x hello")); + using var _ = new AssertionScope(); + result1.Diagram().Should().Be(result2.Diagram()); + + foreach (var result in new[] { result1, result2 }) + { + result.Errors.Should().BeEmpty(); + result.RootCommandResult.Command.Name.Should().Be("outer"); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } } [Fact] @@ -40,8 +53,6 @@ public void When_parsing_a_string_array_input_then_a_full_path_to_an_executable_ } }; - command.Parse(Split("inner -x hello")).Errors.Should().BeEmpty(); - var parserResult = command.Parse(Split($"\"{RootCommand.ExecutablePath}\" inner -x hello")); parserResult .Errors @@ -52,18 +63,29 @@ public void When_parsing_a_string_array_input_then_a_full_path_to_an_executable_ [Fact] public void When_parsing_an_unsplit_string_a_root_command_can_be_omitted_from_the_parsed_args() { + var option = new Option("-x"); var command = new Command("outer") { new Command("inner") { - new Option("-x") + option } }; var result1 = command.Parse("inner -x hello"); var result2 = command.Parse("outer inner -x hello"); + using var _ = new AssertionScope(); + result1.Diagram().Should().Be(result2.Diagram()); + + foreach (var result in new[] { result1, result2 }) + { + result.Errors.Should().BeEmpty(); + result.RootCommandResult.Command.Name.Should().Be("outer"); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } } [Fact] @@ -82,25 +104,6 @@ public void When_parsing_an_unsplit_string_then_input_a_full_path_to_an_executab result2.RootCommandResult.IdentifierToken.Value.Should().Be(RootCommand.ExecutablePath); } - [Fact] - public void When_parsing_an_unsplit_string_then_a_renamed_RootCommand_can_be_omitted_from_the_parsed_args() - { - var rootCommand = new Command("outer") - { - new Command("inner") - { - new Option("-x") - } - }; - - var result1 = rootCommand.Parse("inner -x hello"); - var result2 = rootCommand.Parse("outer inner -x hello"); - var result3 = rootCommand.Parse($"{RootCommand.ExecutableName} inner -x hello"); - - result2.RootCommandResult.Command.Should().BeSameAs(result1.RootCommandResult.Command); - result3.RootCommandResult.Command.Should().BeSameAs(result1.RootCommandResult.Command); - } - [Fact] public void When_parsing_a_string_array_option_values_containing_command_name_after_slash_are_not_treated_as_root_command() { @@ -112,6 +115,8 @@ public void When_parsing_a_string_array_option_values_containing_command_name_af var result = command.Parse(Split("/p:Key=something/myapp")); + using var _ = new AssertionScope(); + result.Errors.Should().BeEmpty(); result.GetResult(option).Should().NotBeNull(); } @@ -130,10 +135,89 @@ public void When_parsing_a_string_array_option_values_ending_with_slash_command_ var result = command.Parse(Split(arg)); + using var _ = new AssertionScope(); + result.Errors.Should().BeEmpty(); result.GetResult(option).Should().NotBeNull(); } + [Theory] + [MemberData(nameof(PathLikeFirstArgsMatchingTheRootCommand))] + public void When_parsing_a_string_array_a_path_like_first_arg_matching_the_root_command_is_treated_as_the_root_command(string pathLikeRootArg) + { + var option = new Option("-x"); + var command = new Command("outer") + { + new Command("inner") + { + option + } + }; + + var result = command.Parse([pathLikeRootArg, "inner", "-x", "hello"]); + + using var _ = new AssertionScope(); + + result.Errors.Should().BeEmpty(); + result.RootCommandResult.IdentifierToken.Value.Should().Be(pathLikeRootArg); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } + + public static TheoryData PathLikeFirstArgsMatchingTheRootCommand => + [ + "./outer", + "tools/outer", + @".\outer", + @"tools\outer" + ]; + + [Fact] + public void RootCommand_matches_its_executable_name_as_the_first_arg_the_same_way_a_named_Command_matches_its_name() + { + // Guards the assumption that a named Command can stand in for a RootCommand + // in arg0-matching tests: RootCommand.Name is forced to the executable name and + // cannot be changed, so the equivalent scenario uses the executable name as arg0. + var option = new Option("-x"); + var rootCommand = new RootCommand + { + new Command("inner") + { + option + } + }; + + var result = rootCommand.Parse([RootCommand.ExecutableName, "inner", "-x", "hello"]); + + using var _ = new AssertionScope(); + + result.Errors.Should().BeEmpty(); + result.RootCommandResult.IdentifierToken.Value.Should().Be(RootCommand.ExecutableName); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } + +#if NETFRAMEWORK + [Fact] + public void When_parsing_a_string_array_arguments_containing_invalid_path_characters_the_netframework_PathGetFileName_exception_is_ignored() + { + const string invalidPath = "my\0app"; + + // On .NET Framework, this throws ArgumentException for illegal characters in path. + Action getFileName = () => Path.GetFileName(invalidPath); + + var argument = new Argument("value"); + var command = new Command("myapp") { argument }; + var result = command.Parse(invalidPath); + + using var _ = new AssertionScope(); + + getFileName.Should().Throw(); + result.Errors.Should().BeEmpty(); + result.GetValue(argument).Should().Be(invalidPath); + } +#endif + string[] Split(string value) => CommandLineParser.SplitCommandLine(value).ToArray(); } } \ No newline at end of file diff --git a/src/System.CommandLine.Tests/RootCommandTests.cs b/src/System.CommandLine.Tests/RootCommandTests.cs index d5be20a34d..b6d76d1045 100644 --- a/src/System.CommandLine.Tests/RootCommandTests.cs +++ b/src/System.CommandLine.Tests/RootCommandTests.cs @@ -48,5 +48,17 @@ public void Setting_HelpName_does_not_change_Name() rootCommand.Name.Should().Be(RootCommand.ExecutableName); } + + [Fact] + public void ExecutablePath_is_not_null() + { + RootCommand.ExecutablePath.Should().NotBeNull(); + } + + [Fact] + public void ExecutableName_is_not_null_or_empty() + { + RootCommand.ExecutableName.Should().NotBeNullOrEmpty(); + } } } diff --git a/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj new file mode 100644 index 0000000000..6b1cef499d --- /dev/null +++ b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj @@ -0,0 +1,32 @@ + + + + $(NetMinimum) + + Shared + true + false + Guard + true + + + + ..\..\..\System.CommandLine\bin\Release\$(NetMinimum)\System.CommandLine.dll + + + + + $(SystemCommandLineDllPath) + + + + + + + diff --git a/src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs b/src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs new file mode 100644 index 0000000000..9bffcc34d8 --- /dev/null +++ b/src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.CommandLine; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +public static class Exports +{ + // Exported native symbol. Builds a RootCommand and returns its Name as UTF-8. + // When hosted as a native library, Environment.GetCommandLineArgs() is empty, + // so RootCommand.ExecutableName falls back to the AppContext value injected by + // the System.CommandLine build targets (which is the assembly name). + // + // Two-call protocol: pass a null buffer to get the required length, then call + // again with a buffer of that size. Returns the number of UTF-8 bytes. + [UnmanagedCallersOnly(EntryPoint = "get_executable_name", CallConvs = new[] { typeof(CallConvCdecl) })] + public static int GetExecutableName(IntPtr buffer, int bufferLength) + { + string name = new RootCommand().Name; + byte[] bytes = Encoding.UTF8.GetBytes(name); + + if (buffer != IntPtr.Zero && bufferLength >= bytes.Length) + { + Marshal.Copy(bytes, 0, buffer, bytes.Length); + } + + return bytes.Length; + } +} diff --git a/src/System.CommandLine/Parsing/StringExtensions.cs b/src/System.CommandLine/Parsing/StringExtensions.cs index ab6be26ba8..f134a44986 100644 --- a/src/System.CommandLine/Parsing/StringExtensions.cs +++ b/src/System.CommandLine/Parsing/StringExtensions.cs @@ -275,9 +275,25 @@ private static bool FirstArgumentIsRootCommand(IReadOnlyList args, Comma return true; } - if (rootCommand.EqualsNameOrAlias(args[0])) + if (args[0].TrySplitIntoSubtokens(out var first, out _) && + rootCommand.ValidTokens().TryGetValue(first, out var token) && + token.Type == TokenType.Option) { - return true; + return false; + } + + try + { + var potentialRootCommand = Path.GetFileName(args[0]); + + if (rootCommand.EqualsNameOrAlias(potentialRootCommand)) + { + return true; + } + } + catch (ArgumentException) + { + // possible exception for illegal characters in path on .NET Framework } } diff --git a/src/System.CommandLine/RootCommand.cs b/src/System.CommandLine/RootCommand.cs index b9172510f1..23b8229a8f 100644 --- a/src/System.CommandLine/RootCommand.cs +++ b/src/System.CommandLine/RootCommand.cs @@ -69,12 +69,12 @@ public string? HelpName /// The name of the currently running executable. /// public static string ExecutableName - => _executableName ??= Path.GetFileNameWithoutExtension(ExecutablePath).Replace(" ", ""); + => _executableName ??= GetExecutableName(); /// /// The path to the currently running executable. /// - public static string ExecutablePath => _executablePath ??= Environment.GetCommandLineArgs()[0]; + public static string ExecutablePath => _executablePath ??= GetExecutablePath(); private static string? ToolCommandName { @@ -91,5 +91,28 @@ private static string? ToolCommandName return _toolCommandName; } } + + private static string GetExecutablePath() + { + var args = Environment.GetCommandLineArgs(); + return args.Length > 0 ? args[0] : string.Empty; + } + + private static string GetExecutableName() + { + var path = ExecutablePath; + + if (path.Length > 0) + { + return Path.GetFileNameWithoutExtension(path).Replace(" ", ""); + } + + if (AppContext.GetData("System.CommandLine.ExecutableName") is string name && name.Length > 0) + { + return name; + } + + return "app"; + } } } diff --git a/src/System.CommandLine/System.CommandLine.csproj b/src/System.CommandLine/System.CommandLine.csproj index 83e10ad75d..a3e9e06e02 100644 --- a/src/System.CommandLine/System.CommandLine.csproj +++ b/src/System.CommandLine/System.CommandLine.csproj @@ -19,7 +19,8 @@ - + + diff --git a/src/System.CommandLine/build/System.CommandLine.targets b/src/System.CommandLine/build/System.CommandLine.targets new file mode 100644 index 0000000000..dcf4008a8c --- /dev/null +++ b/src/System.CommandLine/build/System.CommandLine.targets @@ -0,0 +1,46 @@ + + + + <_SystemCommandLineExecutableName Condition="'$(_SystemCommandLineExecutableName)' == ''">$(AssemblyName) + + + + <_SystemCommandLineGeneratedFile>$(IntermediateOutputPath)System.CommandLine.ExecutableName.g.cs + + + + + + <_SystemCommandLineGeneratedCode> +namespace System.CommandLine.Generated +{ + internal static class ExecutableNameInitializer + { + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Initialize() + { + global::System.AppContext.SetData("System.CommandLine.ExecutableName", "$(_SystemCommandLineExecutableName)"); + } + } +} +]]> + + + + + + + + + + + + From 4efa565ebb622895e670bdb57c3b5b478e482373 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Wed, 17 Jun 2026 15:54:28 -0700 Subject: [PATCH 2/8] Enhance build targets to support executable name caching for NativeAOT libraries --- .../NativeLibrary/NativeLibrary.csproj | 14 ++++++- .../build/System.CommandLine.targets | 40 ++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj index 6b1cef499d..42298fe373 100644 --- a/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj +++ b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj @@ -1,4 +1,11 @@ - + + + + $(NetMinimum) @@ -23,10 +30,13 @@ + + + generated for this NativeLib build. Imported after Sdk.targets so it sees + fully-resolved SDK properties, mirroring a real package consumer. --> diff --git a/src/System.CommandLine/build/System.CommandLine.targets b/src/System.CommandLine/build/System.CommandLine.targets index dcf4008a8c..1b719f30d1 100644 --- a/src/System.CommandLine/build/System.CommandLine.targets +++ b/src/System.CommandLine/build/System.CommandLine.targets @@ -6,13 +6,45 @@ <_SystemCommandLineGeneratedFile>$(IntermediateOutputPath)System.CommandLine.ExecutableName.g.cs + <_SystemCommandLineExecutableNameInputsFile>$(IntermediateOutputPath)System.CommandLine.ExecutableName.inputs.cache + + + + + + + + + + + + + + + + Condition="'$(_SystemCommandLineExecutableName)' != '' and '$(Language)' == 'C#' and '$(TargetFrameworkIdentifier)' == '.NETCoreApp' and $([MSBuild]::VersionGreaterThanOrEquals('$(TargetFrameworkVersion)', '5.0')) and '$(NativeLib)' != ''"> <_SystemCommandLineGeneratedCode> @@ -37,10 +69,6 @@ namespace System.CommandLine.Generated Overwrite="true" WriteOnlyWhenDifferent="true" /> - - - - From 7c2e7e1a67c639d2b2ff97d7ae28821ee55b8380 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Thu, 18 Jun 2026 14:36:38 -0700 Subject: [PATCH 3/8] Improve error reporting in CompilationTests for native library publishing --- src/System.CommandLine.Tests/CompilationTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index a7ff1dee58..59bf8d652c 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -90,8 +90,10 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a }, workingDirectory); - stdOut.ToString().Should().NotContain(": error CS"); - exitCode.Should().Be(0); + string publishOutput = $"{Environment.NewLine}STDOUT:{Environment.NewLine}{stdOut}{Environment.NewLine}STDERR:{Environment.NewLine}{stdErr}"; + + stdOut.ToString().Should().NotContain(": error CS", "the native library should compile cleanly. Publish output:{0}", publishOutput); + exitCode.Should().Be(0, "the native library should publish successfully. Publish output:{0}", publishOutput); string nativeLibraryPath = Path.Combine(publishDirectory, NativeLibraryFileName("NativeLibrary")); File.Exists(nativeLibraryPath).Should().BeTrue($"the published native library should exist at {nativeLibraryPath}"); From fcc42b3566cc5c762e682eedf0b870e619205ca2 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Mon, 22 Jun 2026 14:29:48 -0700 Subject: [PATCH 4/8] Refactor ParserTests to conditionally include Windows-specific path arguments and update project files to correctly reference System.CommandLine.targets --- .../ParserTests.RootCommandAndArg0.cs | 27 ++++++++++++++----- .../System.CommandLine.Tests.csproj | 1 + .../NativeLibrary/NativeLibrary.csproj | 6 ++++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs b/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs index 2b4bece456..619f66acf8 100644 --- a/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs +++ b/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs @@ -4,6 +4,7 @@ using System.CommandLine.Parsing; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using FluentAssertions; using FluentAssertions.Execution; using Xunit; @@ -164,13 +165,25 @@ public void When_parsing_a_string_array_a_path_like_first_arg_matching_the_root_ result.GetValue(option).Should().Be("hello"); } - public static TheoryData PathLikeFirstArgsMatchingTheRootCommand => - [ - "./outer", - "tools/outer", - @".\outer", - @"tools\outer" - ]; + public static TheoryData PathLikeFirstArgsMatchingTheRootCommand + { + get + { + var data = new TheoryData + { + "./outer", + "tools/outer" + }; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + data.Add(@".\outer"); + data.Add(@"tools\outer"); + } + + return data; + } + } [Fact] public void RootCommand_matches_its_executable_name_as_the_first_arg_the_same_way_a_named_Command_matches_its_name() diff --git a/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj b/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj index d199ef0a71..df99d1a233 100644 --- a/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj +++ b/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj index 42298fe373..f5bb0d5829 100644 --- a/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj +++ b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj @@ -32,11 +32,15 @@ + + $(MSBuildThisFileDirectory)..\System.CommandLine.targets + + - + From b7f0982ba212064a51742ed21a8ede71e8cf5fef Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Mon, 22 Jun 2026 15:03:41 -0700 Subject: [PATCH 5/8] Pass SystemCommandLineTargetsPath explicitly in NativeLibrary test and add diagnostic output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompilationTests.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index 59bf8d652c..fc315d1be2 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -59,11 +59,12 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a var workingDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestApps", "NativeLibrary"); var publishDirectory = Path.Combine(Path.GetTempPath(), "scl-nativelib-" + Guid.NewGuid().ToString("N")); + var targetsPath = Path.Combine(Directory.GetCurrentDirectory(), "TestApps", "System.CommandLine.targets"); string rId = GetPortableRuntimeIdentifier(); Process.RunToCompletion( DotnetMuxer.Path.FullName, - $"clean -c Release -r {rId}", + $"clean -c Release -r {rId} -p:SystemCommandLineTargetsPath=\"{targetsPath}\"", workingDirectory: workingDirectory); var stdOut = new StringBuilder(); @@ -74,10 +75,11 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a var exitCode = Process.RunToCompletion( DotnetMuxer.Path.FullName, string.Format( - "publish -c Release -r {0} --self-contained -o \"{1}\" -p:SystemCommandLineDllPath=\"{2}\" -p:TreatWarningsAsErrors=true", + "publish -c Release -r {0} --self-contained -o \"{1}\" -p:SystemCommandLineDllPath=\"{2}\" -p:SystemCommandLineTargetsPath=\"{3}\" -p:TreatWarningsAsErrors=true", rId, publishDirectory, - _systemCommandLineDllPath), + _systemCommandLineDllPath, + targetsPath), s => { _output.WriteLine(s); @@ -96,7 +98,18 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a exitCode.Should().Be(0, "the native library should publish successfully. Publish output:{0}", publishOutput); string nativeLibraryPath = Path.Combine(publishDirectory, NativeLibraryFileName("NativeLibrary")); - File.Exists(nativeLibraryPath).Should().BeTrue($"the published native library should exist at {nativeLibraryPath}"); + + if (!File.Exists(nativeLibraryPath) && Directory.Exists(publishDirectory)) + { + _output.WriteLine($"Expected native library at: {nativeLibraryPath}"); + _output.WriteLine("Files in publish directory:"); + foreach (var file in Directory.EnumerateFiles(publishDirectory, "*", SearchOption.AllDirectories)) + { + _output.WriteLine($" {file}"); + } + } + + File.Exists(nativeLibraryPath).Should().BeTrue($"the published native library should exist at {nativeLibraryPath}. Publish output:{publishOutput}"); string executableName = InvokeGetExecutableName(nativeLibraryPath); From 1623e559499a9939e007c45bd95777274a7b5b5e Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Mon, 22 Jun 2026 17:07:40 -0700 Subject: [PATCH 6/8] Improve native library file discovery in test Search publish directory for the native library if it's not at the expected path. Include file listing in assertion message for CI diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompilationTests.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index fc315d1be2..f2bafd5170 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -6,6 +6,7 @@ using System.CommandLine.Suggest; using System.CommandLine.Tests.Utility; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using FluentAssertions; @@ -101,16 +102,24 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a if (!File.Exists(nativeLibraryPath) && Directory.Exists(publishDirectory)) { - _output.WriteLine($"Expected native library at: {nativeLibraryPath}"); - _output.WriteLine("Files in publish directory:"); - foreach (var file in Directory.EnumerateFiles(publishDirectory, "*", SearchOption.AllDirectories)) + // Search for the native library in case it's in a subdirectory or has a different name + var candidates = Directory.EnumerateFiles(publishDirectory, "*NativeLibrary*", SearchOption.AllDirectories).ToArray(); + + string fileList = string.Join(Environment.NewLine, + Directory.EnumerateFiles(publishDirectory, "*", SearchOption.AllDirectories)); + + if (candidates.Length == 1) { - _output.WriteLine($" {file}"); + nativeLibraryPath = candidates[0]; + _output.WriteLine($"Native library found at alternate path: {nativeLibraryPath}"); + } + else + { + nativeLibraryPath.Should().Be("found", + $"the published native library was not found. Candidates: [{string.Join(", ", candidates)}]. All files in publish directory:{Environment.NewLine}{fileList}"); } } - File.Exists(nativeLibraryPath).Should().BeTrue($"the published native library should exist at {nativeLibraryPath}. Publish output:{publishOutput}"); - string executableName = InvokeGetExecutableName(nativeLibraryPath); // Equality to the assembly name proves the AppContext fallback was taken: had From 330c96421f2ba73e4d4dc74623d3d5d0a179df73 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Mon, 22 Jun 2026 19:03:51 -0700 Subject: [PATCH 7/8] Fix native library filename: NativeAOT doesn't add lib prefix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompilationTests.cs | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index f2bafd5170..0f81026730 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -100,25 +100,8 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a string nativeLibraryPath = Path.Combine(publishDirectory, NativeLibraryFileName("NativeLibrary")); - if (!File.Exists(nativeLibraryPath) && Directory.Exists(publishDirectory)) - { - // Search for the native library in case it's in a subdirectory or has a different name - var candidates = Directory.EnumerateFiles(publishDirectory, "*NativeLibrary*", SearchOption.AllDirectories).ToArray(); - - string fileList = string.Join(Environment.NewLine, - Directory.EnumerateFiles(publishDirectory, "*", SearchOption.AllDirectories)); - - if (candidates.Length == 1) - { - nativeLibraryPath = candidates[0]; - _output.WriteLine($"Native library found at alternate path: {nativeLibraryPath}"); - } - else - { - nativeLibraryPath.Should().Be("found", - $"the published native library was not found. Candidates: [{string.Join(", ", candidates)}]. All files in publish directory:{Environment.NewLine}{fileList}"); - } - } + File.Exists(nativeLibraryPath).Should().BeTrue( + "the published native library should exist at {0}. Publish output:{1}", nativeLibraryPath, publishOutput); string executableName = InvokeGetExecutableName(nativeLibraryPath); @@ -137,8 +120,8 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a private static string NativeLibraryFileName(string assemblyName) => OperatingSystem.IsWindows() ? $"{assemblyName}.dll" - : OperatingSystem.IsMacOS() ? $"lib{assemblyName}.dylib" - : $"lib{assemblyName}.so"; + : OperatingSystem.IsMacOS() ? $"{assemblyName}.dylib" + : $"{assemblyName}.so"; [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int GetExecutableNameDelegate(IntPtr buffer, int bufferLength); From e4f6326f6a78b01875fa4ec55751112be936267e Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Mon, 22 Jun 2026 19:17:56 -0700 Subject: [PATCH 8/8] Handle locked DLL during test cleanup on Windows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/System.CommandLine.Tests/CompilationTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index 0f81026730..90157506a9 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -111,9 +111,16 @@ public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a } finally { - if (Directory.Exists(publishDirectory)) + try + { + if (Directory.Exists(publishDirectory)) + { + Directory.Delete(publishDirectory, recursive: true); + } + } + catch (UnauthorizedAccessException) { - Directory.Delete(publishDirectory, recursive: true); + // On Windows the native library may still be locked after NativeLibrary.Free } } }