feat(qqq): add prompt validation with retry loop (#24)

- Add PromptValidation record for validation rules (pattern, length, range, enum)
- Add PromptValidator with support for required, pattern, length, range, and enum validation
- Update PromptRunner to validate and re-prompt on failure
- Validate --var CLI overrides, fallback to interactive prompt if invalid
- Add ConsoleUI methods for validation feedback (error, success, warning)
- Add 20 unit tests for PromptValidator

Closes #24
This commit is contained in:
james.maes committed 2026-01-03 21:00:41 -06:00
1 parent e14565a277
commit a2e85f7f93
6 files changed
+788 -8

No files matched your search

@@ -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.
*
@@ -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());
}
}
@@ -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<String> values,
String message
)
{
}
@@ -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<ValidationResult> 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<ValidationResult> 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();
}
}
@@ -96,10 +96,20 @@ public record TemplateManifest(
String type,
String defaultValue,
List<String> 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;
}
}
@@ -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();
}
}