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:
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user