Skip to content

Commit b3a65e8

Browse files
authored
Empty exception messages when command has missing required options (#115)
* Write exception message correctly in case of CommandParseException. Fixes #114 * Update exception message to use reason instead of because * Extend interfaces so there is a difference between a unprocessed and unused argument. * Add tests for #114 * Improve when printing errors and usage occures Actually fixes #114 * Codefactor: The code must not contain multiple blank lines in a row.
1 parent bffdbee commit b3a65e8

File tree

10 files changed

+251
-18
lines changed

10 files changed

+251
-18
lines changed

CommandLineParser.Tests/Exceptions/ExceptionsTest.cs

+75
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
using MatthiWare.CommandLine.Abstractions.Command;
2+
using MatthiWare.CommandLine.Abstractions.Usage;
23
using MatthiWare.CommandLine.Core.Attributes;
34
using MatthiWare.CommandLine.Core.Exceptions;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Moq;
7+
using System;
8+
using System.Collections.Generic;
49
using System.Linq;
510
using System.Threading.Tasks;
611
using Xunit;
@@ -130,6 +135,67 @@ public async Task CommandParseExceptionTestAsync()
130135
Assert.Same(parser.Commands.First(), result.Errors.Cast<CommandParseException>().First().Command);
131136
}
132137

138+
[Fact]
139+
public async Task CommandParseException_Prints_Errors()
140+
{
141+
var printerMock = new Mock<IUsagePrinter>();
142+
143+
Services.AddSingleton(printerMock.Object);
144+
145+
var parser = new CommandLineParser<OtherOptions>(Services);
146+
147+
parser.AddCommand<Options>()
148+
.Name("missing")
149+
.Required()
150+
.Configure(opt => opt.MissingOption)
151+
.Name("o")
152+
.Required();
153+
154+
var result = await parser.ParseAsync(new string[] { "-a", "1", "-b", "2", "-a", "10" ,"20" ,"30", "missing" });
155+
156+
printerMock.Verify(_ => _.PrintErrors(It.IsAny<IReadOnlyCollection<Exception>>()));
157+
}
158+
159+
[Fact]
160+
public void CommandParseException_Should_Contain_Correct_Message_Single()
161+
{
162+
var cmdMock = new Mock<ICommandLineCommand>();
163+
cmdMock.SetupGet(_ => _.Name).Returns("test");
164+
165+
var exceptionList = new List<Exception>
166+
{
167+
new Exception("msg1")
168+
};
169+
170+
var parseException = new CommandParseException(cmdMock.Object, exceptionList.AsReadOnly());
171+
var msg = parseException.Message;
172+
var expected = @"Unable to parse command 'test' reason: msg1";
173+
174+
Assert.Equal(expected, msg);
175+
}
176+
177+
[Fact]
178+
public void CommandParseException_Should_Contain_Correct_Message_Multiple()
179+
{
180+
var cmdMock = new Mock<ICommandLineCommand>();
181+
cmdMock.SetupGet(_ => _.Name).Returns("test");
182+
183+
var exceptionList = new List<Exception>
184+
{
185+
new Exception("msg1"),
186+
new Exception("msg2")
187+
};
188+
189+
var parseException = new CommandParseException(cmdMock.Object, exceptionList.AsReadOnly());
190+
var msg = parseException.Message;
191+
var expected = @"Unable to parse command 'test' because 2 errors occured
192+
- msg1
193+
- msg2
194+
";
195+
196+
Assert.Equal(expected, msg);
197+
}
198+
133199
[Fact]
134200
public void OptionParseExceptionTest()
135201
{
@@ -158,6 +224,15 @@ public async Task OptionParseExceptionTestAsync()
158224
Assert.Same(parser.Options.First(), result.Errors.Cast<OptionParseException>().First().Option);
159225
}
160226

227+
private class OtherOptions
228+
{
229+
[Required, Name("a")]
230+
public int A { get; set; }
231+
232+
[Required, Name("b")]
233+
public int B { get; set; }
234+
}
235+
161236
private class Options
162237
{
163238
[Required, Name("m", "missing")]

CommandLineParser.Tests/Usage/DamerauLevenshteinTests.cs

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using MatthiWare.CommandLine.Abstractions;
22
using MatthiWare.CommandLine.Abstractions.Command;
3+
using MatthiWare.CommandLine.Abstractions.Usage;
34
using MatthiWare.CommandLine.Core.Usage;
5+
using Microsoft.Extensions.DependencyInjection;
46
using Moq;
57
using System.Collections.Generic;
68
using Xunit;
@@ -107,15 +109,35 @@ public void NoSuggestionsReturnsEmpty()
107109
}
108110

109111
[Fact]
110-
public void Test()
112+
public void ShouldPrintSuggestion()
111113
{
114+
var builderMock = new Mock<IUsageBuilder>();
115+
Services.AddSingleton(builderMock.Object);
116+
112117
var parser = new CommandLineParser(Services);
113118

114119
parser.AddCommand().Name("cmd");
115120

116121
var result = parser.Parse(new[] { "cmdd" });
117122

118123
result.AssertNoErrors();
124+
builderMock.Verify(_ => _.AddSuggestion("cmd"), Times.Once());
125+
}
126+
127+
[Fact]
128+
public void ShouldNotPrintSuggestionForSkippedArguments()
129+
{
130+
var builderMock = new Mock<IUsageBuilder>();
131+
Services.AddSingleton(builderMock.Object);
132+
133+
var parser = new CommandLineParser(new CommandLineParserOptions { StopParsingAfter = "--" }, Services);
134+
135+
parser.AddCommand().Name("cmd");
136+
137+
var result = parser.Parse(new[] { "--", "cmdd" });
138+
139+
result.AssertNoErrors();
140+
builderMock.Verify(_ => _.AddSuggestion("cmd"), Times.Never());
119141
}
120142
}
121143
}

CommandLineParser.Tests/Usage/UsagePrinterTests.cs

+22-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Extensions.DependencyInjection;
1010
using Moq;
1111
using System;
12+
using System.Threading.Tasks;
1213
using Xunit;
1314
using Xunit.Abstractions;
1415

@@ -67,19 +68,39 @@ private class UsagePrinterCommandOptions
6768
[InlineData(new string[] { }, true)]
6869
[InlineData(new string[] { "-o", "bla" }, false)]
6970
[InlineData(new string[] { "-xd", "bla" }, true)]
71+
[InlineData(new string[] { "--", "test" }, true)]
7072
public void UsagePrintGetsCalledInCorrectCases(string[] args, bool called)
7173
{
7274
var printerMock = new Mock<IUsagePrinter>();
7375

7476
Services.AddSingleton(printerMock.Object);
7577

76-
var parser = new CommandLineParser<UsagePrinterGetsCalledOptions>(Services);
78+
var parser = new CommandLineParser<UsagePrinterGetsCalledOptions>(new CommandLineParserOptions { StopParsingAfter = "--" }, Services);
7779

7880
parser.Parse(args);
7981

8082
printerMock.Verify(mock => mock.PrintUsage(), called ? Times.Once() : Times.Never());
8183
}
8284

85+
[Theory]
86+
[InlineData(new string[] { "--", "get-al" }, false)]
87+
[InlineData(new string[] { "--", "get-all" }, false)]
88+
public async Task PrintUsage_ShouldBeCalled_When_Command_Is_Defined_After_StopParsingFlag(string[] args, bool _)
89+
{
90+
var printerMock = new Mock<IUsagePrinter>();
91+
92+
Services.AddSingleton(printerMock.Object);
93+
94+
var parser = new CommandLineParser(new CommandLineParserOptions { StopParsingAfter = "--" }, Services);
95+
96+
parser.AddCommand().Name("get-all");
97+
98+
await parser.ParseAsync(args);
99+
100+
printerMock.Verify(mock => mock.PrintUsage(), Times.Once());
101+
printerMock.Verify(mock => mock.PrintSuggestion(It.IsAny<UnusedArgumentModel>()), Times.Never());
102+
}
103+
83104
[Fact]
84105
public void UsagePrinterPrintsOptionCorrectly()
85106
{

CommandLineParser/Abstractions/Parsing/IArgumentManager.cs

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using MatthiWare.CommandLine.Abstractions.Models;
2+
using MatthiWare.CommandLine.Abstractions.Usage;
23
using System;
34
using System.Collections.Generic;
45

@@ -10,10 +11,22 @@ namespace MatthiWare.CommandLine.Abstractions.Parsing
1011
public interface IArgumentManager
1112
{
1213
/// <summary>
13-
/// Returns a read-only list of unused arguments
14+
/// Returns a read-only list of arguments that never got processed because they appeared after the <see cref="CommandLineParserOptions.StopParsingAfter"/> flag.
15+
/// </summary>
16+
IReadOnlyList<UnusedArgumentModel> UnprocessedArguments { get; }
17+
18+
/// <summary>
19+
/// Returns a read-only list of unused arguments.
20+
/// In most cases this will be mistyped arguments that are not mapped to the actual option/command names.
21+
/// You can pass these arguments inside the <see cref="IUsagePrinter.PrintSuggestion(UnusedArgumentModel)"/> to get a suggestion of what could be the correct argument.
1422
/// </summary>
1523
IReadOnlyList<UnusedArgumentModel> UnusedArguments { get; }
1624

25+
/// <summary>
26+
/// Returns if the <see cref="CommandLineParserOptions.StopParsingAfter"/> flag was found.
27+
/// </summary>
28+
bool StopParsingFlagSpecified { get; }
29+
1730
/// <summary>
1831
/// Tries to get the arguments associated to the current option
1932
/// </summary>

CommandLineParser/Abstractions/Usage/IUsagePrinter.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ public interface IUsagePrinter
2525
/// Print an argument
2626
/// </summary>
2727
/// <param name="argument">The given argument</param>
28-
[EditorBrowsable(EditorBrowsableState.Never)]
2928
[Obsolete("Use PrintCommandUsage or PrintOptionUsage instead")]
29+
[EditorBrowsable(EditorBrowsableState.Never)]
3030
void PrintUsage(IArgument argument);
3131

3232
/// <summary>
@@ -63,6 +63,11 @@ public interface IUsagePrinter
6363
/// <param name="errors">list of errors</param>
6464
void PrintErrors(IReadOnlyCollection<Exception> errors);
6565

66-
void PrintSuggestion(UnusedArgumentModel model);
66+
/// <summary>
67+
/// Prints suggestions based on the input arguments
68+
/// </summary>
69+
/// <param name="model">Input model</param>
70+
/// <returns>True if a suggestion was found and printed, otherwise false</returns>
71+
bool PrintSuggestion(UnusedArgumentModel model);
6772
}
6873
}

CommandLineParser/CommandLineParser.xml

+26-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CommandLineParser/CommandLineParser`TOption.cs

+28-2
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,24 @@ public async Task<IParserResult<TOption>> ParseAsync(string[] args, Cancellation
214214

215215
await AutoExecuteCommandsAsync(result, cancellationToken);
216216

217-
AutoPrintUsageAndErrors(result, args.Length == 0);
217+
AutoPrintUsageAndErrors(result, NoActualArgsSupplied(args.Length));
218218

219219
return result;
220220
}
221221

222+
private bool NoActualArgsSupplied(int argsCount)
223+
{
224+
int leftOverAmountOfArguments = argsCount;
225+
226+
if (argumentManager.StopParsingFlagSpecified)
227+
{
228+
leftOverAmountOfArguments--; // the flag itself
229+
leftOverAmountOfArguments -= argumentManager.UnprocessedArguments.Count;
230+
}
231+
232+
return leftOverAmountOfArguments == 0;
233+
}
234+
222235
private async Task ValidateAsync<T>(T @object, ParseResult<TOption> parseResult, List<Exception> errors, CancellationToken token)
223236
{
224237
if (parseResult.HelpRequested)
@@ -268,6 +281,10 @@ private void AutoPrintUsageAndErrors(ParseResult<TOption> result, bool noArgsSup
268281
{
269282
PrintHelpRequestedForArgument(result.HelpRequestedFor);
270283
}
284+
else if (!noArgsSupplied && result.HasErrors && result.Errors.Any(e => e is CommandParseException || e is OptionParseException))
285+
{
286+
PrintErrors(result.Errors);
287+
}
271288
else if (!noArgsSupplied && argumentManager.UnusedArguments.Count > 0)
272289
{
273290
PrintHelp();
@@ -303,7 +320,16 @@ private void PrintErrors(IReadOnlyCollection<Exception> errors)
303320

304321
private void PrintHelp() => Printer.PrintUsage();
305322

306-
private void PrintSuggestionsIfAny() => Printer.PrintSuggestion(argumentManager.UnusedArguments.First());
323+
private void PrintSuggestionsIfAny()
324+
{
325+
foreach (var argument in argumentManager.UnusedArguments)
326+
{
327+
if (Printer.PrintSuggestion(argument))
328+
{
329+
return;
330+
}
331+
}
332+
}
307333

308334
private async Task AutoExecuteCommandsAsync(ParseResult<TOption> result, CancellationToken cancellationToken)
309335
{

CommandLineParser/Core/Exceptions/CommandParseException.cs

+31-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
35
using MatthiWare.CommandLine.Abstractions.Command;
46

57
namespace MatthiWare.CommandLine.Core.Exceptions
@@ -20,7 +22,35 @@ public class CommandParseException : BaseParserException
2022
/// <param name="command">the failed command</param>
2123
/// <param name="innerExceptions">collection of inner exception</param>
2224
public CommandParseException(ICommandLineCommand command, IReadOnlyCollection<Exception> innerExceptions)
23-
: base(command, "", new AggregateException(innerExceptions))
25+
: base(command, CreateMessage(command, innerExceptions), new AggregateException(innerExceptions))
2426
{ }
27+
28+
private static string CreateMessage(ICommandLineCommand command, IReadOnlyCollection<Exception> exceptions)
29+
{
30+
if (exceptions.Count > 1)
31+
{
32+
return CreateMultipleExceptionsMessage(command, exceptions);
33+
}
34+
else
35+
{
36+
return CreateSingleExceptionMessage(command, exceptions.First());
37+
}
38+
}
39+
40+
private static string CreateSingleExceptionMessage(ICommandLineCommand command, Exception exception)
41+
=> $"Unable to parse command '{command.Name}' reason: {exception.Message}";
42+
43+
private static string CreateMultipleExceptionsMessage(ICommandLineCommand command, IReadOnlyCollection<Exception> exceptions)
44+
{
45+
var message = new StringBuilder();
46+
message.AppendLine($"Unable to parse command '{command.Name}' because {exceptions.Count} errors occured");
47+
48+
foreach (var exception in exceptions)
49+
{
50+
message.AppendLine($" - {exception.Message}");
51+
}
52+
53+
return message.ToString();
54+
}
2555
}
2656
}

0 commit comments

Comments
 (0)