Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,34 @@ public void AnUnknownCommandInsideABranchIsAUsageError() {
Check.That(error).Contains("frobnicate");
}

[Fact(DisplayName = "An option given without its value is a usage error (64).")]
[Fact(DisplayName = "An option no command declares is a usage error (64), naming the argument.")]
public void AnUndeclaredOptionIsAUsageError() {
// Exercise: config show succeeds on its own, so only the unknown option can be what fails this.
(int exitCode, string error) = Run("config", "show", "--nope");

// Verify
Check.That(exitCode).IsEqualTo(64);
Check.That(error).Contains("--nope");
}

// The regression this pins: a mistyped flag used to be collected into the parser's remaining arguments — which
// this tool never reads — so the command ran without it and reported success. A pipeline asking for something the
// tool did not do was told it had it.
[Fact(DisplayName = "A mistyped option is refused rather than ignored, and never reports success.")]
public void AMistypedOptionIsRefusedRatherThanIgnored() {
// Exercise
(int exitCode, string error) = Run("config", "show", "--verbosee");

// Verify
Check.That(exitCode).IsNotEqualTo(0);
Check.That(exitCode).IsEqualTo(64);
Check.That(error).Contains("--verbosee");
}

// Refusing undeclared arguments must not be bought by breaking the parser's own diagnosis. Spectre's strict
// parsing mode would have said the same thing about an unknown option, at the cost of this case: in 0.55 it makes
// a valueless option swallow the internal "__default_command" token, so the tool looks for a file by that name.
[Fact(DisplayName = "An option given without its value is still the parser's usage error (64).")]
public void AnOptionWithoutItsValueIsAUsageError() {
// Exercise
(int exitCode, string error) = Run("generate", "--solution");
Expand Down
48 changes: 47 additions & 1 deletion FirstClassErrors.Cli/CliApplication.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#region Usings declarations

using System.Diagnostics.CodeAnalysis;

using Spectre.Console.Cli;

#endregion
Expand Down Expand Up @@ -39,6 +41,17 @@ private static void Configure(IConfigurator config) {
config.SetApplicationName("fce");
config.SetExceptionHandler(HandleUncaught);

// An argument the command tree does not declare is refused rather than collected. The parser gathers such a
// token into the remaining arguments, which this tool never reads: a mistyped flag was accepted, ignored, and
// reported as a success, so a pipeline asking for something the tool does not do was told it had it.
//
// The parser's own strict mode (UseStrictParsing) would say the same thing, and cannot be used: in
// Spectre.Console.Cli 0.55 it makes an option declared without a value swallow the internal
// "__default_command" token as that value, so `fce generate --solution` looks for a file by that name instead
// of reporting a usage error. Refusing the leftovers ourselves keeps the diagnosis and leaves the parser's
// handling of a missing value intact.
config.SetInterceptor(new RefuseUndeclaredArguments());

config.AddCommand<GenerateCommand>("generate")
.WithDescription("Generate error documentation from a solution or from assemblies.");

Expand Down Expand Up @@ -73,7 +86,8 @@ private static void Configure(IConfigurator config) {
/// </remarks>
private static int HandleUncaught(Exception exception, ITypeResolver? resolver) {
_ = resolver;
bool usage = exception is CommandParseException or CommandTemplateException or CommandConfigurationException;
bool usage = exception is CommandParseException or CommandTemplateException or CommandConfigurationException
or UndeclaredArgumentException;

Console.Error.WriteLine($"error: {exception.Message}");
if (usage) { Console.Error.WriteLine("Run 'fce --help' to see the available commands."); }
Expand All @@ -83,4 +97,36 @@ private static int HandleUncaught(Exception exception, ITypeResolver? resolver)

#endregion

/// <summary>
/// Refuses a command line carrying an argument no command declares, before the command runs.
/// </summary>
private sealed class RefuseUndeclaredArguments : ICommandInterceptor {

/// <inheritdoc />
public void Intercept(CommandContext context, CommandSettings settings) {
if (context is null) { throw new ArgumentNullException(nameof(context)); }

IReadOnlyList<string> undeclared = [.. context.Remaining.Raw, .. context.Remaining.Parsed.Select(pair => pair.Key)];
if (undeclared.Count == 0) { return; }

throw new UndeclaredArgumentException(undeclared[0]);
}

}

}

/// <summary>
/// Raised when the command line carries an argument the command tree does not declare. It is the tool's own
/// usage refusal rather than the parser's, so it names the offending argument and nothing else.
/// </summary>
[SuppressMessage("Minor Code Smell", "S3871:Exception types should be \"public\"",
Justification =
"The rule exists so a caller outside the assembly can catch the exception. This assembly is an " +
"executable: nothing references it, and the only code that catches this is the exit-code handler " +
"a few lines above. Making it public would advertise a type to callers that cannot exist.")]
internal sealed class UndeclaredArgumentException : Exception {

internal UndeclaredArgumentException(string argument) : base($"Unknown argument '{argument}'.") { }

}
4 changes: 2 additions & 2 deletions doc/handwritten/for-users/CatalogVersioningReference.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Exit codes:
| --- | --- |
| `0` | Baseline created, already current, or replaced successfully. |
| `1` | Execution error or baseline schema newer than the tool. |
| `64` | The command line was refused: unknown command, or an option given without its value. |
| `64` | The command line was refused: unknown command, unknown option, or an option given without its value. |
| `130` | Execution interrupted. |

## `fce catalog diff`
Expand All @@ -61,7 +61,7 @@ Exit codes:
| `0` | No change reaches the threshold selected by `--fail-on`. |
| `2` | At least one change reaches that threshold. |
| `1` | Execution error: missing baseline, failed extraction, invalid file, and so on. |
| `64` | The command line was refused: unknown command, or an option given without its value. |
| `64` | The command line was refused: unknown command, unknown option, or an option given without its value. |
| `130` | Execution interrupted. |

### Failure policy: `--fail-on`
Expand Down
4 changes: 2 additions & 2 deletions doc/handwritten/for-users/CatalogVersioningReference.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Codes de sortie :
| --- | --- |
| `0` | Baseline créée, déjà à jour ou remplacée avec succès. |
| `1` | Erreur d'exécution ou schéma de baseline plus récent que l'outil. |
| `64` | Ligne de commande refusée : commande inconnue, ou option fournie sans sa valeur. |
| `64` | Ligne de commande refusée : commande inconnue, option inconnue, ou option fournie sans sa valeur. |
| `130` | Exécution interrompue. |

## `fce catalog diff`
Expand All @@ -61,7 +61,7 @@ Codes de sortie :
| `0` | Aucun changement n'atteint le seuil défini par `--fail-on`. |
| `2` | Au moins un changement atteint ce seuil. |
| `1` | Erreur d'exécution : baseline manquante, extraction impossible, fichier invalide, etc. |
| `64` | Ligne de commande refusée : commande inconnue, ou option fournie sans sa valeur. |
| `64` | Ligne de commande refusée : commande inconnue, option inconnue, ou option fournie sans sa valeur. |
| `130` | Exécution interrompue. |

### Politique d'échec : `--fail-on`
Expand Down
Loading