diff --git a/CliFx.Tests/Services/CommandSchemaResolverTests.Commands.cs b/CliFx.Tests/Services/CommandSchemaResolverTests.Commands.cs index 1277706..bda2662 100644 --- a/CliFx.Tests/Services/CommandSchemaResolverTests.Commands.cs +++ b/CliFx.Tests/Services/CommandSchemaResolverTests.Commands.cs @@ -13,6 +13,9 @@ namespace CliFx.Tests.Services [CommandOption("option-a", 'a')] public int OptionA { get; set; } + [CommandOption('A')] + public int AlmostOptionA { get; set; } + [CommandOption("option-b", IsRequired = true)] public string OptionB { get; set; } diff --git a/CliFx.Tests/Services/CommandSchemaResolverTests.cs b/CliFx.Tests/Services/CommandSchemaResolverTests.cs index 63b6bd4..fb3106d 100644 --- a/CliFx.Tests/Services/CommandSchemaResolverTests.cs +++ b/CliFx.Tests/Services/CommandSchemaResolverTests.cs @@ -22,6 +22,8 @@ namespace CliFx.Tests.Services { new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.OptionA)), "option-a", 'a', false, null), + new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.AlmostOptionA)), + null, 'A', false, null), new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.OptionB)), "option-b", null, true, null) }), @@ -93,7 +95,7 @@ namespace CliFx.Tests.Services // Act & Assert resolver.Invoking(r => r.GetCommandSchemas(commandTypes)) - .Should().ThrowExactly(); + .Should().ThrowExactly(); } } } \ No newline at end of file diff --git a/CliFx/Exceptions/InvalidCommandSchemaException.cs b/CliFx/Exceptions/InvalidCommandSchemaException.cs deleted file mode 100644 index aa98016..0000000 --- a/CliFx/Exceptions/InvalidCommandSchemaException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -namespace CliFx.Exceptions -{ - /// - /// Thrown when a command schema fails validation. - /// - public class InvalidCommandSchemaException : CliFxException - { - /// - /// Initializes an instance of . - /// - public InvalidCommandSchemaException(string message) - : base(message) - { - } - - /// - /// Initializes an instance of . - /// - public InvalidCommandSchemaException(string message, Exception innerException) - : base(message, innerException) - { - } - } -} \ No newline at end of file diff --git a/CliFx/Exceptions/SchemaValidationException.cs b/CliFx/Exceptions/SchemaValidationException.cs new file mode 100644 index 0000000..3209163 --- /dev/null +++ b/CliFx/Exceptions/SchemaValidationException.cs @@ -0,0 +1,26 @@ +using System; + +namespace CliFx.Exceptions +{ + /// + /// Thrown when a command schema fails validation. + /// + public class SchemaValidationException : CliFxException + { + /// + /// Initializes an instance of . + /// + public SchemaValidationException(string message) + : base(message) + { + } + + /// + /// Initializes an instance of . + /// + public SchemaValidationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/CliFx/Internal/Extensions.cs b/CliFx/Internal/Extensions.cs index 7233642..fdedbdd 100644 --- a/CliFx/Internal/Extensions.cs +++ b/CliFx/Internal/Extensions.cs @@ -26,11 +26,6 @@ namespace CliFx.Internal public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => builder.Length > 0 ? builder.Append(value) : builder; - public static TValue GetValueOrDefault(this IReadOnlyDictionary dic, TKey key) => - dic.TryGetValue(key, out var result) ? result : default; - - public static IEnumerable ExceptNull(this IEnumerable source) where T : class => source.Where(i => i != null); - public static IEnumerable Concat(this IEnumerable source, T value) { foreach (var i in source) @@ -51,7 +46,7 @@ namespace CliFx.Internal return type.GetInterfaces() .Select(GetEnumerableUnderlyingType) - .ExceptNull() + .Where(t => t != default) .OrderByDescending(t => t != typeof(object)) // prioritize more specific types .FirstOrDefault(); } diff --git a/CliFx/Services/CommandSchemaResolver.cs b/CliFx/Services/CommandSchemaResolver.cs index 6d188b9..fbb1b34 100644 --- a/CliFx/Services/CommandSchemaResolver.cs +++ b/CliFx/Services/CommandSchemaResolver.cs @@ -14,84 +14,54 @@ namespace CliFx.Services /// public class CommandSchemaResolver : ICommandSchemaResolver { - private CommandOptionSchema GetCommandOptionSchema(PropertyInfo optionProperty) + private IReadOnlyList GetCommandOptionSchemas(Type commandType) { - var attribute = optionProperty.GetCustomAttribute(); + var result = new List(); - // If an attribute is not set, then it's not an option so we just skip it - if (attribute == null) - return null; - - return new CommandOptionSchema(optionProperty, - attribute.Name, - attribute.ShortName, - attribute.IsRequired, - attribute.Description); - } - - private CommandSchema GetCommandSchema(Type commandType) - { - var attribute = commandType.GetCustomAttribute(); - - // Make sure attribute is set - if (attribute == null) + foreach (var property in commandType.GetProperties()) { - throw new InvalidCommandSchemaException($"Command type [{commandType}] must be annotated with [{typeof(CommandAttribute)}]."); + var attribute = property.GetCustomAttribute(); + + // If an attribute is not set, then it's not an option so we just skip it + if (attribute == null) + continue; + + // Build option schema + var optionSchema = new CommandOptionSchema(property, + attribute.Name, + attribute.ShortName, + attribute.IsRequired, + attribute.Description); + + // Make sure there are no other options with the same name + var existingOptionWithSameName = result + .Where(o => !o.Name.IsNullOrWhiteSpace()) + .FirstOrDefault(o => string.Equals(o.Name, optionSchema.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingOptionWithSameName != null) + { + throw new SchemaValidationException( + $"Command type [{commandType}] has options defined with the same name: " + + $"[{existingOptionWithSameName.Property}] and [{optionSchema.Property}]."); + } + + // Make sure there are no other options with the same short name + var existingOptionWithSameShortName = result + .Where(o => o.ShortName != null) + .FirstOrDefault(o => o.ShortName == optionSchema.ShortName); + + if (existingOptionWithSameShortName != null) + { + throw new SchemaValidationException( + $"Command type [{commandType}] has options defined with the same short name: " + + $"[{existingOptionWithSameShortName.Property}] and [{optionSchema.Property}]."); + } + + // Add schema to list + result.Add(optionSchema); } - // Get option schemas - var options = commandType.GetProperties().Select(GetCommandOptionSchema).ExceptNull().ToArray(); - - // Create command schema - var commandSchema = new CommandSchema(commandType, - attribute.Name, - attribute.Description, - options); - - // Make sure command type implements ICommand. - // (we check using command schema to provide a more useful error message) - if (!commandSchema.Type.Implements(typeof(ICommand))) - { - throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace() - ? $"Command [{commandSchema.Name}] doesn't implement ICommand." - : "Default command doesn't implement ICommand."); - } - - // Make sure there are no options with duplicate names - var nonUniqueOptionName = options - .Where(o => !o.Name.IsNullOrWhiteSpace()) - .Select(o => o.Name) - .GroupBy(i => i, StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() >= 2) - .SelectMany(g => g) - .Distinct(StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - - if (nonUniqueOptionName != null) - { - throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace() - ? $"There are multiple options defined with name [{nonUniqueOptionName}] on command [{commandSchema.Name}]." - : $"There are multiple options defined with name [{nonUniqueOptionName}] on default command."); - } - - // Make sure there are no options with duplicate short names - var nonUniqueOptionShortName = commandSchema.Options - .Where(o => o.ShortName != null) - .Select(o => o.ShortName) - .GroupBy(i => i) - .Where(g => g.Count() >= 2) - .SelectMany(g => g) - .Distinct() - .FirstOrDefault(); - - if (nonUniqueOptionShortName != null) - { - throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace() - ? $"There are multiple options defined with short name [{nonUniqueOptionShortName}] on command [{commandSchema.Name}]." - : $"There are multiple options defined with short name [{nonUniqueOptionShortName}] on default command."); - } - - return commandSchema; + return result; } /// @@ -99,32 +69,57 @@ namespace CliFx.Services { commandTypes.GuardNotNull(nameof(commandTypes)); - // Throw if there are no command types specified + // Make sure there's at least one command defined if (!commandTypes.Any()) { - throw new InvalidCommandSchemaException("There are no commands defined."); + throw new SchemaValidationException("There are no commands defined."); } - // Get command schemas - var commandSchemas = commandTypes.Select(GetCommandSchema).ToArray(); + var result = new List(); - // Make sure there are no commands with duplicate names - var nonUniqueCommandName = commandSchemas - .Select(c => c.Name) - .GroupBy(i => i, StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() >= 2) - .SelectMany(g => g) - .Distinct(StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - - if (nonUniqueCommandName != null) + foreach (var commandType in commandTypes) { - throw new InvalidCommandSchemaException(!nonUniqueCommandName.IsNullOrWhiteSpace() - ? $"There are multiple commands defined with name [{nonUniqueCommandName}]." - : "There are multiple default commands defined."); + // Make sure command type implements ICommand. + if (!commandType.Implements(typeof(ICommand))) + { + throw new SchemaValidationException( + $"Command type [{commandType}] must implement {typeof(ICommand)}."); + } + + // Get attribute + var attribute = commandType.GetCustomAttribute(); + + // Make sure attribute is set + if (attribute == null) + { + throw new SchemaValidationException( + $"Command type [{commandType}] must be annotated with [{typeof(CommandAttribute)}]."); + } + + // Get option schemas + var optionSchemas = GetCommandOptionSchemas(commandType); + + // Build command schema + var commandSchema = new CommandSchema(commandType, + attribute.Name, + attribute.Description, + optionSchemas); + + // Make sure there are no other commands with the same name + var existingCommandWithSameName = result + .FirstOrDefault(c => string.Equals(c.Name, commandSchema.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingCommandWithSameName != null) + { + throw new SchemaValidationException( + $"Command type [{existingCommandWithSameName.Type}] has the same name as another command type [{commandType}]."); + } + + // Add schema to list + result.Add(commandSchema); } - return commandSchemas; + return result; } } } \ No newline at end of file