feat(qqq): add structured error handling with suggestions

- Add SuggestionEngine using Levenshtein distance for fuzzy matching
- Add TemplateError with categories mapped to exit codes
- Add ErrorFormatter for consistent ANSI-colored output
- Integrate did-you-mean suggestions in template commands
- Add --verbose flag for stack trace output
This commit is contained in:
james.maes committed 2026-01-03 21:10:32 -06:00
1 parent daea5e66b3
commit 30e124dde5
8 files changed
+1157 -6

No files matched your search

@@ -14,12 +14,16 @@ package io.qrun.qctl.qqq;
import java.io.IOException;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import io.qrun.qctl.qqq.error.ErrorFormatter;
import io.qrun.qctl.qqq.error.SuggestionEngine;
import io.qrun.qctl.qqq.error.TemplateError;
import io.qrun.qctl.qqq.registry.TemplateInfo;
import io.qrun.qctl.qqq.registry.TemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
@@ -79,6 +83,9 @@ public class InitCommand implements Runnable
@Option(names = "--no-prompt", description = "Disable interactive prompts (use defaults)")
boolean noPrompt;
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
boolean verbose;
/***************************************************************************
@@ -197,17 +204,34 @@ public class InitCommand implements Runnable
}
catch(TemplateRenderException e)
{
ui.error("Template error: " + e.getMessage());
System.exit(ExitCodes.VALIDATION);
ErrorFormatter formatter = new ErrorFormatter(verbose);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
e.getMessage(),
"Check template syntax and ensure all variables are defined"
);
System.err.print(formatter.format(error));
System.exit(error.getExitCode());
}
catch(IOException e)
{
ui.error(e.getMessage());
ErrorFormatter formatter = new ErrorFormatter(verbose);
String hint = e instanceof UnknownHostException
? "Check your network connection and try again"
: null;
System.err.print(formatter.format(e, hint));
System.exit(ExitCodes.GENERIC);
}
catch(TemplateError e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
System.err.print(formatter.format(e));
System.exit(e.getExitCode());
}
catch(Exception e)
{
ui.error(e.getMessage());
ErrorFormatter formatter = new ErrorFormatter(verbose);
System.err.print(formatter.format(e, null));
System.exit(ExitCodes.GENERIC);
}
}
@@ -236,8 +260,23 @@ public class InitCommand implements Runnable
return selected;
}
// Template not found - prompt for selection
/////////////////////////////////////////////////////////////////////////
// Template not found - show error with suggestions //
/////////////////////////////////////////////////////////////////////////
List<String> templateIds = templates.stream().map(TemplateInfo::id).toList();
SuggestionEngine suggestionEngine = new SuggestionEngine();
List<String> suggestions = suggestionEngine.findSimilar(templateName, templateIds);
ui.error("Template '" + templateName + "' not found.");
if(!suggestions.isEmpty())
{
ui.println();
ui.println("Did you mean?");
for(String suggestion : suggestions)
{
ui.println(" " + suggestion);
}
}
ui.println();
// Find default index
@@ -14,7 +14,9 @@ package io.qrun.qctl.qqq;
import java.io.IOException;
import java.net.UnknownHostException;
import java.util.List;
import io.qrun.qctl.qqq.error.ErrorFormatter;
import io.qrun.qctl.qqq.registry.TemplateInfo;
import io.qrun.qctl.qqq.registry.TemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
@@ -40,6 +42,9 @@ public class ListCommand implements Runnable
@Option(names = "--refresh", description = "Bypass cache and fetch fresh data")
boolean refresh;
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
boolean verbose;
/***************************************************************************
@@ -93,7 +98,11 @@ public class ListCommand implements Runnable
}
catch(IOException e)
{
System.err.println("error: Failed to fetch templates: " + e.getMessage());
ErrorFormatter formatter = new ErrorFormatter(verbose);
String hint = e instanceof UnknownHostException
? "Check your network connection and try again"
: "Run with --refresh to bypass cache";
System.err.print(formatter.format(e, hint));
System.exit(ExitCodes.NETWORK);
}
}
@@ -0,0 +1,143 @@
/*
* 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.error;
import java.io.PrintWriter;
import java.io.StringWriter;
/*******************************************************************************
* Formats errors with consistent styling and recovery suggestions.
*
* @since 0.2.0
*******************************************************************************/
public class ErrorFormatter
{
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_RESET = "\u001B[0m";
private final boolean verbose;
/***************************************************************************
* Create an error formatter.
*
* @param verbose whether to include stack traces
* @since 0.2.0
***************************************************************************/
public ErrorFormatter(boolean verbose)
{
this.verbose = verbose;
}
/***************************************************************************
* Format a template error for display.
*
* @param error the template error
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String format(TemplateError error)
{
StringBuilder sb = new StringBuilder();
// Error message
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
sb.append(error.getMessage()).append("\n");
// Suggestions (did you mean?)
if(error.hasSuggestions())
{
sb.append("\n");
sb.append(ANSI_YELLOW).append("Did you mean?").append(ANSI_RESET).append("\n");
for(String suggestion : error.getSuggestions())
{
sb.append(" ").append(ANSI_CYAN).append(suggestion).append(ANSI_RESET).append("\n");
}
}
// Hint
if(error.getHint() != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append(error.getHint()).append(ANSI_RESET).append("\n");
}
// Stack trace (verbose mode)
if(verbose && error.getCause() != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
sb.append(getStackTrace(error.getCause()));
}
return sb.toString();
}
/***************************************************************************
* Format a generic exception.
*
* @param error the exception
* @param hint optional hint for recovery
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String format(Exception error, String hint)
{
StringBuilder sb = new StringBuilder();
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
sb.append(error.getMessage()).append("\n");
if(hint != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append(hint).append(ANSI_RESET).append("\n");
}
if(verbose)
{
sb.append("\n");
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
sb.append(getStackTrace(error));
}
return sb.toString();
}
/***************************************************************************
* Get stack trace as string.
*
* @param throwable the exception
* @return stack trace string
* @since 0.2.0
***************************************************************************/
private String getStackTrace(Throwable throwable)
{
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
throwable.printStackTrace(pw);
return sw.toString();
}
}
@@ -0,0 +1,147 @@
/*
* 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.error;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/*******************************************************************************
* Provides fuzzy matching suggestions for typos and similar names.
*
* Uses Levenshtein distance and prefix matching to suggest alternatives.
*
* @since 0.2.0
*******************************************************************************/
public class SuggestionEngine
{
private static final int MAX_SUGGESTIONS = 3;
private static final int MAX_DISTANCE = 3;
/***************************************************************************
* Find similar strings from a list of candidates.
*
* @param input the user's input
* @param candidates list of valid options
* @return list of similar options, sorted by relevance
* @since 0.2.0
***************************************************************************/
public List<String> findSimilar(String input, List<String> candidates)
{
if(input == null || input.isEmpty() || candidates == null || candidates.isEmpty())
{
return List.of();
}
String lowerInput = input.toLowerCase();
List<ScoredCandidate> scored = new ArrayList<>();
for(String candidate : candidates)
{
int score = calculateScore(lowerInput, candidate.toLowerCase());
if(score <= MAX_DISTANCE)
{
scored.add(new ScoredCandidate(candidate, score));
}
}
scored.sort(Comparator.comparingInt(ScoredCandidate::score));
return scored.stream()
.limit(MAX_SUGGESTIONS)
.map(ScoredCandidate::value)
.toList();
}
/***************************************************************************
* Calculate similarity score (lower is better).
*
* Combines Levenshtein distance with prefix bonus.
*
* @param input user input (lowercase)
* @param candidate candidate value (lowercase)
* @return similarity score
* @since 0.2.0
***************************************************************************/
private int calculateScore(String input, String candidate)
{
int distance = levenshteinDistance(input, candidate);
// Bonus for prefix match (reduce distance)
if(candidate.startsWith(input) || input.startsWith(candidate))
{
distance = Math.max(0, distance - 1);
}
// Bonus for containing the input
if(candidate.contains(input))
{
distance = Math.max(0, distance - 1);
}
return distance;
}
/***************************************************************************
* Calculate Levenshtein distance between two strings.
*
* @param s1 first string
* @param s2 second string
* @return edit distance
* @since 0.2.0
***************************************************************************/
private int levenshteinDistance(String s1, String s2)
{
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
for(int i = 0; i <= s1.length(); i++)
{
dp[i][0] = i;
}
for(int j = 0; j <= s2.length(); j++)
{
dp[0][j] = j;
}
for(int i = 1; i <= s1.length(); i++)
{
for(int j = 1; j <= s2.length(); j++)
{
int cost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1;
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
dp[i - 1][j - 1] + cost
);
}
}
return dp[s1.length()][s2.length()];
}
/***************************************************************************
* Scored candidate for sorting.
***************************************************************************/
private record ScoredCandidate(String value, int score)
{
}
}
@@ -0,0 +1,209 @@
/*
* 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.error;
import java.util.List;
import io.qrun.qctl.shared.ExitCodes;
/*******************************************************************************
* Structured error with category, message, and recovery suggestions.
*
* @since 0.2.0
*******************************************************************************/
public class TemplateError extends RuntimeException
{
private final ErrorCategory category;
private final List<String> suggestions;
private final String hint;
/***************************************************************************
* Error categories mapping to exit codes.
*
* @since 0.2.0
***************************************************************************/
public enum ErrorCategory
{
TEMPLATE_NOT_FOUND(ExitCodes.NOT_FOUND),
VARIABLE_NOT_FOUND(ExitCodes.VALIDATION),
MANIFEST_INVALID(ExitCodes.VALIDATION),
RENDER_ERROR(ExitCodes.VALIDATION),
TRANSFORM_ERROR(ExitCodes.VALIDATION),
NETWORK_ERROR(ExitCodes.NETWORK),
VERSION_MISMATCH(ExitCodes.VALIDATION),
DIRECTORY_EXISTS(ExitCodes.CONFLICT),
IO_ERROR(ExitCodes.GENERIC);
private final int exitCode;
/*************************************************************************
* Create an error category with exit code.
*
* @param exitCode the exit code for this category
* @since 0.2.0
*************************************************************************/
ErrorCategory(int exitCode)
{
this.exitCode = exitCode;
}
/*************************************************************************
* Get the exit code for this category.
*
* @return exit code
* @since 0.2.0
*************************************************************************/
public int getExitCode()
{
return exitCode;
}
}
/***************************************************************************
* Create a template error.
*
* @param category error category
* @param message error message
* @param suggestions list of suggestions (e.g., similar names)
* @param hint actionable hint for recovery
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, List<String> suggestions, String hint)
{
super(message);
this.category = category;
this.suggestions = suggestions != null ? suggestions : List.of();
this.hint = hint;
}
/***************************************************************************
* Create a template error without suggestions.
*
* @param category error category
* @param message error message
* @param hint actionable hint
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, String hint)
{
this(category, message, null, hint);
}
/***************************************************************************
* Create a template error with only message.
*
* @param category error category
* @param message error message
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message)
{
this(category, message, null, null);
}
/***************************************************************************
* Create a template error wrapping a cause.
*
* @param category error category
* @param message error message
* @param cause the underlying cause
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, Throwable cause)
{
super(message, cause);
this.category = category;
this.suggestions = List.of();
this.hint = null;
}
/***************************************************************************
* Get the error category.
*
* @return error category
* @since 0.2.0
***************************************************************************/
public ErrorCategory getCategory()
{
return category;
}
/***************************************************************************
* Get the exit code for this error.
*
* @return exit code
* @since 0.2.0
***************************************************************************/
public int getExitCode()
{
return category.getExitCode();
}
/***************************************************************************
* Get suggestions for recovery.
*
* @return list of suggestions
* @since 0.2.0
***************************************************************************/
public List<String> getSuggestions()
{
return suggestions;
}
/***************************************************************************
* Get actionable hint.
*
* @return hint or null
* @since 0.2.0
***************************************************************************/
public String getHint()
{
return hint;
}
/***************************************************************************
* Check if error has suggestions.
*
* @return true if suggestions available
* @since 0.2.0
***************************************************************************/
public boolean hasSuggestions()
{
return !suggestions.isEmpty();
}
}
@@ -0,0 +1,201 @@
/*
* 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.error;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for ErrorFormatter output formatting.
*
* @since 0.2.0
*******************************************************************************/
class ErrorFormatterTest
{
/***************************************************************************
* Test basic error formatting.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_basicError()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Syntax error in template"
);
String output = formatter.format(error);
assertThat(output).contains("error:");
assertThat(output).contains("Syntax error in template");
}
/***************************************************************************
* Test error with suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_withSuggestions()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND,
"Template 'foo' not found",
List.of("foobar", "foo-api"),
null
);
String output = formatter.format(error);
assertThat(output).contains("Did you mean?");
assertThat(output).contains("foobar");
assertThat(output).contains("foo-api");
}
/***************************************************************************
* Test error with hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_withHint()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.NETWORK_ERROR,
"Connection refused",
"Check your network connection"
);
String output = formatter.format(error);
assertThat(output).contains("Check your network connection");
}
/***************************************************************************
* Test verbose mode includes stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_verboseWithCause()
{
ErrorFormatter formatter = new ErrorFormatter(true);
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"Failed to read file",
cause
);
String output = formatter.format(error);
assertThat(output).contains("Stack trace:");
assertThat(output).contains("RuntimeException");
assertThat(output).contains("Root cause");
}
/***************************************************************************
* Test non-verbose mode hides stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_nonVerboseHidesStackTrace()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"Failed to read file",
cause
);
String output = formatter.format(error);
assertThat(output).doesNotContain("Stack trace:");
}
/***************************************************************************
* Test formatting generic exception.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_genericException()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception e = new RuntimeException("Something went wrong");
String output = formatter.format(e, "Try again later");
assertThat(output).contains("error:");
assertThat(output).contains("Something went wrong");
assertThat(output).contains("Try again later");
}
/***************************************************************************
* Test formatting exception without hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_exceptionNoHint()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception e = new RuntimeException("Error message");
String output = formatter.format(e, null);
assertThat(output).contains("error:");
assertThat(output).contains("Error message");
}
/***************************************************************************
* Test verbose exception shows stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_verboseException()
{
ErrorFormatter formatter = new ErrorFormatter(true);
Exception e = new RuntimeException("Error");
String output = formatter.format(e, null);
assertThat(output).contains("Stack trace:");
}
}
@@ -0,0 +1,234 @@
/*
* 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.error;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for SuggestionEngine fuzzy matching.
*
* @since 0.2.0
*******************************************************************************/
class SuggestionEngineTest
{
private SuggestionEngine engine;
private List<String> candidates;
@BeforeEach
void setUp()
{
engine = new SuggestionEngine();
candidates = List.of(
"new-qqq-application",
"new-qqq-etl",
"new-qqq-api",
"spring-boot-starter",
"react-dashboard"
);
}
/***************************************************************************
* Test exact match returns first in results.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_exactMatch_returnsFirst()
{
List<String> results = engine.findSimilar("new-qqq-application", candidates);
assertThat(results).isNotEmpty();
assertThat(results.get(0)).isEqualTo("new-qqq-application");
}
/***************************************************************************
* Test typo with one character difference.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_typoOneChar_suggestsCorrect()
{
List<String> results = engine.findSimilar("new-qqq-aplication", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test prefix match gives bonus.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_prefixMatch_suggestsMatches()
{
List<String> results = engine.findSimilar("new-qqq-ap", candidates);
assertThat(results).contains("new-qqq-api");
}
/***************************************************************************
* Test missing hyphen typo.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_missingHyphen_suggests()
{
List<String> results = engine.findSimilar("newqqq-application", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test wrong word order returns nothing for distant strings.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_veryDifferent_returnsEmpty()
{
List<String> results = engine.findSimilar("completely-different-name", candidates);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test null input returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_nullInput_returnsEmpty()
{
List<String> results = engine.findSimilar(null, candidates);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test empty input returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_emptyInput_returnsEmpty()
{
List<String> results = engine.findSimilar("", candidates);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test null candidates returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_nullCandidates_returnsEmpty()
{
List<String> results = engine.findSimilar("test", null);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test empty candidates returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_emptyCandidates_returnsEmpty()
{
List<String> results = engine.findSimilar("test", List.of());
assertThat(results).isEmpty();
}
/***************************************************************************
* Test case insensitive matching.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_caseInsensitive_suggestsMatch()
{
List<String> results = engine.findSimilar("NEW-QQQ-APPLICATION", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test limits suggestions to 3.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_limitsSuggestions()
{
List<String> manyCandidates = List.of(
"test1", "test2", "test3", "test4", "test5"
);
List<String> results = engine.findSimilar("test", manyCandidates);
assertThat(results).hasSizeLessThanOrEqualTo(3);
}
/***************************************************************************
* Test transposed characters.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_transposedChars_suggests()
{
List<String> results = engine.findSimilar("new-qqq-applictaion", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test short strings with similar candidates.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_shortCandidates_suggests()
{
List<String> shortCandidates = List.of("api", "etl", "web", "app");
List<String> results = engine.findSimilar("aip", shortCandidates);
assertThat(results).contains("api");
}
}
@@ -0,0 +1,169 @@
/*
* 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.error;
import java.util.List;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for TemplateError structured error handling.
*
* @since 0.2.0
*******************************************************************************/
class TemplateErrorTest
{
/***************************************************************************
* Test error with all fields.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_allFields()
{
List<String> suggestions = List.of("foo", "bar");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND,
"Template not found",
suggestions,
"Check the template name"
);
assertThat(error.getCategory()).isEqualTo(TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND);
assertThat(error.getMessage()).isEqualTo("Template not found");
assertThat(error.getSuggestions()).containsExactly("foo", "bar");
assertThat(error.getHint()).isEqualTo("Check the template name");
assertThat(error.hasSuggestions()).isTrue();
}
/***************************************************************************
* Test error with only message and hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_messageAndHint()
{
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Failed to render",
"Check your syntax"
);
assertThat(error.getMessage()).isEqualTo("Failed to render");
assertThat(error.getHint()).isEqualTo("Check your syntax");
assertThat(error.getSuggestions()).isEmpty();
assertThat(error.hasSuggestions()).isFalse();
}
/***************************************************************************
* Test error with only message.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_messageOnly()
{
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"File not readable"
);
assertThat(error.getMessage()).isEqualTo("File not readable");
assertThat(error.getHint()).isNull();
assertThat(error.getSuggestions()).isEmpty();
}
/***************************************************************************
* Test error wrapping a cause.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_withCause()
{
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.NETWORK_ERROR,
"Connection failed",
cause
);
assertThat(error.getMessage()).isEqualTo("Connection failed");
assertThat(error.getCause()).isEqualTo(cause);
}
/***************************************************************************
* Test exit code mapping for each category.
*
* @since 0.2.0
***************************************************************************/
@Test
void testExitCodes()
{
assertThat(new TemplateError(TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND, "x").getExitCode())
.isEqualTo(ExitCodes.NOT_FOUND);
assertThat(new TemplateError(TemplateError.ErrorCategory.VARIABLE_NOT_FOUND, "x").getExitCode())
.isEqualTo(ExitCodes.VALIDATION);
assertThat(new TemplateError(TemplateError.ErrorCategory.MANIFEST_INVALID, "x").getExitCode())
.isEqualTo(ExitCodes.VALIDATION);
assertThat(new TemplateError(TemplateError.ErrorCategory.RENDER_ERROR, "x").getExitCode())
.isEqualTo(ExitCodes.VALIDATION);
assertThat(new TemplateError(TemplateError.ErrorCategory.NETWORK_ERROR, "x").getExitCode())
.isEqualTo(ExitCodes.NETWORK);
assertThat(new TemplateError(TemplateError.ErrorCategory.DIRECTORY_EXISTS, "x").getExitCode())
.isEqualTo(ExitCodes.CONFLICT);
assertThat(new TemplateError(TemplateError.ErrorCategory.IO_ERROR, "x").getExitCode())
.isEqualTo(ExitCodes.GENERIC);
}
/***************************************************************************
* Test null suggestions becomes empty list.
*
* @since 0.2.0
***************************************************************************/
@Test
void testNullSuggestions_becomesEmptyList()
{
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Error",
null,
"hint"
);
assertThat(error.getSuggestions()).isEmpty();
assertThat(error.hasSuggestions()).isFalse();
}
}