diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/ConsoleUI.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/ConsoleUI.java index 05d948c..aaacf63 100644 --- a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/ConsoleUI.java +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/ConsoleUI.java @@ -34,6 +34,8 @@ public class ConsoleUI private static final String ANSI_RESET = "\u001B[0m"; private static final String ANSI_DIM = "\u001B[2m"; private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_RED = "\u001B[31m"; + private static final String ANSI_YELLOW = "\u001B[33m"; private final Console console; private final Scanner scanner; @@ -132,6 +134,44 @@ public class ConsoleUI + /*************************************************************************** + * Print a validation error message inline. + * + * @param text the error message + * @since 0.2.0 + ***************************************************************************/ + public void validationError(String text) + { + System.out.println(" " + ANSI_RED + "✗ " + text + ANSI_RESET); + } + + + + /*************************************************************************** + * Print a validation success indicator. + * + * @since 0.2.0 + ***************************************************************************/ + public void validationSuccess() + { + System.out.println(" " + ANSI_GREEN + "✓" + ANSI_RESET); + } + + + + /*************************************************************************** + * Print a warning message. + * + * @param text the warning message + * @since 0.2.0 + ***************************************************************************/ + public void warning(String text) + { + System.out.println(ANSI_YELLOW + "warning: " + text + ANSI_RESET); + } + + + /*************************************************************************** * Print a blank line. * diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptRunner.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptRunner.java index 1c9a21a..5291e17 100644 --- a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptRunner.java +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptRunner.java @@ -23,12 +23,14 @@ import java.util.Map; * Runs interactive prompts to collect template variables. * * Uses ConsoleUI for all interactive input. Supports text input and selections. + * Validates input against prompt rules and re-prompts on failure. * * @since 0.1.0 *******************************************************************************/ public class PromptRunner { private final ConsoleUI ui; + private final PromptValidator validator; @@ -40,6 +42,7 @@ public class PromptRunner public PromptRunner() { this.ui = new ConsoleUI(); + this.validator = new PromptValidator(); } @@ -53,6 +56,7 @@ public class PromptRunner public PromptRunner(ConsoleUI ui) { this.ui = ui; + this.validator = new PromptValidator(); } @@ -77,12 +81,26 @@ public class PromptRunner for(TemplateManifest.Prompt prompt : prompts) { - // Skip if already provided via CLI + // Check if provided via CLI if(overrides.containsKey(prompt.name())) { String value = overrides.get(prompt.name()); - ui.showValue(prompt.message() != null ? prompt.message() : prompt.name(), value); - results.put(prompt.name(), value); + String label = prompt.message() != null ? prompt.message() : prompt.name(); + PromptValidator.ValidationResult result = validator.validate(prompt, value); + + if(result.valid()) + { + ui.showValue(label, value); + results.put(prompt.name(), value); + } + else + { + ui.showValue(label, value); + ui.validationError(result.errorMessage()); + ui.warning("Invalid --var value, prompting interactively"); + String corrected = promptForValue(prompt); + results.put(prompt.name(), corrected); + } continue; } @@ -96,10 +114,12 @@ public class PromptRunner /*************************************************************************** - * Prompt for a single value. + * Prompt for a single value with validation. + * + * Re-prompts on validation failure until valid input is provided. * * @param prompt prompt definition - * @return user input value + * @return validated user input value * @since 0.1.0 ***************************************************************************/ private String promptForValue(TemplateManifest.Prompt prompt) @@ -111,9 +131,24 @@ public class PromptRunner { return promptSelect(message, prompt.choices(), prompt.defaultValue()); } - else + + boolean hasValidation = prompt.isRequired() || prompt.validation() != null; + + while(true) { - return ui.promptText(message, prompt.defaultValue()); + String value = ui.promptText(message, prompt.defaultValue()); + PromptValidator.ValidationResult result = validator.validate(prompt, value); + + if(result.valid()) + { + if(hasValidation) + { + ui.validationSuccess(); + } + return value; + } + + ui.validationError(result.errorMessage()); } } diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptValidation.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptValidation.java new file mode 100644 index 0000000..4aa31fa --- /dev/null +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptValidation.java @@ -0,0 +1,45 @@ +/* + * All Rights Reserved + * + * Copyright (c) 2025. QRunIO. Contact: contact@qrun.io + * + * THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL. + * UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED. + * + * The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them + * for any purpose other than the purpose for which they were provided to you. + */ + +package io.qrun.qctl.qqq.template; + + +import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + + +/******************************************************************************* + * Validation rules for template prompts. + * + * Supports string validation (pattern, length) and integer validation (range). + * + * @param pattern regex pattern for string validation + * @param minLength minimum string length + * @param maxLength maximum string length + * @param min minimum value for integers + * @param max maximum value for integers + * @param values allowed values (enum validation) + * @param message custom error message + * @since 0.2.0 + *******************************************************************************/ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PromptValidation( + String pattern, + Integer minLength, + Integer maxLength, + Integer min, + Integer max, + List values, + String message +) +{ +} diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptValidator.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptValidator.java new file mode 100644 index 0000000..99a245d --- /dev/null +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/PromptValidator.java @@ -0,0 +1,235 @@ +/* + * All Rights Reserved + * + * Copyright (c) 2025. QRunIO. Contact: contact@qrun.io + * + * THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL. + * UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED. + * + * The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them + * for any purpose other than the purpose for which they were provided to you. + */ + +package io.qrun.qctl.qqq.template; + + +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + + +/******************************************************************************* + * Validates prompt values against validation rules. + * + * Supports pattern matching, length constraints, numeric ranges, and enum values. + * + * @since 0.2.0 + *******************************************************************************/ +public class PromptValidator +{ + /*************************************************************************** + * Validation result containing success status and error message. + * + * @param valid true if validation passed + * @param errorMessage error message if validation failed + * @since 0.2.0 + ***************************************************************************/ + public record ValidationResult(boolean valid, String errorMessage) + { + /************************************************************************* + * Create a successful validation result. + * + * @return success result + * @since 0.2.0 + *************************************************************************/ + public static ValidationResult success() + { + return new ValidationResult(true, null); + } + + + + /************************************************************************* + * Create a failed validation result. + * + * @param message the error message + * @return failure result + * @since 0.2.0 + *************************************************************************/ + public static ValidationResult failure(String message) + { + return new ValidationResult(false, message); + } + } + + + + /*************************************************************************** + * Validate a value against prompt rules. + * + * @param prompt the prompt definition with validation rules + * @param value the value to validate + * @return validation result + * @since 0.2.0 + ***************************************************************************/ + public ValidationResult validate(TemplateManifest.Prompt prompt, String value) + { + // Check required + if(prompt.isRequired() && (value == null || value.isBlank())) + { + return ValidationResult.failure("This field is required"); + } + + // If empty and not required, skip further validation + if(value == null || value.isBlank()) + { + return ValidationResult.success(); + } + + // Get validation rules + PromptValidation validation = prompt.validation(); + if(validation == null) + { + return ValidationResult.success(); + } + + // Determine type + String type = prompt.type() != null ? prompt.type().toLowerCase() : "text"; + + // Type-specific validation + return switch(type) + { + case "integer", "number" -> validateInteger(validation, value); + default -> validateString(validation, value); + }; + } + + + + /*************************************************************************** + * Validate a string value. + * + * @param validation validation rules + * @param value the value to validate + * @return validation result + * @since 0.2.0 + ***************************************************************************/ + private ValidationResult validateString(PromptValidation validation, String value) + { + // Pattern validation + if(validation.pattern() != null) + { + Optional patternResult = validatePattern(validation.pattern(), value, validation.message()); + if(patternResult.isPresent()) + { + return patternResult.get(); + } + } + + // Length validation + if(validation.minLength() != null && value.length() < validation.minLength()) + { + String message = validation.message() != null + ? validation.message() + : "Must be at least " + validation.minLength() + " characters"; + return ValidationResult.failure(message); + } + + if(validation.maxLength() != null && value.length() > validation.maxLength()) + { + String message = validation.message() != null + ? validation.message() + : "Must be at most " + validation.maxLength() + " characters"; + return ValidationResult.failure(message); + } + + // Enum validation + if(validation.values() != null && !validation.values().isEmpty()) + { + if(!validation.values().contains(value)) + { + String message = validation.message() != null + ? validation.message() + : "Must be one of: " + String.join(", ", validation.values()); + return ValidationResult.failure(message); + } + } + + return ValidationResult.success(); + } + + + + /*************************************************************************** + * Validate an integer value. + * + * @param validation validation rules + * @param value the value to validate + * @return validation result + * @since 0.2.0 + ***************************************************************************/ + private ValidationResult validateInteger(PromptValidation validation, String value) + { + // Parse as integer + int intValue; + try + { + intValue = Integer.parseInt(value.trim()); + } + catch(NumberFormatException e) + { + return ValidationResult.failure("Must be a valid integer"); + } + + // Min validation + if(validation.min() != null && intValue < validation.min()) + { + String message = validation.message() != null + ? validation.message() + : "Must be at least " + validation.min(); + return ValidationResult.failure(message); + } + + // Max validation + if(validation.max() != null && intValue > validation.max()) + { + String message = validation.message() != null + ? validation.message() + : "Must be at most " + validation.max(); + return ValidationResult.failure(message); + } + + return ValidationResult.success(); + } + + + + /*************************************************************************** + * Validate against a regex pattern. + * + * @param pattern the regex pattern + * @param value the value to validate + * @param customMessage custom error message + * @return validation result if failed, empty if passed + * @since 0.2.0 + ***************************************************************************/ + private Optional validatePattern(String pattern, String value, String customMessage) + { + try + { + Pattern regex = Pattern.compile(pattern); + if(!regex.matcher(value).matches()) + { + String message = customMessage != null + ? customMessage + : "Must match pattern: " + pattern; + return Optional.of(ValidationResult.failure(message)); + } + } + catch(PatternSyntaxException e) + { + return Optional.of(ValidationResult.failure("Invalid validation pattern: " + pattern)); + } + return Optional.empty(); + } +} diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateManifest.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateManifest.java index 2d0a4b7..8a35c42 100644 --- a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateManifest.java +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateManifest.java @@ -96,10 +96,20 @@ public record TemplateManifest( String type, String defaultValue, List choices, - String validation, + PromptValidation validation, Boolean required ) { + /************************************************************************* + * Check if this prompt is required. + * + * @return true if required (defaults to true if not specified) + * @since 0.2.0 + *************************************************************************/ + public boolean isRequired() + { + return required == null || required; + } } diff --git a/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/PromptValidatorTest.java b/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/PromptValidatorTest.java new file mode 100644 index 0000000..233f160 --- /dev/null +++ b/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/PromptValidatorTest.java @@ -0,0 +1,415 @@ +/* + * All Rights Reserved + * + * Copyright (c) 2025. QRunIO. Contact: contact@qrun.io + * + * THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL. + * UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED. + * + * The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them + * for any purpose other than the purpose for which they were provided to you. + */ + +package io.qrun.qctl.qqq.template; + + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + * Unit tests for PromptValidator. + * + * @since 0.2.0 + *******************************************************************************/ +class PromptValidatorTest +{ + private PromptValidator validator; + + + + @BeforeEach + void setUp() + { + validator = new PromptValidator(); + } + + + + /*************************************************************************** + * Test required field with blank value fails. + ***************************************************************************/ + @Test + void testRequiredFieldBlankFails() + { + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, null, true); + + PromptValidator.ValidationResult result = validator.validate(prompt, ""); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).isEqualTo("This field is required"); + } + + + + /*************************************************************************** + * Test required field with null value fails. + ***************************************************************************/ + @Test + void testRequiredFieldNullFails() + { + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, null, true); + + PromptValidator.ValidationResult result = validator.validate(prompt, null); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).isEqualTo("This field is required"); + } + + + + /*************************************************************************** + * Test required field with valid value passes. + ***************************************************************************/ + @Test + void testRequiredFieldWithValuePasses() + { + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, null, true); + + PromptValidator.ValidationResult result = validator.validate(prompt, "John"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test optional field with blank value passes. + ***************************************************************************/ + @Test + void testOptionalFieldBlankPasses() + { + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, null, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, ""); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test pattern validation passes for valid input. + ***************************************************************************/ + @Test + void testPatternValidationPasses() + { + PromptValidation validation = new PromptValidation( + "^[a-z][a-z0-9]*$", null, null, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "myapp123"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test pattern validation fails for invalid input. + ***************************************************************************/ + @Test + void testPatternValidationFails() + { + PromptValidation validation = new PromptValidation( + "^[a-z][a-z0-9]*$", null, null, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "123invalid"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("pattern"); + } + + + + /*************************************************************************** + * Test custom error message is used on validation failure. + ***************************************************************************/ + @Test + void testCustomErrorMessage() + { + PromptValidation validation = new PromptValidation( + "^[a-z]+$", null, null, null, null, null, "Must contain only lowercase letters"); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "ABC123"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).isEqualTo("Must contain only lowercase letters"); + } + + + + /*************************************************************************** + * Test minLength validation passes. + ***************************************************************************/ + @Test + void testMinLengthPasses() + { + PromptValidation validation = new PromptValidation( + null, 3, null, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "abc"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test minLength validation fails. + ***************************************************************************/ + @Test + void testMinLengthFails() + { + PromptValidation validation = new PromptValidation( + null, 5, null, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "ab"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("at least 5"); + } + + + + /*************************************************************************** + * Test maxLength validation passes. + ***************************************************************************/ + @Test + void testMaxLengthPasses() + { + PromptValidation validation = new PromptValidation( + null, null, 10, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "short"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test maxLength validation fails. + ***************************************************************************/ + @Test + void testMaxLengthFails() + { + PromptValidation validation = new PromptValidation( + null, null, 5, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "toolongvalue"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("at most 5"); + } + + + + /*************************************************************************** + * Test enum values validation passes. + ***************************************************************************/ + @Test + void testEnumValuesPasses() + { + PromptValidation validation = new PromptValidation( + null, null, null, null, null, List.of("dev", "staging", "prod"), null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "env", "Environment", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "dev"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test enum values validation fails. + ***************************************************************************/ + @Test + void testEnumValuesFails() + { + PromptValidation validation = new PromptValidation( + null, null, null, null, null, List.of("dev", "staging", "prod"), null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "env", "Environment", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "invalid"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("one of"); + } + + + + /*************************************************************************** + * Test integer validation passes. + ***************************************************************************/ + @Test + void testIntegerValidationPasses() + { + PromptValidation validation = new PromptValidation( + null, null, null, 1, 100, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "count", "Count", "integer", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "50"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test integer min validation fails. + ***************************************************************************/ + @Test + void testIntegerMinFails() + { + PromptValidation validation = new PromptValidation( + null, null, null, 10, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "count", "Count", "integer", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "5"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("at least 10"); + } + + + + /*************************************************************************** + * Test integer max validation fails. + ***************************************************************************/ + @Test + void testIntegerMaxFails() + { + PromptValidation validation = new PromptValidation( + null, null, null, null, 100, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "count", "Count", "integer", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "200"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("at most 100"); + } + + + + /*************************************************************************** + * Test non-integer input fails for integer type. + ***************************************************************************/ + @Test + void testNonIntegerFails() + { + PromptValidation validation = new PromptValidation( + null, null, null, 1, 100, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "count", "Count", "integer", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "abc"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("valid integer"); + } + + + + /*************************************************************************** + * Test invalid regex pattern in validation. + ***************************************************************************/ + @Test + void testInvalidPatternReportsError() + { + PromptValidation validation = new PromptValidation( + "[invalid", null, null, null, null, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "test"); + + assertThat(result.valid()).isFalse(); + assertThat(result.errorMessage()).contains("Invalid validation pattern"); + } + + + + /*************************************************************************** + * Test no validation rules passes. + ***************************************************************************/ + @Test + void testNoValidationRulesPasses() + { + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "name", "Name", "text", null, null, null, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "anything"); + + assertThat(result.valid()).isTrue(); + } + + + + /*************************************************************************** + * Test number type works same as integer type. + ***************************************************************************/ + @Test + void testNumberTypeValidation() + { + PromptValidation validation = new PromptValidation( + null, null, null, 1, 10, null, null); + + TemplateManifest.Prompt prompt = new TemplateManifest.Prompt( + "count", "Count", "number", null, null, validation, false); + + PromptValidator.ValidationResult result = validator.validate(prompt, "5"); + + assertThat(result.valid()).isTrue(); + } +}