diff --git a/src/main/java/mayobot/commands/FindCommand.java b/src/main/java/mayobot/commands/FindCommand.java index ce0d5ffb8f..510daf1add 100644 --- a/src/main/java/mayobot/commands/FindCommand.java +++ b/src/main/java/mayobot/commands/FindCommand.java @@ -24,6 +24,15 @@ public class FindCommand extends Command { private static final String MISSING_SEARCH_TERM_MESSAGE = "Please specify a search term."; private static final String NO_TASK_FOUND_MESSAGE = "No matching tasks found."; + private static final String FUZZY_SEARCH_INDICATOR = " (fuzzy search)"; + private static final String STRICT_SEARCH_INDICATOR = " (exact word search)"; + + private static final double FUZZY_THRESHOLD = 0.7; // 70% similarity threshold + + private final boolean fuzzySearch; + private final boolean strictSearch; + private final String searchTerm; + /** * Constructs a new FindCommand with the specified search arguments. * @@ -31,6 +40,20 @@ public class FindCommand extends Command { */ public FindCommand(String arguments) { super("find", arguments); + + String trimmedArgs = arguments.trim(); + + this.fuzzySearch = trimmedArgs.startsWith("--fuzzy"); + this.strictSearch = trimmedArgs.startsWith("--strict"); + + // Extract search term by removing flags + if (fuzzySearch) { + this.searchTerm = trimmedArgs.replaceFirst("^--fuzzy\\s*", "").trim(); + } else if (strictSearch) { + this.searchTerm = trimmedArgs.replaceFirst("^--strict\\s*", "").trim(); + } else { + this.searchTerm = trimmedArgs; + } } /** @@ -49,35 +72,102 @@ public FindCommand(String arguments) { */ @Override public String execute(Ui ui, TaskList taskList, boolean isGui) throws MayoBotException { - String arguments = this.getArguments(); - if (arguments.trim().isEmpty()) { + if (searchTerm.isEmpty()) { + String helpMessage = buildHelpMessage(); if (!isGui) { - ui.showMessage(MISSING_SEARCH_TERM_MESSAGE); + ui.showMessage(helpMessage); } - return buildResponse(MISSING_SEARCH_TERM_MESSAGE); + return buildResponse(helpMessage); + } + + ArrayList matchingTasks = performSearch(taskList); + + if (matchingTasks.isEmpty()) { + String noResultsMessage = buildNoResultsMessage(); + if (!isGui) { + ui.showMessage(noResultsMessage); + } + return buildResponse(noResultsMessage); + } + + return buildSearchResults(ui, matchingTasks, isGui); + } + + /** + * Performs the appropriate search based on the search mode. + */ + private ArrayList performSearch(TaskList taskList) { + if (fuzzySearch) { + return taskList.findTasksFuzzy(searchTerm, FUZZY_THRESHOLD); + } else if (strictSearch) { + return taskList.findTasksStrict(searchTerm); } else { - ArrayList matchingTasks = taskList.findTasks(arguments.trim()); - if (matchingTasks.isEmpty()) { - if (!isGui) { - ui.showMessage(NO_TASK_FOUND_MESSAGE); - } - return buildResponse(NO_TASK_FOUND_MESSAGE); - } else { - StringBuilder response = new StringBuilder(); - if (!isGui) { - ui.showMessage(LIST_OUTPUT_HEADER); - } - response.append(LIST_OUTPUT_HEADER + "\n"); - for (Object[] matchingTask : matchingTasks) { - int index = (Integer) matchingTask[0]; - Task task = (Task) matchingTask[1]; - if (!isGui) { - ui.showMessage(index + ". " + task); - } - response.append(index + ". " + task + "\n"); - } - return buildResponse(response.toString()); + return taskList.findTasks(searchTerm); // Your existing partial match method + } + } + + /** + * Builds help message for invalid search input. + */ + private String buildHelpMessage() { + return MISSING_SEARCH_TERM_MESSAGE + "\n" + + "Usage:\n" + + " find - partial matching\n" + + " find --fuzzy - fuzzy matching (handles typos)\n" + + " find --strict - exact word matching"; + } + + /** + * Builds no results message with search mode indicator. + */ + private String buildNoResultsMessage() { + String searchMode = ""; + if (fuzzySearch) { + searchMode = FUZZY_SEARCH_INDICATOR; + } else if (strictSearch) { + searchMode = STRICT_SEARCH_INDICATOR; + } + + return NO_TASK_FOUND_MESSAGE + " for \"" + searchTerm + "\"" + searchMode; + } + + /** + * Builds and returns the search results response. + */ + private String buildSearchResults(Ui ui, ArrayList matchingTasks, boolean isGui) { + StringBuilder response = new StringBuilder(); + String header = buildSearchHeader(matchingTasks.size()); + + if (!isGui) { + ui.showMessage(header); + } + response.append(header).append("\n"); + + for (Object[] matchingTask : matchingTasks) { + int index = (Integer) matchingTask[0]; + Task task = (Task) matchingTask[1]; + String taskLine = index + ". " + task; + + if (!isGui) { + ui.showMessage(taskLine); } + response.append(taskLine).append("\n"); } + + return buildResponse(response.toString()); + } + + /** + * Builds the search results header with search mode indicator. + */ + private String buildSearchHeader(int resultCount) { + String searchMode = ""; + if (fuzzySearch) { + searchMode = FUZZY_SEARCH_INDICATOR; + } else if (strictSearch) { + searchMode = STRICT_SEARCH_INDICATOR; + } + + return LIST_OUTPUT_HEADER + " (" + resultCount + " found)" + searchMode; } } diff --git a/src/main/java/mayobot/task/TaskList.java b/src/main/java/mayobot/task/TaskList.java index 355497e1b0..b5fae6e5e5 100644 --- a/src/main/java/mayobot/task/TaskList.java +++ b/src/main/java/mayobot/task/TaskList.java @@ -1,11 +1,13 @@ package mayobot.task; import java.util.ArrayList; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; import mayobot.Storage; import mayobot.ui.Ui; +import mayobot.util.SearchMatcher; /** * Manages a collection of tasks and provides operations for task manipulation. @@ -266,4 +268,43 @@ public ArrayList findTasks(String searchTerm) { .mapToObj(i -> new Object[]{i + 1, tasks.get(i)}) .collect(Collectors.toCollection(ArrayList::new)); } + + /** + * Finds tasks that match the search term using fuzzy matching with streams. + * This method handles typos and similar words using Levenshtein distance. + * The search is performed using Java streams for efficient processing. + * + * @param keyword the search term to match against + * @param threshold similarity threshold (0.0 to 1.0) + * @return list of matching tasks with their original indices + */ + public ArrayList findTasksFuzzy(String keyword, double threshold) { + assert keyword != null : "Search keyword cannot be null"; + assert !keyword.trim().isEmpty() : "Search keyword cannot be empty"; + assert threshold >= 0.0 && threshold <= 1.0 : "Threshold must be between 0.0 and 1.0"; + + return IntStream.range(0, tasks.size()) + .filter(i -> SearchMatcher.fuzzyMatch(keyword, tasks.get(i).getDescription(), threshold)) + .mapToObj(i -> new Object[]{i + 1, tasks.get(i)}) + .collect(Collectors.toCollection(ArrayList::new)); + } + + /** + * Finds tasks that contain the exact search term as a whole word using streams. + * This method performs exact word matching (case-insensitive). + * + * @param keyword the exact word to search for + * @return list of matching tasks with their original indices + */ + public ArrayList findTasksStrict(String keyword) { + assert keyword != null : "Search keyword cannot be null"; + assert !keyword.trim().isEmpty() : "Search keyword cannot be empty"; + + String searchPattern = "\\b" + Pattern.quote(keyword.toLowerCase()) + "\\b"; + + return IntStream.range(0, tasks.size()) + .filter(i -> tasks.get(i).getDescription().toLowerCase().matches(".*" + searchPattern + ".*")) + .mapToObj(i -> new Object[]{i + 1, tasks.get(i)}) + .collect(Collectors.toCollection(ArrayList::new)); + } } diff --git a/src/main/java/mayobot/util/SearchMatcher.java b/src/main/java/mayobot/util/SearchMatcher.java new file mode 100644 index 0000000000..9234910323 --- /dev/null +++ b/src/main/java/mayobot/util/SearchMatcher.java @@ -0,0 +1,80 @@ +package mayobot.util; + +/** + * Utility class for advanced string matching including fuzzy search capabilities. + */ +public class SearchMatcher { + + /** + * Calculates similarity between two strings using Levenshtein distance. + * Returns a value between 0.0 (no similarity) and 1.0 (identical). + */ + public static double calculateSimilarity(String s1, String s2) { + if (s1 == null || s2 == null) { + return 0.0; + } + if (s1.equals(s2)) { + return 1.0; + } + + int maxLength = Math.max(s1.length(), s2.length()); + if (maxLength == 0) { + return 1.0; + } + + return (maxLength - levenshteinDistance(s1, s2)) / (double) maxLength; + } + + /** + * Calculates Levenshtein distance between two strings. + */ + private static 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++) { + if (s1.charAt(i - 1) == s2.charAt(j - 1)) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = 1 + Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1])); + } + } + } + + return dp[s1.length()][s2.length()]; + } + + /** + * Checks if the search term matches the text with fuzzy matching. + * + * @param searchTerm the term to search for + * @param text the text to search in + * @param threshold similarity threshold (0.0 to 1.0) + * @return true if similarity is above threshold + */ + public static boolean fuzzyMatch(String searchTerm, String text, double threshold) { + String[] searchWords = searchTerm.toLowerCase().split("\\s+"); + String[] textWords = text.toLowerCase().split("\\s+"); + + for (String searchWord : searchWords) { + boolean found = false; + for (String textWord : textWords) { + if (calculateSimilarity(searchWord, textWord) >= threshold) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } +}