From 96fc47f7425e563eb0774e11ab7effd8aa6af83c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:25:32 +0000 Subject: [PATCH] Refuse an option alias that a standard switch or another option already owns The parser reads the standard switches before any application option and resolves application options first-match, so a Flag or Option spelled -s, --for, or like an earlier option could never be reached, while --help listed both. Flag and Option now throw ArgumentException naming the alias. Fixes #18 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SwBwi3NknT7dGVuSEED1DX --- TrayApp.Test/TrayAppBuilderTests.cs | 41 +++++++++++++++++++++++++++++ TrayApp/Cli/CommandLineParser.cs | 7 +++++ TrayApp/TrayAppBuilder.cs | 33 +++++++++++++++++++++-- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/TrayApp.Test/TrayAppBuilderTests.cs b/TrayApp.Test/TrayAppBuilderTests.cs index d563747..37cd1a8 100644 --- a/TrayApp.Test/TrayAppBuilderTests.cs +++ b/TrayApp.Test/TrayAppBuilderTests.cs @@ -31,6 +31,47 @@ public void Build_WithNoPreferences_TakesNoProvider() Assert.AreEqual("demo", app.DisplayName); } + [TestMethod] + [DataRow("-s")] + [DataRow("--status")] + [DataRow("-f")] + [DataRow("--for")] + [DataRow("-t")] + [DataRow("--no-tray")] + [DataRow("-h")] + [DataRow("-?")] + [DataRow("-v")] + public void Flag_WithAStandardSwitchAlias_Throws(string alias) + { + // The standard switches are read first, so an option spelled the same way could never be reached. + ArgumentException ex = Assert.ThrowsExactly( + () => TrayAppBuilder.Create("demo").Flag(["--quiet", alias], "Quiet.", () => { })); + + Assert.Contains($"'{alias}'", ex.Message, StringComparison.Ordinal); + Assert.AreEqual("aliases", ex.ParamName); + } + + [TestMethod] + public void Option_WithAStandardSwitchAlias_Throws() + { + ArgumentException ex = Assert.ThrowsExactly( + () => TrayAppBuilder.Create("demo").Option(["-f", "--file"], "path", "A file.", _ => { })); + + Assert.Contains("'-f'", ex.Message, StringComparison.Ordinal); + } + + [TestMethod] + public void Flag_WithAnAliasAnotherOptionTook_Throws() + { + TrayAppBuilder builder = TrayAppBuilder.Create("demo").Option(["-d", "--delay"], "time", "Delay.", _ => { }); + + ArgumentException ex = Assert.ThrowsExactly( + () => builder.Flag(["-d", "--display"], "Display.", () => { })); + + Assert.Contains("'-d'", ex.Message, StringComparison.Ordinal); + Assert.AreEqual(1, builder.Build().Options.Count); + } + [TestMethod] public void Build_CollectsItemsTogglesAndOptions() { diff --git a/TrayApp/Cli/CommandLineParser.cs b/TrayApp/Cli/CommandLineParser.cs index 42526b7..60a82be 100644 --- a/TrayApp/Cli/CommandLineParser.cs +++ b/TrayApp/Cli/CommandLineParser.cs @@ -31,6 +31,13 @@ private enum ReadResult Failed, } + /// + /// Gets the spellings of the standard switches, which an application option may not reuse: they are read + /// first, so an application option spelled the same way could never be reached. + /// + internal static IReadOnlyList StandardAliases { get; } = + ["-t", "--tray", "--no-tray", "-s", "--status", "-h", "--help", "-?", "-v", "--version", "-f", "--for"]; + /// /// Parses the arguments. /// diff --git a/TrayApp/TrayAppBuilder.cs b/TrayApp/TrayAppBuilder.cs index 85299cf..2bc48ce 100644 --- a/TrayApp/TrayAppBuilder.cs +++ b/TrayApp/TrayAppBuilder.cs @@ -5,6 +5,7 @@ namespace ktsu.TrayApp; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Avalonia; @@ -247,9 +248,12 @@ public TrayAppBuilder Separator() /// Called once when the switch is present. /// The builder, for chaining. /// Any argument is . + /// + /// An alias is one of the standard switches, or is already taken by another option. + /// public TrayAppBuilder Flag(IReadOnlyList aliases, string description, Action onSet) { - appOptions.Add(AppOption.Flag(aliases, description, onSet)); + AddOption(AppOption.Flag(aliases, description, onSet), nameof(aliases)); return this; } @@ -265,9 +269,12 @@ public TrayAppBuilder Flag(IReadOnlyList aliases, string description, Ac /// /// The builder, for chaining. /// Any argument is . + /// + /// An alias is one of the standard switches, or is already taken by another option. + /// public TrayAppBuilder Option(IReadOnlyList aliases, string valueName, string description, Action onValue) { - appOptions.Add(AppOption.Value(aliases, valueName, description, onValue)); + AddOption(AppOption.Value(aliases, valueName, description, onValue), nameof(aliases)); return this; } @@ -660,4 +667,26 @@ internal static int RecoverFromTrayFailure( return first.IsChecked; }; } + + /// + /// Registers an application option, refusing an alias that could never be reached: the standard switches + /// are read before any application option, and the first option to claim an alias wins it. + /// + private void AddOption(AppOption option, string paramName) + { + foreach (string alias in option.Aliases) + { + if (CommandLineParser.StandardAliases.Contains(alias, StringComparer.Ordinal)) + { + throw new ArgumentException($"'{alias}' is a standard switch and cannot be reused by an application option.", paramName); + } + + if (appOptions.Any(existing => existing.Matches(alias))) + { + throw new ArgumentException($"'{alias}' is already taken by another option.", paramName); + } + } + + appOptions.Add(option); + } }