diff --git a/.cursor/commands/implement-story-tasks.md b/.cursor/commands/implement-story-tasks.md index 44c3304..18ee38f 100644 --- a/.cursor/commands/implement-story-tasks.md +++ b/.cursor/commands/implement-story-tasks.md @@ -1,6 +1,6 @@ # Implement Story Tasks (All Tasks in Sequence) -Orchestrates implementation of all tasks from a MegaBrain `*-tasks.md` file in order. For each task T1, T2, … that is not yet completed, runs a **task council** (three subagents in parallel) to produce an implementation brief, then runs the implement-task workflow via a subagent using that brief (without the full test suite per task), commits the result, then runs the next task. When all tasks are implemented, runs the full test suite once via a test-runner subagent to fix any failures. +Orchestrates implementation of all tasks from a MegaBrain `*-tasks.md` file in order. Creates a feature branch, then for each task T1, T2, … that is not yet completed, runs a **task council** (three subagents in parallel) to produce an implementation brief, then runs the implement-task workflow via a subagent using that brief (without the full test suite per task), commits the result, then runs the next task. When all tasks are implemented, runs the full test suite once via a test-runner subagent to fix any failures, then pushes the branch and creates a pull request with GitHub CLI (`gh`). ## Usage @@ -24,22 +24,32 @@ Example: - Build an ordered list of tasks that are **not** `Completed`: only these will be implemented in this run. - If the list is empty, respond: "All tasks in this file are already completed." and stop. -2. **For each non-completed task in order (T1, then T2, …)** +2. **Create feature branch** + - Derive the **story slug** from the task file path: from the filename, strip `-tasks.md` (e.g. `US-03-02-openai-integration-tasks.md` → slug `US-03-02-openai-integration`). Branch name is `feature/`. + - If already on that branch, skip creation. Otherwise create and checkout: + - **Commands:** + ```bash + # Create and switch to feature branch (derive story slug from task file, e.g. US-03-02-openai-integration) + git checkout -b feature/ + ``` + - All subsequent commits go to this branch. + +3. **For each non-completed task in order (T1, then T2, …)** - Extract the **full task block** for that task (from the `### Tn:` heading through the end of that task section, i.e. until the next `### T...` or `---` / end of Task List). - Derive the **story id** from the file (e.g. from "Story: US-03-02" in the file or from the filename like `US-03-02-openai-integration-tasks.md`). - - **2a. Task council** — Before implementing, run a council of three subagents in parallel to get recommendations. Invoke **three** `mcp_task` calls **in the same turn** (parallel), so the orchestrator waits for all three results before proceeding. Use the following: + - **3a. Task council** — Before implementing, run a council of three subagents in parallel to get recommendations. Invoke **three** `mcp_task` calls **in the same turn** (parallel), so the orchestrator waits for all three results before proceeding. Use the following: - **Shared context** (include in each council prompt): story id, task id, full task block, task file path, path to related user story (same directory, `-tasks.md` replaced by `.md`), and the project's `docs/` folder as the canonical place for documentation updates. - **Council 1 (best practices & implementation):** `subagent_type`: `code-reviewer` (or custom `council-best-practices` if available). **Prompt:** "You are acting as a task council member. Given the task below, recommend only: (1) best practices and patterns to follow for this codebase for this task, (2) best way to implement this task (interfaces, classes, error handling). Reply with a concise, actionable list. Do not implement code. Context: [story id], [task id], [full task block], [task file path], [related user story path], docs folder: docs/." - **Council 2 (unit testing):** `subagent_type`: `generalPurpose` (or custom `council-testing` if available). **Prompt:** "You are acting as a task council member. Given the task below, recommend only: best way to unit test this task (test classes, scenarios, mocks, coverage focus). Reply with a concise, actionable list. Do not implement code. Context: [story id], [task id], [full task block], [task file path], [related user story path], docs folder: docs/." - **Council 3 (documentation):** `subagent_type`: `doc-generator` (or custom `council-docs` if available). **Prompt:** "You are acting as a task council member. Given the task below, recommend only: what should be added or updated in the project's `docs/` folder (e.g. configuration-reference.md, getting-started.md, new or updated pages). Reply with a concise, actionable list. Do not edit files. Context: [story id], [task id], [full task block], [task file path], [related user story path], docs folder: docs/." - - **2b. Synthesis** — From the three council replies, the **coordinator** (orchestrator) produces a short **implementation brief**: a single bullet list merging (1) best practices to follow, (2) recommended implementation approach, (3) unit-test approach, (4) docs updates for `docs/`. Keep the brief concise and actionable. + - **3b. Synthesis** — From the three council replies, the **coordinator** (orchestrator) produces a short **implementation brief**: a single bullet list merging (1) best practices to follow, (2) recommended implementation approach, (3) unit-test approach, (4) docs updates for `docs/`. Keep the brief concise and actionable. - **Invoke a subagent** to implement this single task: - Use `mcp_task` with `subagent_type`: `generalPurpose`. - **Prompt** (provide full context; subagents have no prior context): - - **Start with the council brief:** "Use the following council recommendations when implementing this task: [paste the implementation brief from 2b]. Then follow the implement-task process below." + - **Start with the council brief:** "Use the following council recommendations when implementing this task: [paste the implementation brief from 3b]. Then follow the implement-task process below." - Instruct the subagent to implement **only** this task (the given task id and task block). - Tell the subagent to follow the **implement-task** process in `.cursor/commands/implement-task.md` for the single task: task analysis, implementation planning, code, unit test authoring, and documentation/completion updates to the **tasks file** and the **related user story** file. - **Important:** Tell the subagent: "You are running as part of implement-story-tasks. **Do not run the full test suite** (`mvn clean install`) at the end of this task. Run only `mvn compile` and, if needed, specific tests (e.g. `mvn test -Dtest=ClassName`) to catch obvious errors. Skip the 'MANDATORY (Final Validation): mvn clean install' and the Documentation & Completion gate that requires build success. Still update the task status to Completed and update the tasks file and related user story. The orchestrator will run the full test suite once at the end of the story." @@ -55,12 +65,34 @@ Example: - If the subagent failed or did not respond with "all done :)", report the failure and ask the user whether to continue to the next task or stop. - After committing, **re-read the task file** to get the updated list of non-completed tasks for the next iteration (in case statuses changed). Or maintain the list and remove the current task; either way, process only non-completed tasks in order. -3. **When no tasks remain – run full test suite** +4. **When no tasks remain – run full test suite** - When every task in the file has `**Status:** Completed`, run the full backend build and tests once, and fix any failures: - Invoke a **test-runner** subagent via `mcp_task` with `subagent_type`: `test-runner`. - **Prompt:** "Run the full backend build and test suite: `mvn clean install` in the backend directory. Fix any compilation or test failures. Report when the build succeeds or if you cannot fix the failures." - Wait for the subagent to complete. If it reports success, optionally commit with a message like: `chore: verify story build – mvn clean install passes`. If it reports unresolved failures, report them to the user and do not commit. - - Then respond that the whole story tasks file has been implemented, list the tasks completed in this run, and confirm whether the final build passed. + - Then proceed to Step 5. + +5. **Finalize and create pull request** + - **Prerequisite:** GitHub CLI (`gh`) must be installed and authenticated (`gh auth status`). + - **Commit if needed:** If there are uncommitted changes (`git status` shows changes), run: + ```bash + git add . + git commit -m "chore: verify story build – mvn clean install passes" + ``` + - **Push the branch:** + ```bash + git push -u origin feature/ + ``` + - **Create pull request** (choose one): + ```bash + gh pr create --title "feat: " --body "Implements all tasks from . Resolves ." + ``` + or + ```bash + gh pr create --fill + ``` + (`--fill` uses the first commit message as title/body.) + - Then respond that the whole story tasks file has been implemented, list the tasks completed in this run, confirm whether the final build passed, and give the PR URL (from `gh pr create` output). ## Task File and User Story Conventions @@ -82,10 +114,10 @@ Custom council agents under `.cursor/agents/` can set different `model` values ( | Step | Action | |------|--------| -| 1 | Parse task file → ordered list of non-completed tasks (T1, T2, …). | -| 2 | If list empty → "All tasks already completed." → stop. | -| 2a–2b | For each task: run **task council** (3 subagents in parallel: code-reviewer / council-best-practices, generalPurpose / council-testing, doc-generator / council-docs) → coordinator **synthesizes implementation brief** → pass brief to implement-task subagent. | -| 3 | For each task in list: run **generalPurpose** subagent (implement-task for that task only, **with council brief** in prompt; **skip full mvn clean install**; use mvn compile / targeted tests only) → on "all done :)", commit → next task. | -| 4 | When all tasks Completed → run **test-runner** subagent: `mvn clean install`, fix failures → optionally commit → report done. | +| 1 | Parse task file → ordered list of non-completed tasks (T1, T2, …). If list empty → "All tasks already completed." → stop. | +| 2 | Create branch `feature/` (`git checkout -b feature/`). | +| 3 | For each task: run **task council** (3a–3b), then **generalPurpose** subagent (implement-task with council brief; skip full `mvn clean install`; use mvn compile / targeted tests only) → on "all done :)", commit → next task. | +| 4 | When all tasks Completed → run **test-runner** subagent: `mvn clean install`, fix failures → optionally commit → then Step 5. | +| 5 | Commit any remaining changes (if needed) → `git push -u origin feature/` → `gh pr create` (with `--title`/`--body` or `--fill`) → report done and PR URL. | Reference: [implement-task](.cursor/commands/implement-task.md) for the per-task process and subagent usage (explore, test-runner, verifier, etc.). The subagent should follow that command for the single task it is given. diff --git a/backend/pom.xml b/backend/pom.xml index 7a4b65a..439c781 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -150,6 +150,13 @@ quarkus-picocli + + + org.fusesource.jansi + jansi + 2.4.2 + + io.quarkus diff --git a/backend/src/main/java/io/megabrain/api/SearchResource.java b/backend/src/main/java/io/megabrain/api/SearchResource.java index 011d3d2..55b2bdb 100644 --- a/backend/src/main/java/io/megabrain/api/SearchResource.java +++ b/backend/src/main/java/io/megabrain/api/SearchResource.java @@ -23,8 +23,6 @@ import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; -import org.apache.lucene.document.Document; - import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -189,7 +187,7 @@ public Uni search( // Convert merged results to SearchResult DTOs List results = mergedResults.stream() - .map(this::convertToSearchResult) + .map(SearchResultMapper::toSearchResult) .collect(Collectors.toList()); // Calculate pagination @@ -235,89 +233,6 @@ public Uni search( } } - /** - * Converts a MergedResult to a SearchResult DTO. - * - * @param mergedResult the merged result from hybrid search - * @return SearchResult DTO - */ - private SearchResult convertToSearchResult(ResultMerger.MergedResult mergedResult) { - // Determine which result source to use (prefer Lucene if available) - String content; - String entityName; - String entityType; - String sourceFile; - String language; - String repository; - float score; - LineRange lineRange; - - if (mergedResult.luceneDocument() != null) { - var luceneDoc = mergedResult.luceneDocument(); - content = luceneDoc.get("content"); - entityName = luceneDoc.get("entity_name"); - entityType = luceneDoc.get("entity_type"); - sourceFile = luceneDoc.get("source_file"); - language = luceneDoc.get("language"); - repository = luceneDoc.get("repository"); - score = (float) mergedResult.combinedScore(); - int startLine = getIntField(luceneDoc, "start_line", 1); - int endLine = getIntField(luceneDoc, "end_line", 1); - lineRange = new LineRange(startLine, endLine); - } else if (mergedResult.vectorResult() != null) { - var vectorMeta = mergedResult.vectorResult().metadata(); - content = vectorMeta.content(); - entityName = vectorMeta.entityName(); - entityType = vectorMeta.entityType(); - sourceFile = vectorMeta.sourceFile(); - language = vectorMeta.language(); - repository = ""; // Vector results don't have repository yet - score = (float) mergedResult.combinedScore(); - lineRange = new LineRange(vectorMeta.startLine(), vectorMeta.endLine()); - } else { - // Fallback (should not happen) - content = ""; - entityName = ""; - entityType = ""; - sourceFile = ""; - language = ""; - repository = ""; - score = 0.0f; - lineRange = new LineRange(1, 1); - } - - FieldMatchInfo apiFieldMatch = mergedResult.fieldMatch() != null - ? new FieldMatchInfo(mergedResult.fieldMatch().matchedFields(), mergedResult.fieldMatch().scores()) - : null; - // Transitive metadata (US-02-06, T6): mark results from graph traversal and include relationship path - boolean isTransitive = mergedResult.transitivePath() != null; - List relationshipPath = mergedResult.transitivePath() != null && !mergedResult.transitivePath().isEmpty() - ? mergedResult.transitivePath() - : null; - return new SearchResult(content, entityName, entityType, sourceFile, - language, repository, score, lineRange, null, apiFieldMatch, isTransitive, relationshipPath); - } - - /** - * Gets an integer field from a Lucene document, with a default value. - * - * @param doc the Lucene document - * @param fieldName the field name - * @param defaultValue the default value if field is missing or invalid - * @return the integer value - */ - private int getIntField(Document doc, String fieldName, int defaultValue) { - String value = doc.get(fieldName); - if (value == null || value.isBlank()) { - return defaultValue; - } - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - return defaultValue; - } - } - /** * Parses the search mode string to SearchMode enum. * diff --git a/backend/src/main/java/io/megabrain/api/SearchResultMapper.java b/backend/src/main/java/io/megabrain/api/SearchResultMapper.java new file mode 100644 index 0000000..8f8a2cd --- /dev/null +++ b/backend/src/main/java/io/megabrain/api/SearchResultMapper.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.api; + +import io.megabrain.core.ResultMerger; +import io.megabrain.core.VectorStore; +import org.apache.lucene.document.Document; + +import java.util.List; + +/** + * Maps internal search result types to API DTOs. + * Shared by REST SearchResource and CLI SearchCommand. + */ +public final class SearchResultMapper { + + private SearchResultMapper() { + } + + /** + * Converts a MergedResult to a SearchResult DTO. + * + * @param mergedResult the merged result from hybrid search + * @return SearchResult DTO + */ + public static SearchResult toSearchResult(ResultMerger.MergedResult mergedResult) { + String content; + String entityName; + String entityType; + String sourceFile; + String language; + String repository; + float score; + LineRange lineRange; + + if (mergedResult.luceneDocument() != null) { + Document luceneDoc = mergedResult.luceneDocument(); + content = luceneDoc.get("content"); + entityName = luceneDoc.get("entity_name"); + entityType = luceneDoc.get("entity_type"); + sourceFile = luceneDoc.get("source_file"); + language = luceneDoc.get("language"); + repository = luceneDoc.get("repository"); + score = (float) mergedResult.combinedScore(); + int startLine = getIntField(luceneDoc, "start_line", 1); + int endLine = getIntField(luceneDoc, "end_line", 1); + lineRange = new LineRange(startLine, endLine); + } else if (mergedResult.vectorResult() != null) { + VectorStore.VectorMetadata vectorMeta = mergedResult.vectorResult().metadata(); + content = vectorMeta != null ? vectorMeta.content() : ""; + entityName = vectorMeta != null ? vectorMeta.entityName() : ""; + entityType = vectorMeta != null ? vectorMeta.entityType() : ""; + sourceFile = vectorMeta != null ? vectorMeta.sourceFile() : ""; + language = vectorMeta != null ? vectorMeta.language() : ""; + repository = ""; + score = (float) mergedResult.combinedScore(); + int start = vectorMeta != null ? vectorMeta.startLine() : 1; + int end = vectorMeta != null ? vectorMeta.endLine() : 1; + lineRange = new LineRange(start, end); + } else { + content = ""; + entityName = ""; + entityType = ""; + sourceFile = ""; + language = ""; + repository = ""; + score = 0.0f; + lineRange = new LineRange(1, 1); + } + + FieldMatchInfo apiFieldMatch = mergedResult.fieldMatch() != null + ? new FieldMatchInfo(mergedResult.fieldMatch().matchedFields(), mergedResult.fieldMatch().scores()) + : null; + boolean isTransitive = mergedResult.transitivePath() != null; + List relationshipPath = mergedResult.transitivePath() != null && !mergedResult.transitivePath().isEmpty() + ? mergedResult.transitivePath() + : null; + return new SearchResult(content, entityName, entityType, sourceFile, + language, repository, score, lineRange, null, apiFieldMatch, isTransitive, relationshipPath); + } + + /** + * Gets an integer field from a Lucene document, with a default value. + */ + public static int getIntField(Document doc, String fieldName, int defaultValue) { + String value = doc.get(fieldName); + if (value == null || value.isBlank()) { + return defaultValue; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException _) { + return defaultValue; + } + } +} diff --git a/backend/src/main/java/io/megabrain/cli/CliSyntaxHighlighter.java b/backend/src/main/java/io/megabrain/cli/CliSyntaxHighlighter.java new file mode 100644 index 0000000..4f38a62 --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/CliSyntaxHighlighter.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import org.fusesource.jansi.AnsiConsole; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Keyword/pattern-based syntax highlighter for CLI snippets. + * Supports Java, Python, JavaScript; other languages fall back to plain text. + * Uses Jansi for ANSI codes; null/blank language or useColor false returns content unchanged. + */ +@ApplicationScoped +public class CliSyntaxHighlighter implements SyntaxHighlighter { + + /** Languages that have highlighting rules; others fall back to plain. */ + private static final Set SUPPORTED_LANGUAGES = Set.of("java", "python", "javascript", "typescript"); + + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_KEYWORD = "\u001B[33m"; // yellow/bright + private static final String ANSI_STRING = "\u001B[32m"; // green + private static final String ANSI_NUMBER = "\u001B[35m"; // magenta + private static final String ANSI_COMMENT = "\u001B[90m"; // bright black/gray + + // Java/JS/TS keywords (common subset) + private static final Pattern KEYWORD_PATTERN = Pattern.compile( + "\\b(public|private|protected|static|final|class|interface|extends|implements|void|return|if|else|for|while|do|switch|case|break|continue|try|catch|finally|throw|throws|new|this|super|import|package|def|lambda|async|await|const|let|var|function|true|false|null)\\b" + ); + // Python keywords + private static final Pattern PYTHON_KEYWORD_PATTERN = Pattern.compile( + "\\b(def|class|if|elif|else|for|while|try|except|finally|with|as|import|from|return|yield|lambda|and|or|not|in|is|None|True|False|async|await)\\b" + ); + // Number (integer or float) + private static final Pattern NUMBER_PATTERN = Pattern.compile("\\b\\d+\\.?\\d*\\b"); + + static { + AnsiConsole.systemInstall(); + } + + @Override + public String highlight(String content, String language, boolean useColor) { + if (content == null) { + return ""; + } + if (!useColor || language == null || language.isBlank()) { + return content; + } + String lang = language.trim().toLowerCase(); + if (!SUPPORTED_LANGUAGES.contains(lang)) { + return content; + } + try { + return applyHighlight(content, lang); + } catch (Exception e) { + return content; + } + } + + private String applyHighlight(String content, String lang) { + if (content.isEmpty()) { + return content; + } + if ("python".equals(lang)) { + return highlightPython(content); + } + return highlightJavaLike(content); + } + + private String highlightJavaLike(String content) { + StringBuilder sb = new StringBuilder(content.length() * 2); + String[] lines = content.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(highlightLineJavaLike(lines[i])); + } + return sb.toString(); + } + + private String highlightLineJavaLike(String line) { + String rest = line; + StringBuilder out = new StringBuilder(); + int idx = 0; + while (idx < rest.length()) { + int commentStart = rest.indexOf("//", idx); + if (commentStart >= 0) { + out.append(highlightSegment(rest.substring(idx, commentStart), true)); + out.append(ANSI_COMMENT).append(rest.substring(commentStart)).append(ANSI_RESET); + return out.toString(); + } + int nextDq = rest.indexOf('"', idx); + int nextSq = rest.indexOf('\'', idx); + int next = rest.length(); + char quote = 0; + if (nextDq >= 0 && nextDq < next) { + next = nextDq; + quote = '"'; + } + if (nextSq >= 0 && nextSq < next) { + next = nextSq; + quote = '\''; + } + String segment = rest.substring(idx, next); + out.append(highlightSegment(segment, true)); + if (quote != 0 && next < rest.length()) { + int end = next + 1; + while (end < rest.length()) { + char c = rest.charAt(end); + if (c == '\\') { + end += 2; + continue; + } + if (c == quote) { + end++; + break; + } + end++; + } + out.append(ANSI_STRING).append(rest.substring(next, end)).append(ANSI_RESET); + idx = end; + } else { + idx = next; + } + } + return out.toString(); + } + + private String highlightSegment(String segment, boolean javaLike) { + if (segment.isEmpty()) { + return segment; + } + Pattern kw = javaLike ? KEYWORD_PATTERN : PYTHON_KEYWORD_PATTERN; + String s = segment; + java.util.regex.Matcher m = kw.matcher(s); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + m.appendReplacement(sb, ANSI_KEYWORD + m.group() + ANSI_RESET); + } + m.appendTail(sb); + s = sb.toString(); + m = NUMBER_PATTERN.matcher(s); + sb = new StringBuffer(); + while (m.find()) { + m.appendReplacement(sb, ANSI_NUMBER + m.group() + ANSI_RESET); + } + m.appendTail(sb); + return sb.toString(); + } + + private String highlightPython(String content) { + StringBuilder sb = new StringBuilder(content.length() * 2); + String[] lines = content.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + sb.append('\n'); + } + String line = lines[i]; + int hash = line.indexOf('#'); + if (hash >= 0) { + sb.append(highlightSegment(line.substring(0, hash), false)); + sb.append(ANSI_COMMENT).append(line.substring(hash)).append(ANSI_RESET); + } else { + sb.append(highlightLinePythonStrings(line)); + } + } + return sb.toString(); + } + + private String highlightLinePythonStrings(String line) { + StringBuilder out = new StringBuilder(); + int idx = 0; + while (idx < line.length()) { + int nextDq = line.indexOf('"', idx); + int nextSq = line.indexOf('\'', idx); + int next = line.length(); + char quote = 0; + if (nextDq >= 0 && nextDq < next) { + next = nextDq; + quote = '"'; + } + if (nextSq >= 0 && nextSq < next) { + next = nextSq; + quote = '\''; + } + String segment = line.substring(idx, next); + out.append(highlightSegment(segment, false)); + if (quote != 0 && next < line.length()) { + int end = next + 1; + while (end < line.length()) { + char c = line.charAt(end); + if (c == '\\') { + end += 2; + continue; + } + if (c == quote) { + end++; + break; + } + end++; + } + out.append(ANSI_STRING).append(line.substring(next, end)).append(ANSI_RESET); + idx = end; + } else { + idx = next; + } + } + return out.toString(); + } +} diff --git a/backend/src/main/java/io/megabrain/cli/HumanReadableSearchResultFormatter.java b/backend/src/main/java/io/megabrain/cli/HumanReadableSearchResultFormatter.java new file mode 100644 index 0000000..eeac30e --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/HumanReadableSearchResultFormatter.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import io.megabrain.api.SearchResponse; +import io.megabrain.api.SearchResult; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import jakarta.inject.Inject; +import java.util.List; + +/** + * Human-readable formatter for CLI search output. + * Format per result: File, Entity, Score, snippet, separator. Truncates long snippets. + * Does not reuse ContextFormatter (that is for LLM prompts). + */ +@ApplicationScoped +public class HumanReadableSearchResultFormatter implements SearchResultFormatter { + + private static final Logger LOG = Logger.getLogger(HumanReadableSearchResultFormatter.class); + + private final SyntaxHighlighter highlighter; + + /** Maximum number of lines to show in a snippet. */ + public static final int MAX_SNIPPET_LINES = 15; + /** Maximum characters per line before truncation. */ + public static final int MAX_LINE_LENGTH = 120; + /** Separator between results. */ + public static final String RESULT_SEPARATOR = "---"; + + private static final String PLACEHOLDER_PATH = "(no path)"; + private static final String PLACEHOLDER_ENTITY = "(no entity)"; + private static final String NO_RESULTS = "No results."; + + @Inject + public HumanReadableSearchResultFormatter(SyntaxHighlighter highlighter) { + this.highlighter = highlighter; + } + + /** + * No-arg constructor for tests when highlighter is not available; highlighter will be null (no highlighting). + */ + public HumanReadableSearchResultFormatter() { + this.highlighter = null; + } + + @Override + public String format(SearchResponse response) { + return format(response, false, true); + } + + @Override + public String format(SearchResponse response, boolean quiet, boolean useColor) { + if (response == null || response.getResults() == null || response.getResults().isEmpty()) { + return NO_RESULTS; + } + if (quiet) { + return formatQuiet(response); + } + StringBuilder sb = new StringBuilder(); + appendHeader(response, sb); + List results = response.getResults(); + for (int i = 0; i < results.size(); i++) { + appendResult(results.get(i), sb, useColor); + sb.append(RESULT_SEPARATOR); + if (i < results.size() - 1) { + sb.append('\n'); + } + } + return sb.toString(); + } + + @Override + public String formatQuiet(SearchResponse response) { + if (response == null || response.getResults() == null || response.getResults().isEmpty()) { + return NO_RESULTS; + } + StringBuilder sb = new StringBuilder(); + for (SearchResult r : response.getResults()) { + String path = nullToEmpty(r.getSourceFile(), PLACEHOLDER_PATH); + String entity = nullToEmpty(r.getEntityName(), PLACEHOLDER_ENTITY); + sb.append(path).append('\t').append(entity).append('\n'); + } + return sb.toString(); + } + + private void appendHeader(SearchResponse response, StringBuilder sb) { + String query = response.getQuery(); + if (query != null && !query.isBlank()) { + sb.append("Query: ").append(query.trim()).append('\n'); + sb.append("Total: ").append(response.getTotal()).append(" | Took: ").append(response.getTookMs()).append(" ms\n\n"); + } + } + + private void appendResult(SearchResult r, StringBuilder sb, boolean useColor) { + String path = nullToEmpty(r.getSourceFile(), PLACEHOLDER_PATH); + String entity = nullToEmpty(r.getEntityName(), PLACEHOLDER_ENTITY); + float score = r.getScore(); + String snippet = truncateSnippet(nullToEmpty(r.getContent(), "")); + + sb.append("File: ").append(path).append('\n'); + sb.append("Entity: ").append(entity).append('\n'); + sb.append("Score: ").append(score).append('\n'); + sb.append('\n'); + if (!snippet.isEmpty()) { + String toAppend = snippet; + if (highlighter != null && useColor) { + try { + toAppend = highlighter.highlight(snippet, r.getLanguage(), useColor); + } catch (Exception e) { + LOG.debugf(e, "Syntax highlighter failed for language %s, using plain snippet", r.getLanguage()); + } + } + sb.append(toAppend).append('\n'); + } + sb.append('\n'); + } + + private String truncateSnippet(String content) { + if (content == null || content.isBlank()) { + return ""; + } + String[] lines = content.split("\n", -1); + StringBuilder sb = new StringBuilder(); + int lineCount = 0; + for (String line : lines) { + if (lineCount >= MAX_SNIPPET_LINES) { + sb.append("... (truncated)\n"); + break; + } + String trimmed = line.length() > MAX_LINE_LENGTH + ? line.substring(0, MAX_LINE_LENGTH) + "..." + : line; + sb.append(trimmed).append('\n'); + lineCount++; + } + return sb.toString().trim(); + } + + private static String nullToEmpty(String value, String placeholder) { + if (value == null || value.isBlank()) { + return placeholder != null ? placeholder : ""; + } + return value.trim(); + } +} diff --git a/backend/src/main/java/io/megabrain/cli/IngestCommand.java b/backend/src/main/java/io/megabrain/cli/IngestCommand.java new file mode 100644 index 0000000..00fd753 --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/IngestCommand.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import io.megabrain.api.IngestionResource; +import io.megabrain.ingestion.IngestionService; +import io.megabrain.ingestion.ProgressEvent; +import io.smallrye.mutiny.Multi; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; +import picocli.CommandLine; + +import java.io.PrintWriter; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.jboss.logmanager.Level; + +/** + * CLI command to ingest a repository into the MegaBrain index. + * Exit codes: 0 = success, 1 = execution/ingestion failure, 2 = invalid arguments. + * Use {@code megabrain ingest --help} for usage. + */ +@ApplicationScoped +@CommandLine.Command( + name = "ingest", + description = "Ingest a repository (GitHub, GitLab, Bitbucket, or local path) into the MegaBrain index.", + mixinStandardHelpOptions = true, + exitCodeOnInvalidInput = 2, + exitCodeOnExecutionException = 1 +) +public class IngestCommand implements Runnable { + + private static final Logger LOG = Logger.getLogger(IngestCommand.class); + private static final int MAX_MESSAGE_LENGTH = 200; + + private final IngestionService ingestionService; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + @CommandLine.Option( + names = "--source", + required = true, + description = "Source type: github, gitlab, bitbucket, or local." + ) + String source; + + @CommandLine.Option( + names = "--repo", + required = true, + description = "Repository URL or identifier (e.g. owner/repo or file path for local)." + ) + String repo; + + @CommandLine.Option( + names = "--branch", + defaultValue = "main", + description = "Branch to ingest (default: main)." + ) + String branch; + + @CommandLine.Option( + names = "--token", + description = "Authentication token for private repositories (never logged)." + ) + String token; + + @CommandLine.Option( + names = "--incremental", + defaultValue = "false", + description = "Perform incremental ingestion (default: false)." + ) + boolean incremental; + + @CommandLine.Option( + names = "--verbose", + description = "Show detailed progress, debug messages, and stack traces on errors." + ) + boolean verbose; + + @Inject + public IngestCommand(IngestionService ingestionService) { + this.ingestionService = ingestionService; + } + + @Override + public void run() { + if (verbose) { + org.jboss.logmanager.LogContext.getLogContext() + .getLogger("io.megabrain") + .setLevel(Level.DEBUG); + } + + IngestionResource.SourceType sourceType = IngestionResource.SourceType.fromString(source); + if (sourceType == null) { + throw new CommandLine.ParameterException( + spec.commandLine(), + "Invalid source: '" + source + "'. Allowed: github, gitlab, bitbucket, local." + ); + } + if (repo == null || repo.isBlank()) { + throw new CommandLine.ParameterException( + spec.commandLine(), + "Repository (--repo) is required and must be non-blank." + ); + } + + String repositoryUrl = repo.trim(); + Multi progressStream = incremental + ? ingestionService.ingestRepositoryIncrementally(repositoryUrl) + : ingestionService.ingestRepository(repositoryUrl); + + boolean tty = System.console() != null; + PrintWriter out = spec.commandLine().getOut(); + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean failed = new AtomicBoolean(false); + + progressStream.subscribe().with( + item -> { + String msg = item.message() != null ? item.message() : ""; + if (!verbose && msg.length() > MAX_MESSAGE_LENGTH) { + msg = msg.substring(0, MAX_MESSAGE_LENGTH) + "..."; + } + String line = String.format("%s %.1f%%", msg, item.progress()); + if (tty) { + out.print("\r" + line); + out.flush(); + } else { + out.println(line); + out.flush(); + } + }, + err -> { + if (verbose) { + LOG.error("Ingestion failed", err); + } else { + LOG.errorf("Ingestion failed: %s", err.getMessage()); + } + failed.set(true); + latch.countDown(); + }, + latch::countDown + ); + + try { + latch.await(1, TimeUnit.HOURS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Ingestion interrupted"); + throw new CommandLine.ExecutionException(spec.commandLine(), "Interrupted"); + } + if (tty) { + out.println(); + out.flush(); + } + if (failed.get()) { + throw new CommandLine.ExecutionException(spec.commandLine(), "Ingestion failed."); + } + } +} diff --git a/backend/src/main/java/io/megabrain/cli/MegaBrainCommand.java b/backend/src/main/java/io/megabrain/cli/MegaBrainCommand.java new file mode 100644 index 0000000..1859744 --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/MegaBrainCommand.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import io.quarkus.picocli.runtime.annotations.TopCommand; +import picocli.CommandLine; + +/** + * Top-level CLI command for MegaBrain. Dispatches to subcommands such as {@code ingest} and {@code search}. + */ +@TopCommand +@CommandLine.Command( + name = "megabrain", + description = "MegaBrain CLI: ingest repositories and search code.", + mixinStandardHelpOptions = true, + subcommands = { IngestCommand.class, SearchCommand.class } +) +public class MegaBrainCommand implements Runnable { + + @Override + public void run() { + // No subcommand specified: help is shown by Picocli when --help is used + } +} diff --git a/backend/src/main/java/io/megabrain/cli/SearchCommand.java b/backend/src/main/java/io/megabrain/cli/SearchCommand.java new file mode 100644 index 0000000..f26bb91 --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/SearchCommand.java @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.megabrain.api.SearchRequest; +import io.megabrain.api.SearchResponse; +import io.megabrain.api.SearchResultMapper; +import io.megabrain.core.SearchMode; +import io.megabrain.core.SearchOrchestrator; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import picocli.CommandLine; + +import jakarta.inject.Inject; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * CLI command to search the MegaBrain index from the command line. + * Exit codes: 0 = success, 1 = execution failure, 2 = invalid arguments (e.g. missing or blank query). + * Use {@code megabrain search --help} for usage. + */ +@ApplicationScoped +@CommandLine.Command( + name = "search", + description = "Search the MegaBrain index. Provide a query string; optional filters: --language, --repo, --type, --limit, --json, --quiet.", + mixinStandardHelpOptions = true, + exitCodeOnInvalidInput = 2, + exitCodeOnExecutionException = 1 +) +public class SearchCommand implements Runnable { + + private static final Logger LOG = Logger.getLogger(SearchCommand.class); + + private final SearchOrchestrator searchOrchestrator; + private final SearchResultFormatter searchResultFormatter; + private final ObjectMapper objectMapper; + private final int facetLimit; + private final int transitiveDefaultDepth; + private final int transitiveMaxDepth; + + /** Supported languages (aligned with Tree-sitter/grammar). */ + private static final Set SUPPORTED_LANGUAGES = Set.of( + "java", "python", "javascript", "typescript", "go", "rust", "kotlin", + "ruby", "scala", "swift", "php", "c", "cpp" + ); + + /** Supported entity types for --type filter. */ + private static final Set SUPPORTED_ENTITY_TYPES = Set.of( + "class", "method", "function", "field", "interface", "enum", "module" + ); + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + @CommandLine.Parameters( + index = "0", + description = "Search query string.", + paramLabel = "" + ) + String query; + + @CommandLine.Option( + names = "--language", + description = "Filter by programming language (repeatable). Allowed: java, python, javascript, typescript, go, rust, kotlin, ruby, scala, swift, php, c, cpp.", + paramLabel = "LANG" + ) + List language; + + @CommandLine.Option( + names = "--repo", + description = "Filter by repository name or identifier (repeatable).", + paramLabel = "REPO" + ) + List repo; + + @CommandLine.Option( + names = "--type", + description = "Filter by entity type (repeatable). Allowed: class, method, function, field, interface, enum, module.", + paramLabel = "TYPE" + ) + List type; + + @CommandLine.Option( + names = "--limit", + description = "Maximum number of results to return (1-100).", + paramLabel = "N", + defaultValue = "10" + ) + int limit; + + @CommandLine.Option( + names = "--json", + description = "Output results as JSON (API format: results, total, page, size, query, took_ms, facets; use --quiet for results array only).", + defaultValue = "false" + ) + boolean json; + + @CommandLine.Option( + names = "--quiet", + description = "Minimal output, pipe-friendly (with --json: results array only; otherwise one line per result).", + defaultValue = "false" + ) + boolean quiet; + + @CommandLine.Option( + names = "--no-color", + description = "Disable syntax highlighting and ANSI color in output.", + defaultValue = "false" + ) + boolean noColor; + + /** Built after validation; used by T3/T5 for actual search and formatting. */ + private SearchRequest searchRequest; + + /** + * CDI constructor for production. Quarkus injects orchestrator, formatter, ObjectMapper, and config. + * Tests can use this constructor with mocked dependencies. + */ + @Inject + public SearchCommand( + SearchOrchestrator searchOrchestrator, + SearchResultFormatter searchResultFormatter, + ObjectMapper objectMapper, + @ConfigProperty(name = "megabrain.search.facets.limit", defaultValue = "10") int facetLimit, + @ConfigProperty(name = "megabrain.search.transitive.default-depth", defaultValue = "5") int transitiveDefaultDepth, + @ConfigProperty(name = "megabrain.search.transitive.max-depth", defaultValue = "10") int transitiveMaxDepth) { + this.searchOrchestrator = searchOrchestrator; + this.searchResultFormatter = searchResultFormatter; + this.objectMapper = objectMapper; + this.facetLimit = facetLimit; + this.transitiveDefaultDepth = transitiveDefaultDepth; + this.transitiveMaxDepth = transitiveMaxDepth; + } + + /** + * No-arg constructor for Picocli when command is created without CDI (e.g. some tests). + * Dependencies will be null; run() will validate and print "Query received" then return. + */ + public SearchCommand() { + this.searchOrchestrator = null; + this.searchResultFormatter = null; + this.objectMapper = null; + this.facetLimit = 10; + this.transitiveDefaultDepth = 5; + this.transitiveMaxDepth = 10; + } + + /** + * Returns the validated search request built in run(). Null until run() has been called successfully. + * + * @return the SearchRequest with query and filters set, or null + */ + public SearchRequest getSearchRequest() { + return searchRequest; + } + + @Override + public void run() { + if (query == null || query.isBlank()) { + throw new CommandLine.ParameterException( + spec.commandLine(), + "Search query is required and must be non-blank." + ); + } + String trimmedQuery = query.trim(); + + List languages = language != null ? new ArrayList<>(language) : new ArrayList<>(); + List repos = repo != null ? new ArrayList<>(repo) : new ArrayList<>(); + List types = type != null ? new ArrayList<>(type) : new ArrayList<>(); + + validateLanguages(languages); + validateTypes(types); + validateLimit(); + + searchRequest = buildSearchRequest(trimmedQuery, languages, repos, types); + + LOG.debugf("Search command received query: %s, filters: language=%s, repo=%s, type=%s, limit=%d, json=%s, quiet=%s", + trimmedQuery, languages, repos, types, limit, json, quiet); + + if (searchOrchestrator == null || searchResultFormatter == null) { + // Standalone test run without CDI: only validate and build request + spec.commandLine().getOut().println("Query received: " + trimmedQuery); + spec.commandLine().getOut().flush(); + return; + } + + long startTime = System.currentTimeMillis(); + int effectiveDepth = searchRequest.getDepth() != null + ? Math.max(1, Math.min(searchRequest.getDepth(), transitiveMaxDepth)) + : transitiveDefaultDepth; + + try { + SearchOrchestrator.OrchestratorResult orcResult = searchOrchestrator + .orchestrate(searchRequest, SearchMode.HYBRID, facetLimit, effectiveDepth) + .await().indefinitely(); + + long tookMs = System.currentTimeMillis() - startTime; + List results = orcResult.mergedResults().stream() + .map(SearchResultMapper::toSearchResult) + .toList(); + SearchResponse response = new SearchResponse( + results, + results.size(), + 0, + searchRequest.getLimit(), + searchRequest.getQuery(), + tookMs, + orcResult.facets() + ); + + boolean useColor = resolveUseColor(); + PrintWriter out = spec.commandLine().getOut(); + if (json) { + if (objectMapper == null) { + throw new CommandLine.ExecutionException(spec.commandLine(), + "JSON output requires ObjectMapper (use CDI or pass ObjectMapper in constructor).", null); + } + boolean pretty = !quiet && System.console() != null && !noColor; + try { + if (quiet) { + objectMapper.writeValue(out, response.getResults()); + } else { + if (pretty) { + objectMapper.writerWithDefaultPrettyPrinter().writeValue(out, response); + } else { + objectMapper.writeValue(out, response); + } + } + } catch (java.io.IOException e) { + throw new CommandLine.ExecutionException(spec.commandLine(), "JSON serialization failed: " + e.getMessage(), e); + } + } else { + out.println(searchResultFormatter.format(response, quiet, useColor)); + } + out.flush(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + String message = cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName(); + throw new CommandLine.ExecutionException(spec.commandLine(), "Search failed: " + message, e); + } + } + + private void validateLanguages(List languages) { + for (String lang : languages) { + String normalized = lang == null ? "" : lang.trim().toLowerCase(); + if (normalized.isEmpty()) { + continue; + } + if (!SUPPORTED_LANGUAGES.contains(normalized)) { + throw new CommandLine.ParameterException( + spec.commandLine(), + "Invalid --language '" + lang + "'. Allowed: " + String.join(", ", SUPPORTED_LANGUAGES) + "." + ); + } + } + } + + private void validateTypes(List types) { + for (String t : types) { + String normalized = t == null ? "" : t.trim().toLowerCase(); + if (normalized.isEmpty()) { + continue; + } + if (!SUPPORTED_ENTITY_TYPES.contains(normalized)) { + throw new CommandLine.ParameterException( + spec.commandLine(), + "Invalid --type '" + t + "'. Allowed: " + String.join(", ", SUPPORTED_ENTITY_TYPES) + "." + ); + } + } + } + + private void validateLimit() { + if (limit < 1 || limit > 100) { + throw new CommandLine.ParameterException( + spec.commandLine(), + "Invalid --limit " + limit + ". Allowed range: 1-100." + ); + } + } + + private SearchRequest buildSearchRequest(String trimmedQuery, List languages, List repos, List types) { + SearchRequest req = new SearchRequest(trimmedQuery); + for (String lang : languages) { + if (lang != null && !lang.isBlank()) { + req.addLanguage(lang.trim().toLowerCase()); + } + } + for (String r : repos) { + if (r != null && !r.isBlank()) { + req.addRepository(r.trim()); + } + } + for (String t : types) { + if (t != null && !t.isBlank()) { + req.addEntityType(t.trim().toLowerCase()); + } + } + req.setLimit(limit); + return req; + } + + /** + * Resolves whether to use color (syntax highlighting) in output. + * Color is disabled when: --no-color is set, or NO_COLOR env is set, or output is not a TTY. + */ + private boolean resolveUseColor() { + if (noColor) { + return false; + } + if (System.getenv("NO_COLOR") != null && !System.getenv("NO_COLOR").isBlank()) { + return false; + } + return System.console() != null; + } +} diff --git a/backend/src/main/java/io/megabrain/cli/SearchResultFormatter.java b/backend/src/main/java/io/megabrain/cli/SearchResultFormatter.java new file mode 100644 index 0000000..d6ddd7f --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/SearchResultFormatter.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import io.megabrain.api.SearchResponse; + +/** + * Formats search results for terminal or other text output. + * Implementations produce human-readable or minimal (quiet) layout. + */ +public interface SearchResultFormatter { + + /** + * Formats the full search response (default layout). + * + * @param response the search response to format + * @return formatted string for terminal output + */ + String format(SearchResponse response); + + /** + * Formats the search response, optionally in quiet (minimal) mode and with optional color. + * + * @param response the search response to format + * @param quiet when true, output is minimal (e.g. one line per result: path + entity) + * @param useColor when true and implementation supports it, code snippets may be syntax-highlighted (ANSI) + * @return formatted string for terminal output + */ + String format(SearchResponse response, boolean quiet, boolean useColor); + + /** + * Formats the search response, optionally in quiet (minimal) mode. + * Default implementation delegates to {@link #format(SearchResponse, boolean, boolean)} with useColor true. + * + * @param response the search response to format + * @param quiet when true, output is minimal (e.g. one line per result: path + entity) + * @return formatted string for terminal output + */ + default String format(SearchResponse response, boolean quiet) { + return format(response, quiet, true); + } + + /** + * Minimal format: one line per result (path + entity), pipe-friendly. + * + * @param response the search response + * @return minimal formatted string + */ + String formatQuiet(SearchResponse response); +} diff --git a/backend/src/main/java/io/megabrain/cli/SyntaxHighlighter.java b/backend/src/main/java/io/megabrain/cli/SyntaxHighlighter.java new file mode 100644 index 0000000..31b4996 --- /dev/null +++ b/backend/src/main/java/io/megabrain/cli/SyntaxHighlighter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +/** + * Highlights code snippets with ANSI color codes for terminal output. + * Implementations may be keyword/pattern-based; unknown or blank language returns content unchanged. + */ +public interface SyntaxHighlighter { + + /** + * Optionally highlights the given content for the specified language. + * + * @param content the code snippet to highlight (may be empty) + * @param language the programming language (e.g. java, python); null or blank → return content unchanged + * @param useColor when false, returns content unchanged; when true, may wrap tokens in ANSI codes + * @return content with ANSI codes when useColor is true and language is supported, else content unchanged + */ + String highlight(String content, String language, boolean useColor); +} diff --git a/backend/src/test/java/io/megabrain/cli/CliSyntaxHighlighterTest.java b/backend/src/test/java/io/megabrain/cli/CliSyntaxHighlighterTest.java new file mode 100644 index 0000000..e7fcf0a --- /dev/null +++ b/backend/src/test/java/io/megabrain/cli/CliSyntaxHighlighterTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for CliSyntaxHighlighter (US-04-05 T4). + */ +class CliSyntaxHighlighterTest { + + private final CliSyntaxHighlighter highlighter = new CliSyntaxHighlighter(); + + private static final String ANSI_ESCAPE = "\u001B["; + + @Test + @DisplayName("color on produces output containing ANSI escape") + void highlight_useColorTrue_containsAnsi() { + String code = "public class Foo { }"; + String out = highlighter.highlight(code, "java", true); + assertThat(out).contains(ANSI_ESCAPE); + } + + @Test + @DisplayName("color off returns content unchanged") + void highlight_useColorFalse_unchanged() { + String code = "public class Foo { }"; + String out = highlighter.highlight(code, "java", false); + assertThat(out).isEqualTo(code); + assertThat(out).doesNotContain(ANSI_ESCAPE); + } + + @Test + @DisplayName("Java snippet with keyword gets highlighting") + void highlight_javaKeyword_containsAnsi() { + String code = "public void run() { return 42; }"; + String out = highlighter.highlight(code, "java", true); + assertThat(out).contains(ANSI_ESCAPE); + assertThat(out).contains("public"); + assertThat(out).contains("return"); + } + + @Test + @DisplayName("Python snippet with keyword gets highlighting") + void highlight_pythonKeyword_containsAnsi() { + String code = "def hello(): return None"; + String out = highlighter.highlight(code, "python", true); + assertThat(out).contains(ANSI_ESCAPE); + assertThat(out).contains("def"); + assertThat(out).contains("return"); + } + + @Test + @DisplayName("JavaScript snippet gets highlighting") + void highlight_javascript_containsAnsi() { + String code = "const x = 1; function f() {}"; + String out = highlighter.highlight(code, "javascript", true); + assertThat(out).contains(ANSI_ESCAPE); + } + + @Test + @DisplayName("unknown language returns content unchanged") + void highlight_unknownLanguage_unchanged() { + String code = "public class Foo { }"; + String out = highlighter.highlight(code, "haskell", true); + assertThat(out).isEqualTo(code); + assertThat(out).doesNotContain(ANSI_ESCAPE); + } + + @Test + @DisplayName("null language returns content unchanged") + void highlight_nullLanguage_unchanged() { + String code = "public class Foo { }"; + String out = highlighter.highlight(code, null, true); + assertThat(out).isEqualTo(code); + assertThat(out).doesNotContain(ANSI_ESCAPE); + } + + @Test + @DisplayName("blank language returns content unchanged") + void highlight_blankLanguage_unchanged() { + String code = "public class Foo { }"; + String out = highlighter.highlight(code, " ", true); + assertThat(out).isEqualTo(code); + assertThat(out).doesNotContain(ANSI_ESCAPE); + } + + @Test + @DisplayName("empty snippet returns empty string") + void highlight_emptySnippet_returnsEmpty() { + String out = highlighter.highlight("", "java", true); + assertThat(out).isEmpty(); + } + + @Test + @DisplayName("null content returns empty string") + void highlight_nullContent_returnsEmpty() { + String out = highlighter.highlight(null, "java", true); + assertThat(out).isEmpty(); + } +} diff --git a/backend/src/test/java/io/megabrain/cli/IngestCommandTest.java b/backend/src/test/java/io/megabrain/cli/IngestCommandTest.java new file mode 100644 index 0000000..f1208c7 --- /dev/null +++ b/backend/src/test/java/io/megabrain/cli/IngestCommandTest.java @@ -0,0 +1,539 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import io.megabrain.ingestion.IngestionService; +import io.megabrain.ingestion.ProgressEvent; +import io.smallrye.mutiny.Multi; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import picocli.CommandLine; + +import java.io.ByteArrayOutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for IngestCommand (US-04-04 T1–T4). + */ +class IngestCommandTest { + + private static IngestionService mockIngestionServiceCompleting() { + IngestionService service = mock(IngestionService.class); + when(service.ingestRepository(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of("Done", 100.0))); + when(service.ingestRepositoryIncrementally(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of("Incremental done", 100.0))); + return service; + } + + private static CommandLine createCommandLineForExitCodeTests(IngestionService ingestionService) { + return new CommandLine(new IngestCommand(ingestionService)); + } + + @Test + @DisplayName("command name is ingest") + void commandSpec_name_isIngest() { + CommandLine cmd = new CommandLine(new IngestCommand(mockIngestionServiceCompleting())); + assertThat(cmd.getCommandSpec().name()).isEqualTo("ingest"); + } + + @Test + @DisplayName("--help prints usage containing ingest and option descriptions") + void execute_help_printsUsageWithIngestAndOptions() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("--help"); + + out.flush(); + err.flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isEqualTo(0); + assertThat(output).contains("ingest"); + assertThat(output).contains("--help"); + assertThat(output).contains("Ingest a repository"); + assertThat(output).contains("--source"); + assertThat(output).contains("--repo"); + assertThat(output).contains("--branch"); + assertThat(output).contains("--token"); + assertThat(output).contains("--incremental"); + assertThat(output).contains("--verbose"); + } + + @Test + @DisplayName("default branch when --branch omitted") + void execute_sourceAndRepoOnly_defaultBranchIsMain() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--source", "github", "--repo", "owner/repo"); + + assertThat(command.branch).isEqualTo("main"); + } + + @Test + @DisplayName("default incremental false when omitted") + void execute_incrementalOmitted_defaultIsFalse() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--source", "github", "--repo", "owner/repo"); + + assertThat(command.incremental).isFalse(); + } + + @Test + @DisplayName("explicit branch and incremental parsed") + void execute_explicitBranchAndIncremental_parsedCorrectly() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--source", "gitlab", "--repo", "gitlab.com/group/proj", "--branch", "develop", "--incremental"); + + assertThat(command.branch).isEqualTo("develop"); + assertThat(command.incremental).isTrue(); + assertThat(command.source).isEqualTo("gitlab"); + assertThat(command.repo).isEqualTo("gitlab.com/group/proj"); + } + + @ParameterizedTest + @ValueSource(strings = { "github", "GITHUB", "gitlab", "GITLAB", "bitbucket", "BITBUCKET", "local", "LOCAL" }) + @DisplayName("valid source for each enum value parses and run does not throw") + void execute_validSource_parsesAndRunDoesNotThrow(String sourceValue) { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", sourceValue, "--repo", "some/repo"); + + assertThat(exitCode).isEqualTo(0); + } + + @Test + @DisplayName("invalid source returns exit code 2") + void execute_invalidSource_failsWithClearMessage() { + CommandLine cmd = createCommandLineForExitCodeTests(mockIngestionServiceCompleting()); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("--source", "invalid", "--repo", "owner/repo"); + + out.flush(); + err.flush(); + assertThat(exitCode).isEqualTo(2); + String errOutput = new String(errBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(errOutput).contains("Invalid source"); + assertThat(errOutput).containsIgnoringCase("allowed"); + assertThat(errOutput).contains("github"); + assertThat(errOutput).contains("gitlab"); + assertThat(errOutput).contains("bitbucket"); + assertThat(errOutput).contains("local"); + } + + @Test + @DisplayName("token optional and with value parses") + void execute_tokenOptional_withValue_parses() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo", "--token", "secret-token"); + + assertThat(exitCode).isEqualTo(0); + assertThat(command.token).isEqualTo("secret-token"); + } + + @Test + @DisplayName("exit code 0 on success") + void execute_success_returnsExitCodeZero() { + CommandLine cmd = createCommandLineForExitCodeTests(mockIngestionServiceCompleting()); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo"); + + assertThat(exitCode).isEqualTo(0); + } + + @Test + @DisplayName("missing --repo fails with clear message") + void execute_missingRepo_failsWithClearMessage() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(err); + + int exitCode = cmd.execute("--source", "github"); + + err.flush(); + assertThat(exitCode).isNotEqualTo(0); + String errOutput = new String(errBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(errOutput).contains("--repo"); + } + + @Test + @DisplayName("blank --repo fails with clear message") + void execute_blankRepo_failsWithClearMessage() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(err); + + int exitCode = cmd.execute("--source", "github", "--repo", " "); + + err.flush(); + assertThat(exitCode).isNotEqualTo(0); + String errOutput = new String(errBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(errOutput).contains("non-blank"); + } + + @Test + @DisplayName("full ingest prints progress events and completes") + void execute_fullIngest_printsProgressAndCompletes() { + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn(Multi.createFrom().items( + ProgressEvent.of("Cloning", 10.0), + ProgressEvent.of("Parsing", 50.0), + ProgressEvent.of("Done", 100.0))); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().empty()); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo"); + + out.flush(); + err.flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isEqualTo(0); + assertThat(output).contains("Cloning"); + assertThat(output).contains("Parsing"); + assertThat(output).contains("Done"); + assertThat(output).contains("10.0"); + assertThat(output).contains("50.0"); + assertThat(output).contains("100.0"); + verify(mockService).ingestRepository("owner/repo"); + verify(mockService, never()).ingestRepositoryIncrementally(anyString()); + } + + @Test + @DisplayName("incremental ingest prints progress and calls incremental only") + void execute_incrementalIngest_printsProgressAndCallsIncrementalOnly() { + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn(Multi.createFrom().empty()); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().items( + ProgressEvent.of("Cloning", 10.0), + ProgressEvent.of("Parsing", 50.0), + ProgressEvent.of("Done", 100.0))); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo", "--incremental"); + + out.flush(); + err.flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isEqualTo(0); + assertThat(output).contains("Cloning"); + assertThat(output).contains("Parsing"); + assertThat(output).contains("Done"); + assertThat(output).contains("10"); + assertThat(output).contains("50"); + assertThat(output).contains("100"); + verify(mockService, never()).ingestRepository(anyString()); + verify(mockService).ingestRepositoryIncrementally("owner/repo"); + } + + @Test + @DisplayName("ingestion failure returns exit code 1") + void execute_streamFailure_exitsNonZeroAndShowsError() { + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn( + Multi.createFrom().failure(new RuntimeException("Clone failed"))); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().empty()); + + CommandLine cmd = createCommandLineForExitCodeTests(mockService); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo"); + + err.flush(); + assertThat(exitCode).isEqualTo(1); + String errOutput = new String(errBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(errOutput).contains("Ingestion failed"); + } + + @Test + @DisplayName("--verbose parses and sets verbose true") + void execute_withVerbose_setsVerboseTrue() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--source", "github", "--repo", "owner/repo", "--verbose"); + + assertThat(command.verbose).isTrue(); + } + + @Test + @DisplayName("verbose mode prints full progress message (no truncation)") + void execute_verbose_longProgressLine_notTruncated() { + String longMessage = "Cloning " + "x".repeat(300) + " done"; + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of(longMessage, 100.0))); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().empty()); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo", "--verbose"); + + cmd.getOut().flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isEqualTo(0); + assertThat(output).contains("x".repeat(300)); + } + + // --- Council-recommended tests (T6) --- + + @Test + @DisplayName("token never appears in stdout or stderr") + void execute_withToken_secretNeverInOutput() { + IngestionService mockService = mockIngestionServiceCompleting(); + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + cmd.execute("--source", "github", "--repo", "owner/repo", "--token", "secret"); + + out.flush(); + err.flush(); + String stdout = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + String stderr = new String(errBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(stdout).doesNotContain("secret"); + assertThat(stderr).doesNotContain("secret"); + } + + @Test + @DisplayName("--repo value is trimmed before calling ingestion service") + void execute_repoWithSpaces_trimmedWhenCallingService() { + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of("Done", 100.0))); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().empty()); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--source", "github", "--repo", " owner/repo "); + + verify(mockService).ingestRepository("owner/repo"); + verify(mockService, never()).ingestRepositoryIncrementally(anyString()); + } + + @Test + @DisplayName("incremental with trimmed repo calls ingestRepositoryIncrementally with trimmed value") + void execute_incremental_repoTrimmed() { + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn(Multi.createFrom().empty()); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of("Done", 100.0))); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--source", "github", "--repo", " owner/repo ", "--incremental"); + + verify(mockService, never()).ingestRepository(anyString()); + verify(mockService).ingestRepositoryIncrementally("owner/repo"); + } + + @Test + @DisplayName("Picocli exit-code contract: invalid input 2, execution exception 1") + void commandSpec_exitCodeContract() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + new CommandLine(command); + assertThat(command.spec.exitCodeOnInvalidInput()).isEqualTo(2); + assertThat(command.spec.exitCodeOnExecutionException()).isEqualTo(1); + } + + @Test + @DisplayName("help contains branch default (main)") + void execute_help_containsBranchDefault() { + IngestCommand command = new IngestCommand(mockIngestionServiceCompleting()); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--help"); + cmd.getOut().flush(); + + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(output).containsIgnoringCase("default"); + assertThat(output).contains("main"); + } + + @Test + @DisplayName("non-verbose long message is truncated with ellipsis and percentage") + void execute_nonVerbose_longMessage_truncatedWithPercent() { + String longMessage = "A".repeat(250); + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of(longMessage, 75.5))); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().empty()); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo"); + + cmd.getOut().flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isEqualTo(0); + assertThat(output).contains("A".repeat(200) + "..."); + assertThat(output).contains("75.5"); + } + + @Test + @DisplayName("null progress message does not NPE and shows percentage") + void execute_nullProgressMessage_noNPE_andShowsProgress() { + IngestionService mockService = mock(IngestionService.class); + when(mockService.ingestRepository(anyString())).thenReturn( + Multi.createFrom().items(ProgressEvent.of(null, 50.0))); + when(mockService.ingestRepositoryIncrementally(anyString())).thenReturn(Multi.createFrom().empty()); + + IngestCommand command = new IngestCommand(mockService); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", "github", "--repo", "owner/repo"); + + cmd.getOut().flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isEqualTo(0); + assertThat(output).contains("50.0"); + } + + @Test + @DisplayName("missing --repo with --source github returns exit code 2") + void execute_missingRepo_onlySource_returnsExitCode2() { + CommandLine cmd = createCommandLineForExitCodeTests(mockIngestionServiceCompleting()); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8))); + + int exitCode = cmd.execute("--source", "github"); + + cmd.getErr().flush(); + assertThat(exitCode).isEqualTo(2); + } + + @Test + @DisplayName("MegaBrainCommand --help contains megabrain and ingest") + void megaBrainCommand_help_containsMegabrainAndIngest() { + CommandLine.IFactory factory = new CommandLine.IFactory() { + @Override + @SuppressWarnings("unchecked") + public K create(Class cls) throws Exception { + if (cls == IngestCommand.class) { + return (K) new IngestCommand(mockIngestionServiceCompleting()); + } + return cls.getDeclaredConstructor().newInstance(); + } + }; + CommandLine cmd = new CommandLine(new MegaBrainCommand(), factory); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.UTF_8))); + + cmd.execute("--help"); + cmd.getOut().flush(); + + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(output).containsIgnoringCase("megabrain"); + assertThat(output).containsIgnoringCase("ingest"); + } +} diff --git a/backend/src/test/java/io/megabrain/cli/SearchCommandTest.java b/backend/src/test/java/io/megabrain/cli/SearchCommandTest.java new file mode 100644 index 0000000..7ef4de6 --- /dev/null +++ b/backend/src/test/java/io/megabrain/cli/SearchCommandTest.java @@ -0,0 +1,732 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import picocli.CommandLine; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import uk.org.webcompere.systemstubs.jupiter.SystemStub; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.megabrain.core.ResultMerger; +import io.megabrain.core.SearchOrchestrator; +import io.smallrye.mutiny.Uni; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.StringField; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +/** + * Unit tests for SearchCommand (US-04-05 T1–T6). + */ +@ExtendWith(SystemStubsExtension.class) +class SearchCommandTest { + + @SystemStub + private EnvironmentVariables environmentVariables; + + private static final java.nio.charset.Charset UTF8 = StandardCharsets.UTF_8; + private static final ObjectMapper JSON = new ObjectMapper(); + + private static String runAndGetOut(CommandLine cmd, String... args) { + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + cmd.execute(args); + cmd.getOut().flush(); + cmd.getErr().flush(); + return new String(outBa.toByteArray(), UTF8); + } + + @Test + @DisplayName("command name is search") + void commandSpec_name_isSearch() { + CommandLine cmd = new CommandLine(new SearchCommand()); + assertThat(cmd.getCommandSpec().name()).isEqualTo("search"); + } + + @Test + @DisplayName("--help prints usage containing search and description") + void execute_help_printsUsageWithSearchAndDescription() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("--help"); + + out.flush(); + err.flush(); + String output = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(exitCode).isZero(); + assertThat(output) + .contains("search") + .contains("--help") + .contains("Search the MegaBrain index"); + } + + @Test + @DisplayName("execute with one query arg parses and runs") + void execute_withQueryArg_parsesAndRuns() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + PrintWriter out = new PrintWriter(new java.io.OutputStreamWriter(outBa, StandardCharsets.UTF_8)); + PrintWriter err = new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8)); + cmd.setOut(out); + cmd.setErr(err); + + int exitCode = cmd.execute("hello world"); + + out.flush(); + err.flush(); + assertThat(exitCode).isZero(); + assertThat(command.query).isEqualTo("hello world"); + String stdout = new String(outBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(stdout).contains("Query received: hello world"); + } + + @Test + @DisplayName("blank query returns exit code 2 and error message") + void execute_blankQuery_returnsExitCode2AndErrorMessage() { + CommandLine cmd = new CommandLine(new SearchCommand()); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, StandardCharsets.UTF_8))); + + int exitCode = cmd.execute(" "); + + cmd.getErr().flush(); + assertThat(exitCode).isEqualTo(2); + String errOutput = new String(errBa.toByteArray(), StandardCharsets.UTF_8); + assertThat(errOutput).containsIgnoringCase("non-blank"); + } + + // ---------- T2: filter options, validation, defaults ---------- + + @Test + @DisplayName("defaults when only query: limit 10, json and quiet false, lists empty") + void execute_onlyQuery_setsDefaults() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + runAndGetOut(cmd, "foo"); + + assertThat(command.limit).isEqualTo(10); + assertThat(command.json).isFalse(); + assertThat(command.quiet).isFalse(); + assertThat(command.language).isNull(); + assertThat(command.repo).isNull(); + assertThat(command.type).isNull(); + assertThat(command.getSearchRequest()).isNotNull(); + assertThat(command.getSearchRequest().getLimit()).isEqualTo(10); + assertThat(command.getSearchRequest().getLanguages()).isEmpty(); + assertThat(command.getSearchRequest().getRepositories()).isEmpty(); + assertThat(command.getSearchRequest().getEntityTypes()).isEmpty(); + } + + @Test + @DisplayName("each option parsed when passed") + void execute_withOptions_parsesAllOptionValues() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + runAndGetOut(cmd, "q", "--language", "java", "--language", "python", "--repo", "r1", "--repo", "r2", + "--type", "class", "--type", "method", "--limit", "5", "--json", "--quiet"); + + assertThat(command.query).isEqualTo("q"); + assertThat(command.language).containsExactly("java", "python"); + assertThat(command.repo).containsExactly("r1", "r2"); + assertThat(command.type).containsExactly("class", "method"); + assertThat(command.limit).isEqualTo(5); + assertThat(command.json).isTrue(); + assertThat(command.quiet).isTrue(); + assertThat(command.getSearchRequest().getLanguages()).containsExactly("java", "python"); + assertThat(command.getSearchRequest().getRepositories()).containsExactly("r1", "r2"); + assertThat(command.getSearchRequest().getEntityTypes()).containsExactly("class", "method"); + assertThat(command.getSearchRequest().getLimit()).isEqualTo(5); + } + + @Test + @DisplayName("multi-value --language, --repo, --type") + void execute_multiValueFilters_buildsRequestWithAllValues() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + runAndGetOut(cmd, "x", "--language", "go", "--language", "rust", "--repo", "a/b", "--repo", "c/d", + "--type", "function", "--type", "interface"); + + assertThat(command.getSearchRequest().getLanguages()).containsExactlyInAnyOrder("go", "rust"); + assertThat(command.getSearchRequest().getRepositories()).containsExactlyInAnyOrder("a/b", "c/d"); + assertThat(command.getSearchRequest().getEntityTypes()).containsExactlyInAnyOrder("function", "interface"); + } + + @Test + @DisplayName("valid --language and --type exit 0") + void execute_validLanguageAndType_exitZero() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + int exit = cmd.execute("query", "--language", "java", "--type", "class"); + assertThat(exit).isZero(); + } + + @Test + @DisplayName("invalid --language exit 2 and stderr contains validation message") + void execute_invalidLanguage_exit2AndStderrContainsAllowedValues() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + int exit = cmd.execute("q", "--language", "haskell"); + cmd.getErr().flush(); + assertThat(exit).isEqualTo(2); + String err = new String(errBa.toByteArray(), UTF8); + assertThat(err) + .containsIgnoringCase("invalid") + .contains("--language") + .contains("haskell") + .contains("Allowed:"); + } + + @Test + @DisplayName("invalid --type exit 2 and stderr contains validation message") + void execute_invalidType_exit2AndStderrContainsAllowedValues() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + int exit = cmd.execute("q", "--type", "unknown"); + cmd.getErr().flush(); + assertThat(exit).isEqualTo(2); + String err = new String(errBa.toByteArray(), UTF8); + assertThat(err) + .containsIgnoringCase("invalid") + .contains("--type") + .contains("unknown") + .contains("Allowed:"); + } + + @Test + @DisplayName("--help contains all option names") + void execute_help_containsAllOptionNames() { + String out = runAndGetOut(new CommandLine(new SearchCommand()), "--help"); + assertThat(out) + .contains("--language") + .contains("--repo") + .contains("--type") + .contains("--limit") + .contains("--json") + .contains("--quiet") + .contains("--no-color"); + } + + @Test + @DisplayName("--limit 1 and --limit 100 are valid") + void execute_limit1And100_exitZero() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + assertThat(cmd.execute("q", "--limit", "1")).isZero(); + assertThat(cmd.execute("q", "--limit", "100")).isZero(); + } + + @Test + @DisplayName("--limit 0 or out of range exit 2") + void execute_limit0OrOutOfRange_exit2() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + assertThat(cmd.execute("q", "--limit", "0")).isEqualTo(2); + cmd.getErr().flush(); + assertThat(new String(errBa.toByteArray(), UTF8)).contains("limit").contains("1-100"); + + cmd = new CommandLine(new SearchCommand()); + assertThat(cmd.execute("q", "--limit", "-1")).isEqualTo(2); + cmd = new CommandLine(new SearchCommand()); + assertThat(cmd.execute("q", "--limit", "101")).isEqualTo(2); + } + + @Test + @DisplayName("missing query exit 2") + void execute_noQuery_exit2() { + CommandLine cmd = new CommandLine(new SearchCommand()); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new ByteArrayOutputStream())); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + int exit = cmd.execute("--limit", "5"); + cmd.getErr().flush(); + assertThat(exit).isEqualTo(2); + assertThat(new String(errBa.toByteArray(), UTF8)).containsIgnoringCase("query").containsIgnoringCase("required"); + } + + // ---------- T3: result formatting, orchestrator integration ---------- + + @Test + @DisplayName("when not --json stdout contains formatted result") + void execute_withQueryAndMockOrchestrator_stdoutContainsFormattedResult() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + + int exitCode = cmd.execute("foo"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8); + assertThat(stdout).contains("File: Test0.java"); + assertThat(stdout).contains("Entity: TestEntity0"); + assertThat(stdout).contains("Score:"); + assertThat(stdout).contains("Test content 0"); + assertThat(stdout).contains("---"); + } + + @Test + @DisplayName("empty results print No results.") + void execute_emptyResults_printsNoResults() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(List.of(), Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + int exitCode = cmd.execute("query"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8); + assertThat(stdout).contains("No results."); + } + + @Test + @DisplayName("--json full output is valid JSON with results, total, page, size, query, took_ms, facets") + void execute_json_fullOutput_validJsonWithApiFields() throws Exception { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + int exitCode = cmd.execute("foo", "--json"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8).trim(); + JsonNode root = JSON.readTree(stdout); + assertThat(root.has("results")).isTrue(); + assertThat(root.has("total")).isTrue(); + assertThat(root.has("page")).isTrue(); + assertThat(root.has("size")).isTrue(); + assertThat(root.has("query")).isTrue(); + assertThat(root.has("took_ms")).isTrue(); + assertThat(root.has("facets")).isTrue(); + assertThat(root.get("results").isArray()).isTrue(); + assertThat(root.get("results").size()).isEqualTo(1); + JsonNode first = root.get("results").get(0); + assertThat(first.has("source_file")).isTrue(); + assertThat(first.has("entity_name")).isTrue(); + assertThat(first.has("score")).isTrue(); + } + + @Test + @DisplayName("--json --quiet output is JSON array of results") + void execute_jsonQuiet_outputIsResultsArray() throws Exception { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(2); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + int exitCode = cmd.execute("foo", "--json", "--quiet"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8).trim(); + JsonNode arr = JSON.readTree(stdout); + assertThat(arr.isArray()).isTrue(); + assertThat(arr.size()).isEqualTo(2); + assertThat(arr.get(0).has("source_file")).isTrue(); + assertThat(arr.get(0).has("entity_name")).isTrue(); + } + + @Test + @DisplayName("--json empty results: full JSON has results=[], total=0") + void execute_jsonEmptyResults_fullJsonHasEmptyResultsAndTotalZero() throws Exception { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(List.of(), Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + int exitCode = cmd.execute("query", "--json"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8).trim(); + JsonNode root = JSON.readTree(stdout); + assertThat(root.get("results").isArray()).isTrue(); + assertThat(root.get("results").size()).isZero(); + assertThat(root.get("total").asLong()).isZero(); + } + + @Test + @DisplayName("--json --quiet empty results: output is empty array") + void execute_jsonQuietEmptyResults_outputIsEmptyArray() throws Exception { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(List.of(), Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + int exitCode = cmd.execute("query", "--json", "--quiet"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8).trim(); + JsonNode arr = JSON.readTree(stdout); + assertThat(arr.isArray()).isTrue(); + assertThat(arr.size()).isZero(); + } + + @Test + @DisplayName("--no-color is parsed and useColor false passed to formatter") + void execute_noColor_passesUseColorFalseToFormatter() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + CaptureUseColorFormatter captureFormatter = new CaptureUseColorFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, captureFormatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + cmd.execute("foo", "--no-color"); + + cmd.getOut().flush(); + assertThat(captureFormatter.lastUseColor).isFalse(); + } + + @Test + @DisplayName("output with --no-color contains no ANSI escape") + void execute_noColor_stdoutHasNoAnsi() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(new CliSyntaxHighlighter()); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + cmd.execute("foo", "--no-color"); + + cmd.getOut().flush(); + String stdout = new String(outBa.toByteArray(), UTF8); + assertThat(stdout).doesNotContain("\u001B["); + } + + // ---------- T6: council-recommended and coverage tests ---------- + + @Test + @DisplayName("orchestrator failure returns exit 1 and stderr contains Search failed or cause message") + void execute_orchestratorFailure_exit1AndStderrContainsSearchFailed() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().failure(new RuntimeException("orchestrator error"))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + + int exitCode = cmd.execute("foo"); + + cmd.getErr().flush(); + assertThat(exitCode).isEqualTo(1); + String stderr = new String(errBa.toByteArray(), UTF8); + assertThat(stderr).satisfiesAnyOf( + s -> assertThat(s).contains("Search failed"), + s -> assertThat(s).contains("orchestrator error") + ); + } + + @Test + @DisplayName("--json with null ObjectMapper returns exit 1 and message about JSON requiring ObjectMapper") + void execute_jsonWithNullObjectMapper_exit1AndMessageAboutObjectMapper() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, null, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + + int exitCode = cmd.execute("foo", "--json"); + + cmd.getErr().flush(); + assertThat(exitCode).isEqualTo(1); + String stderr = new String(errBa.toByteArray(), UTF8); + assertThat(stderr).contains("JSON").contains("ObjectMapper"); + } + + @Test + @DisplayName("JSON serialization failure returns exit 1 and message about JSON serialization failed") + void execute_jsonSerializationFailure_exit1AndMessageAboutSerialization() throws Exception { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + ObjectMapper failingMapper = spy(new ObjectMapper()); + doThrow(new IOException("mock io")).when(failingMapper).writeValue(any(java.io.Writer.class), any()); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, failingMapper, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + ByteArrayOutputStream errBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new java.io.OutputStreamWriter(errBa, UTF8))); + + int exitCode = cmd.execute("foo", "--json"); + + cmd.getErr().flush(); + assertThat(exitCode).isEqualTo(1); + String stderr = new String(errBa.toByteArray(), UTF8); + assertThat(stderr).contains("JSON serialization failed").contains("mock io"); + } + + @Test + @DisplayName("NO_COLOR env without --no-color passes useColor false to formatter") + void execute_noColorEnvWithoutNoColorFlag_useColorFalse() { + environmentVariables.set("NO_COLOR", "1"); + + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + CaptureUseColorFormatter captureFormatter = new CaptureUseColorFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, captureFormatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + cmd.execute("foo"); + + cmd.getOut().flush(); + assertThat(captureFormatter.lastUseColor).isFalse(); + } + + @Test + @DisplayName("--language with blank value is skipped in request") + void execute_languageBlankValue_skippedInRequest() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + runAndGetOut(cmd, "q", "--language", " "); + + assertThat(command.getSearchRequest().getLanguages()).isEmpty(); + } + + @Test + @DisplayName("--language JAVA normalized to java in request") + void execute_languageUppercase_normalizedToLowercase() { + SearchCommand command = new SearchCommand(); + CommandLine cmd = new CommandLine(command); + runAndGetOut(cmd, "q", "--language", "JAVA"); + + assertThat(command.getSearchRequest().getLanguages()).containsExactly("java"); + } + + @Test + @DisplayName("--quiet human-readable: formatter called with quiet true, one line per result") + void execute_quietHumanReadable_formatterQuietTrueOneLinePerResult() { + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(2); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, Map.of()))); + + CaptureUseColorFormatter captureFormatter = new CaptureUseColorFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, captureFormatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + cmd.execute("foo", "--quiet"); + + cmd.getOut().flush(); + assertThat(captureFormatter.lastQuiet).isTrue(); + String stdout = new String(outBa.toByteArray(), UTF8); + assertThat(stdout).contains("Test0.java\tTestEntity0"); + assertThat(stdout).contains("Test1.java\tTestEntity1"); + assertThat(stdout).doesNotContain("---"); + } + + @Test + @DisplayName("--json with non-empty facets outputs facets key in JSON") + void execute_jsonWithFacets_outputHasFacetsKey() throws Exception { + Map> facets = Map.of("language", List.of( + new io.megabrain.core.FacetValue("java", 5), + new io.megabrain.core.FacetValue("python", 2) + )); + SearchOrchestrator mockOrchestrator = mock(SearchOrchestrator.class); + List merged = createMockMergedResults(1); + when(mockOrchestrator.orchestrate(any(), eq(io.megabrain.core.SearchMode.HYBRID), anyInt(), anyInt())) + .thenReturn(Uni.createFrom().item(new SearchOrchestrator.OrchestratorResult(merged, facets))); + + SearchResultFormatter formatter = new HumanReadableSearchResultFormatter(); + SearchCommand command = new SearchCommand(mockOrchestrator, formatter, JSON, 10, 5, 10); + CommandLine cmd = new CommandLine(command); + ByteArrayOutputStream outBa = new ByteArrayOutputStream(); + cmd.setOut(new PrintWriter(new java.io.OutputStreamWriter(outBa, UTF8))); + cmd.setErr(new PrintWriter(new ByteArrayOutputStream())); + + int exitCode = cmd.execute("foo", "--json"); + + cmd.getOut().flush(); + assertThat(exitCode).isZero(); + String stdout = new String(outBa.toByteArray(), UTF8).trim(); + JsonNode root = JSON.readTree(stdout); + assertThat(root.has("facets")).isTrue(); + assertThat(root.get("facets").has("language")).isTrue(); + assertThat(root.get("facets").get("language").isArray()).isTrue(); + assertThat(root.get("facets").get("language").size()).isEqualTo(2); + } + + /** Formatter that records the last useColor argument for testing. */ + private static final class CaptureUseColorFormatter implements SearchResultFormatter { + Boolean lastUseColor = null; + Boolean lastQuiet = null; + + @Override + public String format(io.megabrain.api.SearchResponse response) { + return format(response, false, true); + } + + @Override + public String format(io.megabrain.api.SearchResponse response, boolean quiet, boolean useColor) { + this.lastUseColor = useColor; + this.lastQuiet = quiet; + if (quiet) { + return formatQuiet(response); + } + if (response == null || response.getResults() == null || response.getResults().isEmpty()) { + return "No results."; + } + StringBuilder sb = new StringBuilder(); + for (io.megabrain.api.SearchResult r : response.getResults()) { + sb.append(r.getSourceFile()).append("\n"); + } + return sb.toString(); + } + + @Override + public String formatQuiet(io.megabrain.api.SearchResponse response) { + if (response == null || response.getResults() == null || response.getResults().isEmpty()) { + return "No results."; + } + StringBuilder sb = new StringBuilder(); + for (io.megabrain.api.SearchResult r : response.getResults()) { + sb.append(r.getSourceFile()).append("\t").append(r.getEntityName()).append("\n"); + } + return sb.toString(); + } + } + + private static List createMockMergedResults(int count) { + List results = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + Document doc = new Document(); + doc.add(new StringField("content", "Test content " + i, Field.Store.YES)); + doc.add(new StringField("entity_name", "TestEntity" + i, Field.Store.YES)); + doc.add(new StringField("entity_type", "class", Field.Store.YES)); + doc.add(new StringField("source_file", "Test" + i + ".java", Field.Store.YES)); + doc.add(new StringField("language", "java", Field.Store.YES)); + doc.add(new StringField("repository", "test-repo", Field.Store.YES)); + doc.add(new StringField("start_line", "1", Field.Store.YES)); + doc.add(new StringField("end_line", "10", Field.Store.YES)); + String chunkId = "Test" + i + ".java:TestEntity" + i; + results.add(ResultMerger.MergedResult.fromLucene(chunkId, doc, 0.8)); + } + return results; + } +} diff --git a/backend/src/test/java/io/megabrain/cli/SearchResultFormatterTest.java b/backend/src/test/java/io/megabrain/cli/SearchResultFormatterTest.java new file mode 100644 index 0000000..e975969 --- /dev/null +++ b/backend/src/test/java/io/megabrain/cli/SearchResultFormatterTest.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 MegaBrain Contributors + * Licensed under the MIT License - see LICENSE file for details. + */ + +package io.megabrain.cli; + +import io.megabrain.api.LineRange; +import io.megabrain.api.SearchResponse; +import io.megabrain.api.SearchResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for HumanReadableSearchResultFormatter (US-04-05 T3). + */ +class SearchResultFormatterTest { + + private HumanReadableSearchResultFormatter formatter; + + @BeforeEach + void setUp() { + formatter = new HumanReadableSearchResultFormatter(); + } + + @Test + @DisplayName("empty results returns No results.") + void format_emptyResults_returnsNoResults() { + SearchResponse response = new SearchResponse(List.of(), 0, 0, 10, "q", 5L); + String out = formatter.format(response); + assertThat(out).isEqualTo("No results."); + } + + @Test + @DisplayName("null response returns No results.") + void format_nullResponse_returnsNoResults() { + String out = formatter.format(null); + assertThat(out).isEqualTo("No results."); + } + + @Test + @DisplayName("single result has File, Entity, Score, snippet and separator") + void format_singleResult_includesFileEntityScoreSnippet() { + SearchResult result = SearchResult.create( + "public void run() { }", + "MyClass.run()", + "method", + "src/MyClass.java", + "java", + "repo1", + 0.95f, + new LineRange(10, 12) + ); + SearchResponse response = new SearchResponse(List.of(result), 1, 0, 10, "run", 10L); + String out = formatter.format(response); + + assertThat(out).contains("File: src/MyClass.java"); + assertThat(out).contains("Entity: MyClass.run()"); + assertThat(out).contains("Score: 0.95"); + assertThat(out).contains("public void run() { }"); + assertThat(out).contains(HumanReadableSearchResultFormatter.RESULT_SEPARATOR); + } + + @Test + @DisplayName("multiple results have separators between them") + void format_multipleResults_includesSeparators() { + SearchResult r1 = SearchResult.create("c1", "E1", "class", "f1.java", "java", "", 0.9f, new LineRange(1, 1)); + SearchResult r2 = SearchResult.create("c2", "E2", "method", "f2.java", "java", "", 0.8f, new LineRange(1, 1)); + SearchResponse response = new SearchResponse(List.of(r1, r2), 2, 0, 10, "query", 5L); + String out = formatter.format(response); + + assertThat(out).contains("File: f1.java"); + assertThat(out).contains("File: f2.java"); + assertThat(out).contains("---"); + // Each result block ends with ---; with 2 results we get two separators + assertThat(out.indexOf("---")).isGreaterThanOrEqualTo(0); + } + + @Test + @DisplayName("long snippet is truncated by line count") + void format_longSnippet_truncatedByLineCount() { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 25; i++) { + content.append("line ").append(i).append("\n"); + } + SearchResult result = SearchResult.create( + content.toString(), + "X", + "class", + "f.java", + "java", + "", + 0.5f, + new LineRange(1, 25) + ); + SearchResponse response = new SearchResponse(List.of(result), 1, 0, 10, "q", 1L); + String out = formatter.format(response); + + assertThat(out).contains("(truncated)"); + assertThat(out).contains("line 0"); + assertThat(out).doesNotContain("line 20"); + } + + @Test + @DisplayName("long line is truncated by line length") + void format_longLine_truncatedByLineLength() { + String longLine = "a".repeat(200); + SearchResult result = SearchResult.create(longLine, "E", "method", "f.java", "java", "", 0.5f, new LineRange(1, 1)); + SearchResponse response = new SearchResponse(List.of(result), 1, 0, 10, "q", 1L); + String out = formatter.format(response); + + assertThat(out).contains("..."); + assertThat(out).doesNotContain("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + @Test + @DisplayName("null and blank content/sourceFile/entityName do not cause NPE") + void format_nullAndBlankFields_noNPE() { + SearchResult result = new SearchResult( + null, + null, + null, + null, + "", + "", + 0f, + new LineRange(1, 1), + null, + null, + false, + null + ); + SearchResponse response = new SearchResponse(List.of(result), 1, 0, 10, "q", 1L); + String out = formatter.format(response); + + assertThat(out).contains("(no path)"); + assertThat(out).contains("(no entity)"); + assertThat(out).contains("Score: 0.0"); + } + + @Test + @DisplayName("quiet format: one line per result path and entity") + void formatQuiet_multipleResults_oneLinePerResult() { + SearchResult r1 = SearchResult.create("c1", "E1", "class", "path/a.java", "java", "", 0.9f, new LineRange(1, 1)); + SearchResult r2 = SearchResult.create("c2", "E2", "method", "path/b.java", "java", "", 0.8f, new LineRange(1, 1)); + SearchResponse response = new SearchResponse(List.of(r1, r2), 2, 0, 10, "q", 1L); + String out = formatter.formatQuiet(response); + + assertThat(out).contains("path/a.java"); + assertThat(out).contains("E1"); + assertThat(out).contains("path/b.java"); + assertThat(out).contains("E2"); + assertThat(out.split("\n").length).isEqualTo(2); + } + + @Test + @DisplayName("quiet format empty returns No results.") + void formatQuiet_empty_returnsNoResults() { + SearchResponse response = new SearchResponse(List.of(), 0, 0, 10, "q", 0L); + String out = formatter.formatQuiet(response); + assertThat(out).isEqualTo("No results."); + } + + @Test + @DisplayName("format with quiet true uses minimal output") + void format_responseWithQuietTrue_usesQuietFormat() { + SearchResult r = SearchResult.create("x", "MyClass", "class", "p.java", "java", "", 0.9f, new LineRange(1, 1)); + SearchResponse response = new SearchResponse(List.of(r), 1, 0, 10, "q", 1L); + String out = formatter.format(response, true); + + assertThat(out).contains("p.java"); + assertThat(out).contains("MyClass"); + assertThat(out).doesNotContain("Score:"); + assertThat(out).doesNotContain("Entity:"); + } + + private static final String ANSI_ESCAPE = "\u001B["; + + @Test + @DisplayName("format with useColor true and highlighter produces ANSI in snippet") + void format_useColorTrue_snippetContainsAnsi() { + HumanReadableSearchResultFormatter formatterWithHighlighter = + new HumanReadableSearchResultFormatter(new CliSyntaxHighlighter()); + SearchResult result = SearchResult.create( + "public void run() { }", + "MyClass.run()", + "method", + "src/MyClass.java", + "java", + "repo1", + 0.95f, + new LineRange(10, 12) + ); + SearchResponse response = new SearchResponse(List.of(result), 1, 0, 10, "run", 10L); + String out = formatterWithHighlighter.format(response, false, true); + + assertThat(out).contains("File: src/MyClass.java"); + assertThat(out).contains(ANSI_ESCAPE); + } + + @Test + @DisplayName("format with useColor false has no ANSI in output") + void format_useColorFalse_noAnsi() { + HumanReadableSearchResultFormatter formatterWithHighlighter = + new HumanReadableSearchResultFormatter(new CliSyntaxHighlighter()); + SearchResult result = SearchResult.create( + "public void run() { }", + "MyClass.run()", + "method", + "src/MyClass.java", + "java", + "repo1", + 0.95f, + new LineRange(10, 12) + ); + SearchResponse response = new SearchResponse(List.of(result), 1, 0, 10, "run", 10L); + String out = formatterWithHighlighter.format(response, false, false); + + assertThat(out).contains("File: src/MyClass.java"); + assertThat(out).contains("public void run() { }"); + assertThat(out).doesNotContain(ANSI_ESCAPE); + } +} diff --git a/docs/api-reference.md b/docs/api-reference.md index a4bb4ce..213fc0c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -183,23 +183,27 @@ Content-Type: application/json | `provider` | string | Yes | Source control provider: `github`, `gitlab`, `bitbucket` | **Response (200 OK):** -Streams progress events via Server-Sent Events (SSE) with stage, message, and percentage fields. +Streams progress events via Server-Sent Events (SSE) with stage, message, and percentage fields. The CLI `ingest` command consumes the same progress stream and displays it in the terminal. --- ## CLI -Run via Quarkus CLI or packaged JAR: +The MegaBrain CLI is available when running the application in CLI mode (e.g. `java -jar megabrain-runner.jar` or the `megabrain` native executable). The **ingest** command supports `--source`, `--repo`, `--branch`, `--token`, and `--incremental`. Run `megabrain ingest --help` to see full usage and options. ```bash -# Ingest a repository -megabrain ingest --url https://github.com/user/repo --branch main +# Show top-level help +megabrain --help -# Search code -megabrain search --query "dependency graph builder" --limit 5 +# Show ingest command usage and options +megabrain ingest --help -# Get help -megabrain --help +# Ingest a repository (required: --source, --repo) +megabrain ingest --source github --repo olexmal/MegaBrain +megabrain ingest --source github --repo owner/repo --branch develop --token YOUR_TOKEN --incremental + +# Search code (when implemented) +megabrain search --query "dependency graph builder" --limit 5 ``` --- diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..eb8186e --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,153 @@ + + +# CLI Reference + +The MegaBrain CLI provides commands to ingest repositories and search code from the command line. + +## Running the CLI + +After building the backend, run the CLI with Java or the native executable: + +```bash +cd backend +mvn package +java -jar target/quarkus-app/quarkus-run.jar [command] [options] +``` + +Or with the native binary (after building with `-Dquarkus.native.enabled=true`): + +```bash +./target/megabrain-runner [command] [options] +``` + +## Commands + +### megabrain ingest + +Ingest a repository (GitHub, GitLab, Bitbucket, or local path) into the MegaBrain index. + +**Options:** + +| Option | Required | Default | Description | +|:-------|:---------|:--------|:-------------| +| `--source` | Yes | - | Source type: `github`, `gitlab`, `bitbucket`, or `local`. | +| `--repo` | Yes | - | Repository URL or identifier (e.g. `owner/repo` or file path for local). | +| `--branch` | No | `main` | Branch to ingest. | +| `--token` | No | - | Authentication token for private repositories (never logged). | +| `--incremental` | No | `false` | Perform incremental ingestion. | +| `--verbose` | No | `false` | Show detailed progress, debug messages, and stack traces on errors. | +| `--help` | No | - | Show usage and options. | + +**Examples:** + +```bash +# Show usage and all options +megabrain ingest --help + +# Ingest a GitHub repository (default branch: main) +megabrain ingest --source github --repo olexmal/MegaBrain + +# Ingest a specific branch with optional token +megabrain ingest --source github --repo owner/private-repo --branch develop --token YOUR_TOKEN + +# Incremental ingestion +megabrain ingest --source github --repo olexmal/MegaBrain --incremental + +# Local path +megabrain ingest --source local --repo /path/to/repo --branch main +``` + +**Exit codes** + +| Code | Meaning | +|:-----|:--------| +| `0` | Success | +| `1` | Execution or ingestion failure (e.g. clone/parse/index error) | +| `2` | Invalid arguments (e.g. invalid `--source`, missing or blank `--repo`) | + +Use these in scripts or CI to detect success or failure (e.g. `megabrain ingest ...; exit $?`). + +### megabrain search + +Search the MegaBrain index from the command line. Provide a query string as the first argument. Optional filters and output options are supported. + +**Options:** + +| Option | Required | Default | Description | +|:-------|:---------|:--------|:-------------| +| `` | Yes | - | Search query string (first positional argument). | +| `--language` | No | - | Filter by programming language (repeatable). Allowed: java, python, javascript, typescript, go, rust, kotlin, ruby, scala, swift, php, c, cpp. | +| `--repo` | No | - | Filter by repository name or identifier (repeatable). | +| `--type` | No | - | Filter by entity type (repeatable). Allowed: class, method, function, field, interface, enum, module. | +| `--limit` | No | `10` | Maximum number of results (1–100). | +| `--json` | No | `false` | Output results as JSON (see [JSON output](#json-output--json)). | +| `--quiet` | No | `false` | Minimal output, pipe-friendly (with `--json`: results array only; otherwise one line per result). | +| `--no-color` | No | `false` | Disable syntax highlighting and ANSI color in output. | +| `--help` | No | - | Show usage and options. | + +#### JSON output (`--json`) + +When `--json` is set, output matches the REST API search response format: `results`, `total`, `page`, `size`, `query`, `took_ms`, `facets`. With `--quiet`, only the `results` array is printed (no wrapper object). Pretty-printing is used when output is a TTY and `--quiet` and `--no-color` are not set; otherwise output is compact (e.g. for piping or scripting). + +**Validation:** Query must be non-blank. Each `--language` and `--type` value must be from the allowed sets above. `--limit` must be between 1 and 100. Invalid values produce exit code 2 and an error message listing allowed values. + +**Default output (human-readable):** When `--json` is not set, results are printed in a readable layout. Each result shows: + +- **File:** Source file path +- **Entity:** Code entity name (e.g. class or method) +- **Score:** Relevance score +- A code snippet (content), **syntax-highlighted** when color is enabled (see below) + +Results are separated by `---`. Optional header shows query, total count, and time taken (ms). Long snippets are truncated: at most 15 lines and 120 characters per line; excess is replaced with “… (truncated)” or “…” at line end. Null or blank path/entity are shown as “(no path)” and “(no entity)”. If there are no results, the output is “No results.” With `--quiet`, output is minimal: one line per result with path and entity separated by tab, suitable for piping. + +**Syntax highlighting:** Snippets are language-aware (Java, Python, JavaScript, TypeScript; other languages shown as plain text). Color is enabled when the output is a TTY and neither `--no-color` nor the `NO_COLOR` environment variable is set. Use `--no-color` to force plain output (e.g. when piping or in CI). + +**Examples:** + +```bash +# Show usage and all options +megabrain search --help + +# Basic search +megabrain search "authentication" + +# With filters and limit +megabrain search "service" --language java --language python --type class --limit 5 + +# Filter by repository +megabrain search "config" --repo olexmal/MegaBrain --limit 20 + +# Plain output (no syntax highlighting), e.g. for piping or CI +megabrain search "service" --no-color + +# JSON output for scripting (full object or results-only with --quiet) +megabrain search "service" --json +megabrain search "service" --json --quiet +``` + +**Exit codes** + +| Code | Meaning | +|:-----|:--------| +| `0` | Success | +| `1` | Execution failure (e.g. search backend error) | +| `2` | Invalid arguments (missing/blank query, invalid --language/--type/--limit) | + +### Verbose / debugging + +Use `--verbose` to enable detailed progress (no message truncation), debug logging for the `io.megabrain` logger, and full stack traces on ingestion failure. Example: `megabrain ingest --source github --repo owner/repo --verbose`. + +### Progress output + +Progress is shown in real time during ingestion. Typical stages include **cloning**, **parsing**, and **indexing**. When the output is a **TTY** (interactive terminal), progress updates in place on a single line; when not a TTY (e.g. redirect or CI), each event is printed on a new line. Message length is capped for readability. + +### Top-level help + +```bash +megabrain --help +``` + +Shows available subcommands (e.g. `ingest`, `search`). diff --git a/docs/development-guide.md b/docs/development-guide.md index 3a24370..e758d94 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -184,6 +184,7 @@ class SearchServiceTest { - Use `@QuarkusTest` for Quarkus-specific integration tests - Mock external dependencies (GitHub API, Ollama, database) with Mockito - Use Testcontainers for database and service testing +- CLI commands are covered by `*CommandTest` classes using Picocli `CommandLine.execute()` and mocked services (e.g. `IngestCommandTest`). ### Frontend Testing (Jest) diff --git a/docs/getting-started.md b/docs/getting-started.md index ea7f3f3..a3c3d4a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -115,7 +115,17 @@ Expected response: **Frontend:** Open `http://localhost:4200` in your browser. You should see the MegaBrain dashboard. -### 5. Local LLM (Ollama) – offline operation +### 5. CLI (optional) + +When the backend is built for CLI mode, you can run the MegaBrain CLI. The **ingest** and **search** commands are available; use `megabrain ingest --help` or `megabrain search --help` to see usage and options. The search command supports filter options (`--language`, `--repo`, `--type`, `--limit`) and output options (`--json`, `--quiet`, `--no-color`); see [CLI Reference](cli-reference.md#megabrain-search) for details. Use `--json` for scripting (e.g. `megabrain search "query" --json` or `--json --quiet` for the results array only). When you run an ingest (e.g. `megabrain ingest --source github --repo owner/repo`), progress is streamed in the terminal. Use `--verbose` for detailed progress and stack traces on errors. + +```bash +cd backend +mvn package +java -jar target/quarkus-app/quarkus-run.jar ingest --help +``` + +### 6. Local LLM (Ollama) – offline operation To use the local LLM **without internet connectivity** (AC3): diff --git a/docs/implemented-features.md b/docs/implemented-features.md index ca34063..41b900f 100644 --- a/docs/implemented-features.md +++ b/docs/implemented-features.md @@ -2,7 +2,7 @@ This document describes all features that have been implemented, organized by epic. Each section covers the key classes, configuration, and testing status. -**Status:** 11 of 52 user stories fully completed, 1 partially completed (98 of 211 story points). +**Status:** 12 of 52 user stories fully completed (98 of 211 story points). --- @@ -358,3 +358,76 @@ megabrain.llm.ollama.model-availability-cache-seconds=60 **Tests:** Unit tests for OllamaLLMClient, LLMClient interface, OllamaConfiguration. **RAG REST (US-04-03):** AC6 (first token within 2s) is validated by an integration test (`RagStreamingIntegrationTestIT.rag_streamFirstToken_within2Seconds`, tagged `performance`) with a mocked RAG service; production compliance is validated by demo or APM. + +--- + +## EPIC-04: REST API & CLI + +### US-04-04: CLI Ingest Command (Done) + +CLI command structure and options for ingesting repositories from the command line. + +**Key Classes:** +- `MegaBrainCommand` - Top-level CLI entry point (Quarkus Picocli `@TopCommand`) with subcommands +- `IngestCommand` - `ingest` subcommand with `--source`, `--repo`, `--branch`, `--token`, `--incremental` options; CDI bean with constructor-injected `IngestionService` + +**Completed (T1):** +- Picocli integration in package `io.megabrain.cli` +- Command name `ingest`; `megabrain ingest --help` shows usage +- Unit tests for command name, help output, and minimal parse (no mocks) + +**Completed (T2):** +- All five options added: `--source` (required), `--repo` (required), `--branch` (default: main), `--token` (optional), `--incremental` (default: false) +- Validation via `IngestionResource.SourceType.fromString()`; invalid source or blank repo throw `ParameterException` with clear messages; token never logged +- Unit tests for help output, defaults, valid sources, invalid source, token parsing, run() after valid parse + +**Completed (T3):** +- Progress display: subscribes to `IngestionService.ingestRepository(repo)` / `ingestRepositoryIncrementally(repo)`; single-line updates on TTY, line-by-line when not TTY; message length capped; failure logs short message (no token) and exits non-zero +- Unit tests for full ingest progress output, incremental progress output, full vs incremental method calls, stream failure + +**Completed (T4):** +- Exit codes: 0 = success, 1 = execution/ingestion failure, 2 = invalid arguments; documented in CLI reference and Javadoc; no `System.exit()`; Picocli `exitCodeOnInvalidInput` / `exitCodeOnExecutionException` on `IngestCommand`; tests assert codes via `CommandLine.execute()` on `IngestCommand` + +**Completed (T5):** +- `--verbose` option: when set, enables DEBUG for `io.megabrain` logger (via JBoss LogManager), fuller progress (no message truncation), and on ingestion failure logs full stack trace with `LOG.error("Ingestion failed", err)`; otherwise message-only; single source is the `verbose` field + +**Completed (T6):** +- **Tests:** Unit tests for option parsing, validation, progress display, exit codes, and help text using Picocli `CommandLine.execute()` and mocked `IngestionService`. Coverage includes: token never in output, repo trim, Picocli exit-code contract (invalid 2, execution 1), branch default in help, non-verbose truncation, null progress message, missing `--repo` exit 2, MegaBrainCommand help. Package `io.megabrain.cli` line and branch coverage >80% (JaCoCo). + +### US-04-05: CLI Search Command (Done) + +CLI command to search the MegaBrain index from the command line. + +**Key Classes:** +- `SearchCommand` – Picocli `search` subcommand with required query parameter; integrated in `MegaBrainCommand.subcommands` + +**Completed (T1):** +- `SearchCommand` class in package `io.megabrain.cli` with `@Command(name = "search")`, `@Parameters(index = "0")` for query, `mixinStandardHelpOptions = true`, exit codes 2 (invalid input) and 1 (execution exception) +- Validation: non-blank query required; throws `ParameterException` otherwise +- Minimal run() behavior: writes to stdout that query was received (stub for T1; no SearchOrchestrator injection yet) +- Help text: `megabrain search --help` shows description and usage +- Unit tests: `SearchCommandTest` (plain JUnit 5) for command name, `--help` output, execute with one query arg, blank query exit 2 + +**Completed (T2):** +- Filter and output options: `--language`, `--repo`, `--type` (entity_type), `--limit` (default 10), `--json` (default false), `--quiet` (default false). All options in help with clear descriptions. +- Validation in `run()`: after query check, each `--language` validated against supported set (java, python, javascript, typescript, go, rust, kotlin, ruby, scala, swift, php, c, cpp); each `--type` against (class, method, function, field, interface, enum, module); `--limit` 1–100. Invalid values throw `ParameterException(spec.commandLine(), "message")` with allowed values in message. +- `SearchRequest` built in `run()` from validated options: `setQuery`, `addLanguage`/`addRepository`/`addEntityType` for each list, `setLimit`. `--json` and `--quiet` kept as fields for T3/T5. No new DTOs. +- Unit tests: option parsing, defaults when only query, multi-value `--language`/`--repo`/`--type`, valid language/type exit 0, invalid `--language`/`--type` exit 2 with stderr message, `--help` contains all option names, `--limit` 1 and 100 valid, `--limit` 0 or out of range exit 2, missing query exit 2. Aim >80% on SearchCommand. + +**Completed (T3):** +- **Terminal formatting:** `SearchResultFormatter` interface in `io.megabrain.cli` with `format(SearchResponse)` and `format(SearchResponse, boolean quiet)`. `HumanReadableSearchResultFormatter`: per result shows File, Entity, Score, snippet, separator `---`; truncation by line count (max 15 lines) and line length (max 120 chars); null-safe placeholders; empty results → "No results."; optional header (query, total, tookMs). Quiet mode: one line per result (path + entity). +- **SearchCommand integration:** Injects `SearchOrchestrator`, `SearchResultFormatter`, and config (facetLimit, transitiveDefaultDepth, transitiveMaxDepth). In `run()`: builds `SearchRequest`, calls `orchestrate(..., SearchMode.HYBRID, ...).await().indefinitely()`, converts `OrchestratorResult` to `SearchResponse` via `SearchResultMapper.toSearchResult()` (shared helper in `io.megabrain.api`, used by REST and CLI). If !json prints formatter output and flushes; handles Uni failure with user-facing `ExecutionException`. +- **SearchResultMapper:** In `io.megabrain.api`; maps `MergedResult` to `SearchResult` DTO; used by `SearchResource` and CLI. +- **Tests:** `SearchResultFormatterTest` (empty → "No results.", single/multiple layout, long snippet truncated, null/blank no NPE, quiet format); `SearchCommandTest` (mock orchestrator, stdout contains formatted result when not --json, empty results "No results."). + +**Completed (T4):** +- **Syntax highlighting:** `SyntaxHighlighter` interface and `CliSyntaxHighlighter` implementation (keyword/pattern-based) using Jansi for ANSI codes. Supports Java, Python, JavaScript, TypeScript; other languages fall back to plain. `HumanReadableSearchResultFormatter` injects highlighter and uses it in `format(response, quiet, useColor)`; on highlighter failure logs debug and appends plain snippet. +- **Color control:** `--no-color` option (default false). useColor resolved as: false if `--no-color`, else false if env `NO_COLOR` set, else false if output not TTY (`System.console() == null`), else true. Formatter receives useColor and highlights snippets only when true. +- **Tests:** `CliSyntaxHighlighterTest` (color on → ANSI, color off → no ANSI, multiple languages, unknown/null/blank language no exception, empty snippet); `SearchResultFormatterTest` (useColor true → snippet contains ANSI, useColor false → no ANSI); `SearchCommandTest` (`--no-color` parsed and useColor false passed to formatter, output with `--no-color` has no ANSI). + +**Completed (T5):** +- **JSON output:** When `--json` is set, output is written as JSON (no formatter). Injected `ObjectMapper` (Quarkus-provided) serializes `SearchResponse`: full JSON includes `results`, `total`, `page`, `size`, `query`, `took_ms`, `facets`; with `--quiet` only `response.getResults()` is serialized (results array). Pretty-printing uses `writerWithDefaultPrettyPrinter()` when TTY and not quiet and not `--no-color`; compact when piped or `--no-color`. Written to `spec.commandLine().getOut()` and flushed. No new DTOs; `SearchResponse`/`SearchResult` are Jackson-friendly. +- **Tests:** `SearchCommandTest`: with `--json` parse stdout as JSON object, assert root has `results`, `total`, `page`, `size`, `query`, `took_ms`, `facets` and one result has `source_file`, `entity_name`, `score`; with `--json --quiet` parse as JSON array, assert length and element fields; empty results: full JSON has `results=[]`, `total=0`, quiet is `[]`. Use `ObjectMapper.readValue(stdout.trim(), ...)`; no exact string assertions. + +**Completed (T6):** +- **Tests:** Unit tests for SearchCommand covering option parsing, validation, output formatting, JSON mode, and help text using Picocli `CommandLine.execute()` and mocked `SearchOrchestrator`. Council-recommended tests: orchestrator failure (exit 1, stderr "Search failed"), JSON with null ObjectMapper (exit 1), JSON serialization failure (mocked IOException), NO_COLOR env (useColor false via SystemStubs), blank/uppercase filter normalization (--language " " skipped, JAVA → java), --quiet human-readable (formatter quiet true, one line per result), JSON with non-empty facets. Additional: SearchResultFormatterTest, CliSyntaxHighlighterTest. Package `io.megabrain.cli` line and instruction coverage >80% (JaCoCo); SearchCommand 94% instructions, 75% branches. diff --git a/docs/index.md b/docs/index.md index 827e429..08c4316 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,6 +46,7 @@ MegaBrain creates a private, intelligent knowledge base of your codebase that en | [Technology Stack](technology-stack.md) | Backend and frontend technologies with versions | | [Implemented Features](implemented-features.md) | Detailed documentation for all completed features | | [API Reference](api-reference.md) | REST API endpoints, parameters, and response formats | +| [CLI Reference](cli-reference.md) | Command-line interface usage and commands | | [Configuration Reference](configuration-reference.md) | All configuration properties with defaults | | [Development Guide](development-guide.md) | Coding standards, testing, git workflow, contributing | | [Deployment & Operations](deployment.md) | Production build, system requirements, troubleshooting | diff --git a/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command-tasks.md b/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command-tasks.md index b1ce98e..a49b080 100644 --- a/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command-tasks.md +++ b/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command-tasks.md @@ -12,79 +12,79 @@ - **Description:** Create `IngestCommand` class using Picocli framework. Define command name `ingest`. Set up command structure with options and parameters. Integrate with Quarkus CLI or standalone CLI. - **Estimated Hours:** 3 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** US-01-01 (needs ingestion service) - **Acceptance Criteria:** - - [ ] IngestCommand class created - - [ ] Command name `ingest` defined - - [ ] Picocli integration working - - [ ] Help text generated + - [x] IngestCommand class created + - [x] Command name `ingest` defined + - [x] Picocli integration working + - [x] Help text generated - **Technical Notes:** Use Picocli for CLI framework. Annotate class with `@Command(name = "ingest")`. Add `@Option` and `@Parameters` annotations. Generate help with `--help`. ### T2: Add source, repo, branch options - **Description:** Add command options: `--source` (github/gitlab/bitbucket/local), `--repo` (repository URL/identifier), `--branch` (optional, default: main/master), `--token` (optional, for private repos), `--incremental` (boolean flag). - **Estimated Hours:** 3 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1 (needs command class) - **Acceptance Criteria:** - - [ ] All options added - - [ ] Options validated - - [ ] Default values set - - [ ] Help text includes all options + - [x] All options added + - [x] Options validated + - [x] Default values set/ + - [x] Help text includes all options - **Technical Notes:** Use `@Option` annotations. Validate source enum. Make branch optional with default. Use `@Option(names = "--incremental", defaultValue = "false")`. ### T3: Implement progress display - **Description:** Implement progress display in terminal using progress bar or status messages. Show ingestion progress (cloning, parsing, indexing). Update progress in real-time. Handle terminal width and formatting. - **Estimated Hours:** 4 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T2 (needs options), US-01-07 (needs progress events) - **Acceptance Criteria:** - - [ ] Progress displayed in terminal - - [ ] Real-time updates - - [ ] Clear progress indication - - [ ] Handles terminal formatting + - [x] Progress displayed in terminal + - [x] Real-time updates + - [x] Clear progress indication + - [x] Handles terminal formatting - **Technical Notes:** Use library like `com.github.lalyos:jfiglet` or `org.jline:jline3` for progress bars. Subscribe to progress events from ingestion service. Update terminal output. ### T4: Handle exit codes - **Description:** Implement proper exit code handling. Return exit code 0 on success, non-zero on failure. Map different error types to appropriate exit codes. Ensure exit codes are set correctly. - **Estimated Hours:** 2 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1-T3 (needs command working) - **Acceptance Criteria:** - - [ ] Exit code 0 on success - - [ ] Non-zero exit codes on failure - - [ ] Appropriate exit codes for different errors - - [ ] Exit codes documented -- **Technical Notes:** Use `System.exit(code)` or Picocli's exit code handling. Map exceptions to exit codes (1: general error, 2: invalid arguments, etc.). + - [x] Exit code 0 on success + - [x] Non-zero exit codes on failure + - [x] Appropriate exit codes for different errors + - [x] Exit codes documented +- **Technical Notes:** Use Picocli's `exitCodeOnInvalidInput = 2`, `exitCodeOnExecutionException = 1` on IngestCommand; no System.exit(); throw ExecutionException on ingestion failure; ParameterException for invalid --source/--repo. Tests use CommandLine.execute() with MegaBrainCommand hierarchy; docs in cli-reference.md and Javadoc. ### T5: Add verbose logging option - **Description:** Add `--verbose` option for detailed logging. When enabled, show detailed progress information, debug messages, and stack traces on errors. Control log level based on verbose flag. - **Estimated Hours:** 2 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1 (needs command class) - **Acceptance Criteria:** - - [ ] --verbose option added - - [ ] Verbose mode shows detailed logs - - [ ] Log level controlled by flag - - [ ] Help text explains verbose mode + - [x] --verbose option added + - [x] Verbose mode shows detailed logs + - [x] Log level controlled by flag + - [x] Help text explains verbose mode - **Technical Notes:** Use `@Option(names = "--verbose")`. Set log level to DEBUG when verbose. Show additional progress details. ### T6: Write command tests - **Description:** Create unit tests for IngestCommand. Test option parsing, validation, progress display, exit codes, and help text. Use Picocli's testing utilities. - **Estimated Hours:** 3 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1-T5 (needs complete implementation) - **Acceptance Criteria:** - - [ ] Unit tests for command - - [ ] Tests cover option parsing - - [ ] Tests cover validation - - [ ] Tests verify exit codes - - [ ] Test coverage >80% + - [x] Unit tests for command + - [x] Tests cover option parsing + - [x] Tests cover validation + - [x] Tests verify exit codes + - [x] Test coverage >80% - **Technical Notes:** Use Picocli's `CommandLine.execute()` for testing. Mock ingestion service. Verify command behavior and output. --- diff --git a/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command.md b/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command.md index f1d9668..92608bb 100644 --- a/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command.md +++ b/features/user-stories/epic-04-api-cli/US-04-04-cli-ingest-command.md @@ -13,12 +13,12 @@ ## Acceptance Criteria -- [ ] **AC1:** Command: `megabrain ingest --source github --repo olexmal/MegaBrain` -- [ ] **AC2:** Supports: `--branch`, `--token`, `--incremental` -- [ ] **AC3:** Progress displayed in terminal (progress bar) -- [ ] **AC4:** Exit code: 0 (success), non-zero (failure) -- [ ] **AC5:** Verbose mode with `--verbose` -- [ ] **AC6:** Help text with `--help` +- [x] **AC1:** Command: `megabrain ingest --source github --repo olexmal/MegaBrain` +- [x] **AC2:** Supports: `--branch`, `--token`, `--incremental` +- [x] **AC3:** Progress displayed in terminal (progress bar) +- [x] **AC4:** Exit code: 0 (success), non-zero (failure) +- [x] **AC5:** Verbose mode with `--verbose` +- [x] **AC6:** Help text with `--help` --- @@ -53,12 +53,12 @@ ## Technical Tasks -- [ ] **T1:** Create `IngestCommand` Picocli class (backend) -- [ ] **T2:** Add source, repo, branch options (backend) -- [ ] **T3:** Implement progress display (backend) -- [ ] **T4:** Handle exit codes (backend) -- [ ] **T5:** Add verbose logging option (backend) -- [ ] **T6:** Write command tests (test) +- [x] **T1:** Create `IngestCommand` Picocli class (backend) +- [x] **T2:** Add source, repo, branch options (backend) +- [x] **T3:** Implement progress display (backend) +- [x] **T4:** Handle exit codes (backend) +- [x] **T5:** Add verbose logging option (backend) +- [x] **T6:** Write command tests (test) --- diff --git a/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command-tasks.md b/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command-tasks.md index c60e018..0712b76 100644 --- a/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command-tasks.md +++ b/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command-tasks.md @@ -12,79 +12,79 @@ - **Description:** Create `SearchCommand` class using Picocli framework. Define command name `search`. Set up command structure with query parameter and filter options. Integrate with CLI framework. - **Estimated Hours:** 2 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** US-04-02 (needs search API) - **Acceptance Criteria:** - - [ ] SearchCommand class created - - [ ] Command name `search` defined - - [ ] Picocli integration working - - [ ] Help text generated + - [x] SearchCommand class created + - [x] Command name `search` defined + - [x] Picocli integration working + - [x] Help text generated - **Technical Notes:** Use Picocli for CLI framework. Annotate with `@Command(name = "search")`. Add query as `@Parameters`. ### T2: Add filter options - **Description:** Add command options: `--language`, `--repo`, `--type` (entity_type), `--limit`, `--json` (output format), `--quiet` (minimal output). Parse and validate options. - **Estimated Hours:** 3 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1 (needs command class) - **Acceptance Criteria:** - - [ ] All filter options added - - [ ] Options validated - - [ ] Default values set - - [ ] Help text includes all options + - [x] All filter options added + - [x] Options validated + - [x] Default values set + - [x] Help text includes all options - **Technical Notes:** Use `@Option` annotations. Validate language and entity_type enums. Set default limit to 10. Support multiple values for filters if needed. ### T3: Implement result formatting - **Description:** Implement human-readable result formatting for terminal output. Format search results with file path, entity name, code snippet, and relevance score. Use clear, readable layout. - **Estimated Hours:** 4 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1, T2 (needs command and options), US-04-02 (needs search API) - **Acceptance Criteria:** - - [ ] Results formatted for terminal - - [ ] Clear, readable layout - - [ ] Includes file path, entity, snippet - - [ ] Handles long lines gracefully + - [x] Results formatted for terminal + - [x] Clear, readable layout + - [x] Includes file path, entity, snippet + - [x] Handles long lines gracefully - **Technical Notes:** Format: `File: path/to/file.java\nEntity: EntityName.method()\nScore: 0.95\n\n\n\n---\n`. Truncate long snippets. Use proper spacing. ### T4: Add syntax highlighting - **Description:** Implement syntax highlighting for code snippets in terminal output. Use library like Jansi or similar for ANSI color codes. Highlight code based on language. Support color/no-color modes. - **Estimated Hours:** 4 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T3 (needs result formatting) - **Acceptance Criteria:** - - [ ] Syntax highlighting implemented - - [ ] Works for multiple languages - - [ ] Color output when terminal supports it - - [ ] No-color mode supported + - [x] Syntax highlighting implemented + - [x] Works for multiple languages + - [x] Color output when terminal supports it + - [x] No-color mode supported - **Technical Notes:** Use library like `org.fusesource.jansi:jansi` or `com.github.javaparser:javaparser-core` for syntax highlighting. Detect terminal color support. Support `--no-color` flag. ### T5: Add JSON output mode - **Description:** Implement JSON output mode when `--json` flag is set. Output search results as JSON matching API response format. Ensure JSON is valid and properly formatted. Support `--quiet` for minimal JSON. - **Estimated Hours:** 3 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1, T2 (needs command and options) - **Acceptance Criteria:** - - [ ] JSON output mode implemented - - [ ] JSON matches API format - - [ ] Valid JSON output - - [ ] Quiet mode for minimal JSON + - [x] JSON output mode implemented + - [x] JSON matches API format + - [x] Valid JSON output + - [x] Quiet mode for minimal JSON - **Technical Notes:** Use Jackson for JSON serialization. Output same format as SearchResponse DTO. Support `--quiet` for just results array. Pretty print or compact based on flag. ### T6: Write command tests - **Description:** Create unit tests for SearchCommand. Test query parsing, filter options, output formatting, JSON mode, and help text. Use Picocli's testing utilities. - **Estimated Hours:** 3 hours - **Assignee:** TBD -- **Status:** Not Started +- **Status:** Completed - **Dependencies:** T1-T5 (needs complete implementation) - **Acceptance Criteria:** - - [ ] Unit tests for command - - [ ] Tests cover option parsing - - [ ] Tests cover output formatting - - [ ] Tests verify JSON mode - - [ ] Test coverage >80% + - [x] Unit tests for command + - [x] Tests cover option parsing + - [x] Tests cover output formatting + - [x] Tests verify JSON mode + - [x] Test coverage >80% - **Technical Notes:** Use Picocli's `CommandLine.execute()` for testing. Mock search API. Verify command output and formatting. --- diff --git a/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command.md b/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command.md index b4f7aee..aa0c847 100644 --- a/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command.md +++ b/features/user-stories/epic-04-api-cli/US-04-05-cli-search-command.md @@ -13,12 +13,12 @@ ## Acceptance Criteria -- [ ] **AC1:** Command: `megabrain search "query string"` -- [ ] **AC2:** Supports: `--language`, `--repo`, `--type`, `--limit` -- [ ] **AC3:** Results: file path, entity name, code snippet -- [ ] **AC4:** Syntax highlighting for snippets -- [ ] **AC5:** Output formats: human-readable (default), JSON (`--json`) -- [ ] **AC6:** Pipe-friendly with `--quiet` +- [x] **AC1:** Command: `megabrain search "query string"` +- [x] **AC2:** Supports: `--language`, `--repo`, `--type`, `--limit` +- [x] **AC3:** Results: file path, entity name, code snippet +- [x] **AC4:** Syntax highlighting for snippets +- [x] **AC5:** Output formats: human-readable (default), JSON (`--json`) +- [x] **AC6:** Pipe-friendly with `--quiet` --- @@ -51,12 +51,12 @@ ## Technical Tasks -- [ ] **T1:** Create `SearchCommand` Picocli class (backend) -- [ ] **T2:** Add filter options (backend) -- [ ] **T3:** Implement result formatting (backend) -- [ ] **T4:** Add syntax highlighting (backend) -- [ ] **T5:** Add JSON output mode (backend) -- [ ] **T6:** Write command tests (test) +- [x] **T1:** Create `SearchCommand` Picocli class (backend) +- [x] **T2:** Add filter options (backend) +- [x] **T3:** Implement result formatting (backend) +- [x] **T4:** Add syntax highlighting (backend) +- [x] **T5:** Add JSON output mode (backend) +- [x] **T6:** Write command tests (test) ---