diff --git a/README.md b/README.md index 98604df..96c4c42 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ We'd love to hear your feedback, suggestions, and any thoughts you have on mutat [![Run on Replit](https://replit.com/badge/github/codeintegrity-ai/mutahunter)](https://replit.com/@raghuramabilash/Mutahunterai) - ## Table of Contents - [Features](#features) @@ -55,10 +54,10 @@ This tool generates unit tests to increase both line and mutation coverage, insp ## go to examples/java_maven ## remove some tests from BankAccountTest.java -mutahunter gen --test-command "mvn clean test" --code-coverage-report-path "target/site/jacoco/jacoco.xml" --test-file-path "src/test/java/BankAccountTest.java" --source-file-path "src/main/java/com/example/BankAccount.java" --coverage-type jacoco  --model "gpt-4o" +mutahunter gen-line --test-command "mvn test -Dtest=BankAccountTest" --code-coverage-report-path "target/site/jacoco/jacoco.xml" --coverage-type jacoco --test-file-path "src/test/java/BankAccountTest.java" --source-file-path "src/main/java/com/example/BankAccount.java" --model "gpt-4o" --target-line-coverage 0.9 --max-attempts 3 -Line coverage increased from 47.00% to 100.00% -Mutation coverage increased from 92.86% to 92.86% +Line Coverage increased from 47.00% to 100.00% +Mutation Coverage increased from 92.86% to 92.86% ``` ## Getting Started with Mutation Testing @@ -178,4 +177,4 @@ jobs: ## Cash Bounty Program -Help us improve Mutahunter and get rewarded! We have a cash bounty program to incentivize contributions to the project. Check out the [bounty board](https://docs.google.com/spreadsheets/d/1cT2_O55m5txrUgZV81g1gtqE_ZDu9LlzgbpNa_HIisc/edit?gid=0#gid=0) to see the available bounties and claim one today! \ No newline at end of file +Help us improve Mutahunter and get rewarded! We have a cash bounty program to incentivize contributions to the project. Check out the [bounty board](https://docs.google.com/spreadsheets/d/1cT2_O55m5txrUgZV81g1gtqE_ZDu9LlzgbpNa_HIisc/edit?gid=0#gid=0) to see the available bounties and claim one today! diff --git a/examples/java_maven/readme.md b/examples/java_maven/readme.md index 40bebd2..7f58755 100644 --- a/examples/java_maven/readme.md +++ b/examples/java_maven/readme.md @@ -23,7 +23,7 @@ mutahunter run --test-command "mvn test" --code-coverage-report-path "target/sit ```bash # remove some tests -mutahunter gen --test-command "mvn test" --code-coverage-report-path "target/site/jacoco/jacoco.xml" --coverage-type jacoco --test-file-path "src/test/java/BankAccountTest.java" --source-file-path "src/main/java/com/example/BankAccount.java" --model "gpt-4o" +mutahunter gen-line --test-command "mvn test -Dtest=BankAccountTest" --code-coverage-report-path "target/site/jacoco/jacoco.xml" --coverage-type jacoco --test-file-path "src/test/java/BankAccountTest.java" --source-file-path "src/main/java/com/example/BankAccount.java" --model "gpt-4o" --target-line-coverage 0.9 --max-attempts 3 ``` Check `logs/_latest/html` for mutation report. diff --git a/src/mutahunter/core/controller.py b/src/mutahunter/core/controller.py index 17bc9dc..be9a31c 100644 --- a/src/mutahunter/core/controller.py +++ b/src/mutahunter/core/controller.py @@ -13,17 +13,22 @@ from mutahunter.core.db import MutationDatabase from mutahunter.core.entities.config import MutationTestControllerConfig from mutahunter.core.error_parser import extract_error_message -from mutahunter.core.exceptions import (CoverageAnalysisError, - MutantKilledError, MutantSurvivedError, - MutationTestingError, - ReportGenerationError, - UnexpectedTestResultError) +from mutahunter.core.exceptions import ( + CoverageAnalysisError, + MutantKilledError, + MutantSurvivedError, + MutationTestingError, + ReportGenerationError, + UnexpectedTestResultError, +) from mutahunter.core.git_handler import GitHandler from mutahunter.core.io import FileOperationHandler from mutahunter.core.llm_mutation_engine import LLMMutationEngine from mutahunter.core.logger import logger from mutahunter.core.prompts.mutant_generator import ( - SYSTEM_PROMPT_MUTANT_ANALYSUS, USER_PROMPT_MUTANT_ANALYSIS) + SYSTEM_PROMPT_MUTANT_ANALYSUS, + USER_PROMPT_MUTANT_ANALYSIS, +) from mutahunter.core.report import MutantReport from mutahunter.core.router import LLMRouter from mutahunter.core.runner import MutantTestRunner diff --git a/src/mutahunter/core/entities/config.py b/src/mutahunter/core/entities/config.py index 76f2d08..0c34eb9 100644 --- a/src/mutahunter/core/entities/config.py +++ b/src/mutahunter/core/entities/config.py @@ -15,7 +15,7 @@ class MutationTestControllerConfig: @dataclass -class UnittestGeneratorConfig: +class UnittestGeneratorLineConfig: model: str api_base: str test_file_path: str @@ -24,5 +24,17 @@ class UnittestGeneratorConfig: code_coverage_report_path: Optional[str] coverage_type: str target_line_coverage_rate: float + max_attempts: int + + +@dataclass +class UnittestGeneratorMutationConfig: + model: str + api_base: str + test_file_path: str + source_file_path: str + test_command: str + code_coverage_report_path: Optional[str] + coverage_type: str target_mutation_coverage_rate: float max_attempts: int diff --git a/src/mutahunter/core/prompts/unittest_generator.py b/src/mutahunter/core/prompts/unittest_generator.py index d448dc2..f8d87b8 100644 --- a/src/mutahunter/core/prompts/unittest_generator.py +++ b/src/mutahunter/core/prompts/unittest_generator.py @@ -41,7 +41,7 @@ class NewTest(BaseModel): test_behavior: str = Field(..., description="Short description of the behavior the test covers.") lines_to_cover: str = Field(..., description="List of line numbers, currently uncovered, that this specific new test aims to cover.") test_name: str = Field(..., description="A short unique test name reflecting the test objective.") - test_code: str = Field(..., description="A single test function testing the behavioral description.") + test_code: str = Field(..., description="A single test function testing the behavioral description. Do not include boilerplate code. Just the test function.") new_imports_code: str = Field(..., description="New imports required for the new test function, or an empty string if none.") test_tags: List[str] = Field(..., description="Tags for the test such as 'happy path', 'edge case', 'other'.") diff --git a/src/mutahunter/core/unittest_gen_line.py b/src/mutahunter/core/unittest_gen_line.py new file mode 100644 index 0000000..4406f6f --- /dev/null +++ b/src/mutahunter/core/unittest_gen_line.py @@ -0,0 +1,326 @@ +import json +import os +import shutil +import subprocess + +import yaml +from grep_ast import filename_to_lang +from jinja2 import Template + +from mutahunter.core.analyzer import Analyzer +from mutahunter.core.coverage_processor import CoverageProcessor +from mutahunter.core.entities.config import UnittestGeneratorLineConfig +from mutahunter.core.error_parser import extract_error_message +from mutahunter.core.logger import logger +from mutahunter.core.prompts.unittest_generator import ( + FAILED_TESTS_TEXT, + LINE_COV_UNITTEST_GENERATOR_USER_PROMPT, +) +from mutahunter.core.router import LLMRouter + +SYSTEM_YAML_FIX = """ +Based on the error message, the YAML content provided is not in the correct format. Please ensure the YAML content is in the correct format and try again. +""" + +USER_YAML_FIX = """ +YAML content: +```yaml +{{yaml_content}} +``` + +Error: +{{error}} + +Output must be wrapped in triple backticks and in YAML format: +```yaml +...fix the yaml content here... +``` +""" + + +class UnittestGenLine: + def __init__( + self, + config: UnittestGeneratorLineConfig, + coverage_processor: CoverageProcessor, + analyzer: Analyzer, + router: LLMRouter, + ): + self.config = config + self.coverage_processor = coverage_processor + self.analyzer = analyzer + self.router = router + + self.failed_unittests = [] + + self.num = 0 + + def run(self) -> None: + self.coverage_processor.parse_coverage_report() + initial_line_coverage_rate = self.coverage_processor.line_coverage_rate + logger.info(f"Initial Line Coverage: {initial_line_coverage_rate*100:.2f}%") + self.increase_line_coverage() + logger.info( + f"Line coverage increased from {initial_line_coverage_rate*100:.2f}% to {self.coverage_processor.line_coverage_rate*100:.2f}%" + ) + + def increase_line_coverage(self): + attempt = 0 + while ( + self.coverage_processor.line_coverage_rate + < self.config.target_line_coverage_rate + and attempt < self.config.max_attempts + ): + attempt += 1 + response = self.generate_unittests() + self._process_generated_unittests(response) + self.coverage_processor.parse_coverage_report() + + def generate_unittests(self): + try: + source_code = FileUtils.read_file(self.config.source_file_path) + test_code = FileUtils.read_file(self.config.test_file_path) + source_code_with_lines = self._number_lines(source_code) + language = filename_to_lang(self.config.source_file_path) + lines_to_cover = self.coverage_processor.file_lines_not_executed.get( + self.config.source_file_path, [] + ) + user_template = Template(LINE_COV_UNITTEST_GENERATOR_USER_PROMPT).render( + language=language, + source_file_numbered=source_code_with_lines, + source_file_name=self.config.source_file_path, + test_file_name=self.config.test_file_path, + test_file=test_code, + lines_to_cover=lines_to_cover, + failed_tests_section=( + Template(FAILED_TESTS_TEXT).render( + failed_test_runs=json.dumps( + self.failed_unittests[:-5], indent=2 + ) + ) + if self.failed_unittests + else "" + ), + ) + response, _, _ = self.router.generate_response( + prompt={"system": "", "user": user_template}, streaming=True + ) + output = self.extract_response(response) + self._save_yaml(output, "line") + return output + except Exception as e: + raise + + def _save_yaml(self, data, type): + if not os.path.exists("logs/_latest"): + os.makedirs("logs/_latest") + if not os.path.exists("logs/_latest/unittest"): + os.makedirs("logs/_latest/unittest") + if not os.path.exists(f"logs/_latest/unittest/{type}"): + os.makedirs(f"logs/_latest/unittest/{type}") + output = f"unittest_{self.num}.yaml" + with open(os.path.join(f"logs/_latest/unittest/{type}", output), "w") as f: + yaml.dump(data, f, default_flow_style=False, indent=2) + + def _process_generated_unittests(self, response: dict) -> list: + generated_unittests = response.get("new_tests", []) + insertion_point_marker = response.get("insertion_point_marker", {}) + + for generated_unittest in generated_unittests: + self.validate_unittest( + generated_unittest, + insertion_point_marker, + check_line_coverage=True, + ) + + def validate_unittest( + self, + generated_unittest: dict, + insertion_point_marker, + check_line_coverage=True, + ) -> None: + try: + class_name = insertion_point_marker.get("class_name") + method_name = insertion_point_marker.get("method_name") + test_code = self._reset_indentation(generated_unittest["test_code"]) + new_imports_code = generated_unittest.get("new_imports_code", "") + FileUtils.backup_code(self.config.test_file_path) + if method_name: + insertion_node = ( + self.analyzer.find_function_block_by_name( + self.config.test_file_path, method_name=method_name + ) + if method_name + else None + ) + test_code = ( + "\n" + + self._adjust_indentation( + test_code, + insertion_node.start_point[1] if insertion_node else 0, + ) + + "\n" + ) + position = ( + insertion_node.end_point[0] + 1 + if insertion_node + else len( + FileUtils.read_file(self.config.test_file_path).splitlines() + ) + ) + FileUtils.insert_code(self.config.test_file_path, test_code, position) + else: + test_code = "\n" + test_code + "\n" + # just append it to the end of the file + FileUtils.insert_code(self.config.test_file_path, test_code, -1) + + for new_import in new_imports_code.splitlines(): + FileUtils.insert_code(self.config.test_file_path, new_import, 0) + + result = subprocess.run( + self.config.test_command.split(), + capture_output=True, + text=True, + cwd=os.getcwd(), + ) + if result.returncode == 0: + + prev_line_coverage_rate = self.coverage_processor.line_coverage_rate + self.coverage_processor.parse_coverage_report() + + if check_line_coverage: + if self.check_line_coverage_increase(prev_line_coverage_rate): + logger.info(f"Test passed and increased line cov:\n{test_code}") + + return True + else: + logger.info( + f"Test passed but failed to increase line cov for\n{test_code}" + ) + else: + logger.info(f"Test failed for\n{test_code}") + self._handle_failed_test(result, test_code) + except Exception as e: + logger.info(f"Failed to validate unittest: {e}") + raise + else: + FileUtils.revert(self.config.test_file_path) + return False + + def check_line_coverage_increase(self, prev_line_coverage_rate): + if self.coverage_processor.line_coverage_rate > prev_line_coverage_rate: + logger.info( + f"Line coverage increased from {prev_line_coverage_rate*100:.2f}% to {self.coverage_processor.line_coverage_rate*100:.2f}%" + ) + return True + else: + return False + + def _handle_failed_test(self, result, test_code): + lang = self.analyzer.get_language_by_filename(self.config.test_file_path) + error_msg = extract_error_message(lang, result.stdout + result.stderr) + self.failed_unittests.append({"code": test_code, "error_message": error_msg}) + + @staticmethod + def _number_lines(code: str) -> str: + return "\n".join(f"{i + 1} {line}" for i, line in enumerate(code.splitlines())) + + def extract_response(self, response: str) -> dict: + retries = 2 + for attempt in range(retries): + try: + response = response.strip().removeprefix("```yaml").rstrip("`") + data = yaml.safe_load(response) + return data + except Exception as e: + if attempt < retries - 1: + response = self.fix_format(e, response) + else: + return {"new_tests": []} + + def fix_format(self, error, content): + system_template = Template(SYSTEM_YAML_FIX).render() + user_template = Template(USER_YAML_FIX).render( + yaml_content=content, + error=error, + ) + prompt = { + "system": system_template, + "user": user_template, + } + model_response, _, _ = self.router.generate_response( + prompt=prompt, streaming=False + ) + return model_response + + @staticmethod + def _reset_indentation(code: str) -> str: + """Reset the indentation of the given code to zero-based indentation.""" + lines = code.splitlines() + if not lines: + return code + min_indent = min( + len(line) - len(line.lstrip()) for line in lines if line.strip() + ) + return "\n".join(line[min_indent:] if line.strip() else line for line in lines) + + @staticmethod + def _adjust_indentation(code: str, indent_level: int) -> str: + """Adjust the given code to the specified base indentation level.""" + lines = code.splitlines() + adjusted_lines = [" " * indent_level + line for line in lines] + return "\n".join(adjusted_lines) + + +class FileUtils: + @staticmethod + def read_file(path: str) -> str: + try: + with open(path, "r") as file: + return file.read() + except FileNotFoundError: + logger.info(f"File not found: {path}") + except Exception as e: + logger.info(f"Error reading file {path}: {e}") + raise + + @staticmethod + def backup_code(file_path: str) -> None: + backup_path = f"{file_path}.bak" + try: + shutil.copyfile(file_path, backup_path) + except Exception as e: + logger.info(f"Failed to create backup file for {file_path}: {e}") + raise + + @staticmethod + def insert_code(file_path: str, code: str, position: int) -> None: + try: + with open(file_path, "r") as file: + lines = file.read().splitlines() + if position == -1: + position = len(lines) + lines.insert(position, code) + with open(file_path, "w") as file: + file.write("\n".join(lines)) + + # import uuid + + # random_name = str(uuid.uuid4())[:4] + # with open(f"{random_name}.java", "w") as file: + # file.write("\n".join(lines)) + except Exception as e: + raise + + @staticmethod + def revert(file_path: str) -> None: + backup_path = f"{file_path}.bak" + try: + if os.path.exists(backup_path): + shutil.copyfile(backup_path, file_path) + else: + logger.info(f"No backup file found for {file_path}") + raise FileNotFoundError(f"No backup file found for {file_path}") + except Exception as e: + logger.info(f"Failed to revert file {file_path}: {e}") + raise diff --git a/src/mutahunter/core/unittest_generator.py b/src/mutahunter/core/unittest_gen_mutation.py similarity index 77% rename from src/mutahunter/core/unittest_generator.py rename to src/mutahunter/core/unittest_gen_mutation.py index 0f488b4..b7a456d 100644 --- a/src/mutahunter/core/unittest_generator.py +++ b/src/mutahunter/core/unittest_gen_mutation.py @@ -12,13 +12,18 @@ from mutahunter.core.controller import MutationTestController from mutahunter.core.coverage_processor import CoverageProcessor from mutahunter.core.db import MutationDatabase -from mutahunter.core.entities.config import (MutationTestControllerConfig, - UnittestGeneratorConfig) +from mutahunter.core.entities.config import ( + MutationTestControllerConfig, + UnittestGeneratorMutationConfig, +) from mutahunter.core.error_parser import extract_error_message from mutahunter.core.logger import logger from mutahunter.core.prompts.unittest_generator import ( - FAILED_TESTS_TEXT, LINE_COV_UNITTEST_GENERATOR_USER_PROMPT, - MUTATION_COV_UNITTEST_GENERATOR_USER_PROMPT, MUTATION_WEAK_TESTS_TEXT) + FAILED_TESTS_TEXT, + LINE_COV_UNITTEST_GENERATOR_USER_PROMPT, + MUTATION_COV_UNITTEST_GENERATOR_USER_PROMPT, + MUTATION_WEAK_TESTS_TEXT, +) from mutahunter.core.router import LLMRouter from mutahunter.core.runner import MutantTestRunner @@ -42,10 +47,10 @@ """ -class UnittestGenerator: +class UnittestGenMutation: def __init__( self, - config: UnittestGeneratorConfig, + config: UnittestGeneratorMutationConfig, coverage_processor: CoverageProcessor, analyzer: Analyzer, test_runner: MutantTestRunner, @@ -73,39 +78,23 @@ def __init__( def run(self) -> None: self.coverage_processor.parse_coverage_report() initial_line_coverage_rate = self.coverage_processor.line_coverage_rate - logger.info(f"Initial line coverage rate: {initial_line_coverage_rate}") - self.increase_line_coverage() - logger.info( - f"Line coverage increased from {initial_line_coverage_rate*100:.2f}% to {self.coverage_processor.line_coverage_rate*100:.2f}%" - ) + logger.info(f"Initial Line Coverage: {initial_line_coverage_rate*100:.2f}%") + self.mutator.run() self.latest_run_id = self.db.get_latest_run_id() data = self.db.get_mutant_summary(self.latest_run_id) logger.info(f"Data: {data}") initial_mutation_coverage_rate = data["mutation_coverage"] - logger.info(f"Initial mutation coverage rate: {initial_mutation_coverage_rate}") - self.increase_mutation_coverage() logger.info( - f"Line coverage increased from {initial_line_coverage_rate*100:.2f}% to {self.coverage_processor.line_coverage_rate*100:.2f}%" + f"Initial Mutation Coverage: {initial_mutation_coverage_rate*100:.2f}%" ) + self.increase_mutation_coverage() data = self.db.get_mutant_summary(self.latest_run_id) final_mutation_coverage_rate = data["mutation_coverage"] logger.info( - f"Mutation coverage increased from {initial_mutation_coverage_rate*100:.2f}% to {final_mutation_coverage_rate*100:.2f}%" + f"Mutation Coverage increased from {initial_mutation_coverage_rate*100:.2f}% to {final_mutation_coverage_rate*100:.2f}%" ) - def increase_line_coverage(self): - attempt = 0 - while ( - self.coverage_processor.line_coverage_rate - < self.config.target_line_coverage_rate - and attempt < self.config.max_attempts - ): - attempt += 1 - response = self.generate_unittests() - self._process_generated_unittests(response) - self.coverage_processor.parse_coverage_report() - def increase_mutation_coverage(self): attempt = 0 data = self.db.get_mutant_summary(self.latest_run_id) @@ -120,41 +109,6 @@ def increase_mutation_coverage(self): response = self.generate_unittests_for_mutants() self._process_generated_unittests_for_mutation(response) - def generate_unittests(self): - try: - source_code = FileUtils.read_file(self.config.source_file_path) - test_code = FileUtils.read_file(self.config.test_file_path) - source_code_with_lines = self._number_lines(source_code) - language = filename_to_lang(self.config.source_file_path) - lines_to_cover = self.coverage_processor.file_lines_not_executed.get( - self.config.source_file_path, [] - ) - user_template = Template(LINE_COV_UNITTEST_GENERATOR_USER_PROMPT).render( - language=language, - source_file_numbered=source_code_with_lines, - source_file_name=self.config.source_file_path, - test_file_name=self.config.test_file_path, - test_file=test_code, - lines_to_cover=lines_to_cover, - failed_tests_section=( - Template(FAILED_TESTS_TEXT).render( - failed_test_runs=json.dumps( - self.failed_unittests[:-5], indent=2 - ) - ) - if self.failed_unittests - else "" - ), - ) - response, _, _ = self.router.generate_response( - prompt={"system": "", "user": user_template}, streaming=True - ) - otuput = self.extract_response(response) - self._save_yaml(otuput, "line") - return otuput - except Exception as e: - raise - def _save_yaml(self, data, type): if not os.path.exists("logs/_latest"): os.makedirs("logs/_latest") @@ -166,23 +120,10 @@ def _save_yaml(self, data, type): with open(os.path.join(f"logs/_latest/unittest/{type}", output), "w") as f: yaml.dump(data, f, default_flow_style=False, indent=2) - def _process_generated_unittests(self, response: dict) -> list: - generated_unittests = response.get("new_tests", []) - insertion_point_marker = response.get("insertion_point_marker", {}) - - for generated_unittest in generated_unittests: - self.validate_unittest( - generated_unittest, - insertion_point_marker, - check_line_coverage=True, - check_mutantation_coverage=False, - ) - def validate_unittest( self, generated_unittest: dict, insertion_point_marker, - check_line_coverage=True, check_mutantation_coverage=False, ) -> None: try: @@ -234,15 +175,6 @@ def validate_unittest( prev_line_coverage_rate = self.coverage_processor.line_coverage_rate self.coverage_processor.parse_coverage_report() - if check_line_coverage: - if self.check_line_coverage_increase(prev_line_coverage_rate): - logger.info(f"Test passed and increased line cov:\n{test_code}") - - return True - else: - logger.info( - f"Test passed but failed to increase line cov for\n{test_code}" - ) if check_mutantation_coverage: if self.check_mutant_coverage_increase( generated_unittest, test_code @@ -265,15 +197,6 @@ def validate_unittest( FileUtils.revert(self.config.test_file_path) return False - def check_line_coverage_increase(self, prev_line_coverage_rate): - if self.coverage_processor.line_coverage_rate > prev_line_coverage_rate: - logger.info( - f"Line coverage increased from {prev_line_coverage_rate*100:.2f}% to {self.coverage_processor.line_coverage_rate*100:.2f}%" - ) - return True - else: - return False - def check_mutant_coverage_increase(self, generated_unittest, test_code): runner = MutantTestRunner(test_command=self.config.test_command) @@ -366,7 +289,6 @@ def _process_generated_unittests_for_mutation(self, response) -> None: self.validate_unittest( generated_unittest, insertion_point_marker, - check_line_coverage=False, check_mutantation_coverage=True, ) diff --git a/src/mutahunter/main.py b/src/mutahunter/main.py index a4b0c8c..26f80ef 100644 --- a/src/mutahunter/main.py +++ b/src/mutahunter/main.py @@ -5,48 +5,96 @@ from mutahunter.core.controller import MutationTestController from mutahunter.core.coverage_processor import CoverageProcessor from mutahunter.core.db import MutationDatabase -from mutahunter.core.entities.config import (MutationTestControllerConfig, - UnittestGeneratorConfig) -from mutahunter.core.error_parser import extract_error_message -from mutahunter.core.git_handler import GitHandler +from mutahunter.core.entities.config import ( + MutationTestControllerConfig, + UnittestGeneratorLineConfig, + UnittestGeneratorMutationConfig, +) from mutahunter.core.io import FileOperationHandler from mutahunter.core.llm_mutation_engine import LLMMutationEngine -from mutahunter.core.logger import logger from mutahunter.core.report import MutantReport from mutahunter.core.router import LLMRouter from mutahunter.core.runner import MutantTestRunner -from mutahunter.core.unittest_generator import UnittestGenerator +from mutahunter.core.unittest_gen_line import UnittestGenLine +from mutahunter.core.unittest_gen_mutation import UnittestGenMutation -def parse_arguments(): - """ - Parses command-line arguments for the Mutahunter CLI. - - Returns: - argparse.Namespace: Parsed command-line arguments. - """ - parser = argparse.ArgumentParser( - description="Mutahunter CLI for performing mutation testing." +def add_mutation_testing_subparser(subparsers): + parser = subparsers.add_parser("run", help="Run the mutation testing process.") + parser.add_argument( + "--model", + type=str, + default="gpt-4o-mini", + help="The LLM model to use for mutation generation. Default is 'gpt-4o-mini'.", ) - subparsers = parser.add_subparsers(title="commands", dest="command") + parser.add_argument( + "--api-base", + type=str, + default="", + help="The base URL for the API if using a self-hosted LLM model.", + ) + parser.add_argument( + "--test-command", + type=str, + default=None, + required=True, + help="The command to run the tests (e.g., 'pytest'). This argument is required.", + ) + parser.add_argument( + "--code-coverage-report-path", + type=str, + required=False, + help="The path to the code coverage report file. Optional.", + ) + parser.add_argument( + "--coverage-type", + type=str, + default="cobertura", + required=False, + choices=["cobertura", "jacoco", "lcov"], + help="The type of code coverage report to parse. Default is 'cobertura'.", + ) + parser.add_argument( + "--exclude-files", + type=str, + nargs="+", + default=[], + required=False, + help="A list of files to exclude from mutation testing. Optional.", + ) + parser.add_argument( + "--only-mutate-file-paths", + type=str, + nargs="+", + default=[], + required=False, + help="A list of specific files to mutate. Optional.", + ) + parser.add_argument( + "--diff", + default=False, + action="store_true", + help="Run mutation testing only on modified files in the latest commit.", + ) + - # add subparser for test gen - test_gen_parser = subparsers.add_parser( - "gen", help="Generate test cases for a given code snippet." +def add_gen_line_subparser(subparsers): + parser = subparsers.add_parser( + "gen-line", help="Generate test cases for line coverage." ) - test_gen_parser.add_argument( + parser.add_argument( "--test-file-path", ) - test_gen_parser.add_argument( + parser.add_argument( "--source-file-path", ) - test_gen_parser.add_argument( + parser.add_argument( "--code-coverage-report-path", type=str, required=False, help="The path to the code coverage report file. Optional.", ) - test_gen_parser.add_argument( + parser.add_argument( "--coverage-type", type=str, default="cobertura", @@ -54,73 +102,56 @@ def parse_arguments(): choices=["cobertura", "jacoco", "lcov"], help="The type of code coverage report to parse. Default is 'cobertura'.", ) - test_gen_parser.add_argument( + parser.add_argument( "--test-command", type=str, default=None, required=True, help="The command to run the tests (e.g., 'pytest'). This argument is required.", ) - test_gen_parser.add_argument( + parser.add_argument( "--model", type=str, default="gpt-4o-mini", help="The LLM model to use for mutation generation. Default is 'gpt-4o-mini'.", ) - test_gen_parser.add_argument( + parser.add_argument( "--api-base", type=str, default="", help="The base URL for the API if using a self-hosted LLM model.", ) - test_gen_parser.add_argument( + parser.add_argument( "--target-line-coverage-rate", type=float, default=0.9, help="The target line coverage rate. Default is 0.9.", ) - - test_gen_parser.add_argument( - "--target-mutation-coverage-rate", - type=float, - default=0.9, - help="The target mutation coverage rate. Default is 0.9.", - ) - test_gen_parser.add_argument( + parser.add_argument( "--max-attempts", type=int, default=3, help="The maximum number of attempts to generate a test case. Default is 3.", ) - # Main command arguments - main_parser = subparsers.add_parser("run", help="Run the mutation testing process.") - main_parser.add_argument( - "--model", - type=str, - default="gpt-4o-mini", - help="The LLM model to use for mutation generation. Default is 'gpt-4o-mini'.", + +def add_gen_mutation_subparser(subparsers): + parser = subparsers.add_parser( + "gen-mutation", help="Generate test cases for mutation coverage." ) - main_parser.add_argument( - "--api-base", - type=str, - default="", - help="The base URL for the API if using a self-hosted LLM model.", + parser.add_argument( + "--test-file-path", ) - main_parser.add_argument( - "--test-command", - type=str, - default=None, - required=True, - help="The command to run the tests (e.g., 'pytest'). This argument is required.", + parser.add_argument( + "--source-file-path", ) - main_parser.add_argument( + parser.add_argument( "--code-coverage-report-path", type=str, required=False, help="The path to the code coverage report file. Optional.", ) - main_parser.add_argument( + parser.add_argument( "--coverage-type", type=str, default="cobertura", @@ -128,32 +159,70 @@ def parse_arguments(): choices=["cobertura", "jacoco", "lcov"], help="The type of code coverage report to parse. Default is 'cobertura'.", ) - main_parser.add_argument( - "--exclude-files", + parser.add_argument( + "--test-command", type=str, - nargs="+", - default=[], - required=False, - help="A list of files to exclude from mutation testing. Optional.", + default=None, + required=True, + help="The command to run the tests (e.g., 'pytest'). This argument is required.", ) - main_parser.add_argument( - "--only-mutate-file-paths", + parser.add_argument( + "--model", type=str, - nargs="+", - default=[], - required=False, - help="A list of specific files to mutate. Optional.", + default="gpt-4o-mini", + help="The LLM model to use for mutation generation. Default is 'gpt-4o-mini'.", ) - main_parser.add_argument( - "--diff", - default=False, - action="store_true", - help="Run mutation testing only on modified files in the latest commit.", + parser.add_argument( + "--api-base", + type=str, + default="", + help="The base URL for the API if using a self-hosted LLM model.", + ) + parser.add_argument( + "--target-mutation-coverage-rate", + type=float, + default=0.9, + help="The target mutation coverage rate. Default is 0.9.", + ) + parser.add_argument( + "--max-attempts", + type=int, + default=3, + help="The maximum number of attempts to generate a test case. Default is 3.", ) + + +def parse_arguments(): + """ + Parses command-line arguments for the Mutahunter CLI. + + Returns: + argparse.Namespace: Parsed command-line arguments. + """ + parser = argparse.ArgumentParser( + description="Mutahunter CLI for performing mutation testing." + ) + subparsers = parser.add_subparsers(title="commands", dest="command") + add_mutation_testing_subparser(subparsers) + add_gen_line_subparser(subparsers) + add_gen_mutation_subparser(subparsers) + return parser.parse_args() -def create_controller(config: MutationTestControllerConfig) -> MutationTestController: +def create_run_mutation_testing_controller( + args: argparse.Namespace, +) -> MutationTestController: + config = MutationTestControllerConfig( + model=args.model, + api_base=args.api_base, + test_command=args.test_command, + code_coverage_report_path=args.code_coverage_report_path, + coverage_type=args.coverage_type, + exclude_files=args.exclude_files, + only_mutate_file_paths=args.only_mutate_file_paths, + diff=args.diff, + ) coverage_processor = CoverageProcessor( code_coverage_report_path=config.code_coverage_report_path, coverage_type=config.coverage_type, @@ -182,30 +251,63 @@ def create_controller(config: MutationTestControllerConfig) -> MutationTestContr ) -def create_unittest_controller(config: UnittestGeneratorConfig) -> UnittestGenerator: +def create_gen_line_controller(args: argparse.Namespace) -> UnittestGenLine: + config = UnittestGeneratorLineConfig( + model=args.model, + api_base=args.api_base, + test_file_path=args.test_file_path, + source_file_path=args.source_file_path, + test_command=args.test_command, + code_coverage_report_path=args.code_coverage_report_path, + coverage_type=args.coverage_type, + target_line_coverage_rate=args.target_line_coverage_rate, + max_attempts=args.max_attempts, + ) coverage_processor = CoverageProcessor( code_coverage_report_path=config.code_coverage_report_path, coverage_type=config.coverage_type, ) analyzer = Analyzer() - test_runner = MutantTestRunner(test_command=config.test_command) router = LLMRouter(model=config.model, api_base=config.api_base) - db = MutationDatabase() + return UnittestGenLine( + config=config, + coverage_processor=coverage_processor, + analyzer=analyzer, + router=router, + ) - mutation_config = MutationTestControllerConfig( - model=config.model, - api_base=config.api_base, - test_command=config.test_command, + +def crete_gen_mutation_controller( + args: argparse.Namespace, +) -> UnittestGenMutation: + config = UnittestGeneratorMutationConfig( + model=args.model, + api_base=args.api_base, + test_file_path=args.test_file_path, + source_file_path=args.source_file_path, + test_command=args.test_command, + code_coverage_report_path=args.code_coverage_report_path, + coverage_type=args.coverage_type, + target_mutation_coverage_rate=args.target_mutation_coverage_rate, + max_attempts=args.max_attempts, + ) + coverage_processor = CoverageProcessor( code_coverage_report_path=config.code_coverage_report_path, coverage_type=config.coverage_type, - exclude_files=[], - only_mutate_file_paths=[config.source_file_path], - diff=False, ) - mutator = create_controller(mutation_config) + analyzer = Analyzer() + test_runner = MutantTestRunner(test_command=config.test_command) + router = LLMRouter(model=config.model, api_base=config.api_base) + + db = MutationDatabase() - return UnittestGenerator( + args.only_mutate_file_paths = [config.source_file_path] + args.diff = False + args.exclude_files = [] + mutator = create_run_mutation_testing_controller(args) + + return UnittestGenMutation( config=config, coverage_processor=coverage_processor, analyzer=analyzer, @@ -217,41 +319,20 @@ def create_unittest_controller(config: UnittestGeneratorConfig) -> UnittestGener def run(): - """ - Main function to parse arguments and initiate the Mutahunter run process. - """ args = parse_arguments() - command_line_input = " ".join(sys.argv) - logger.info(f"Command line input: {command_line_input}") - # distinguish between gen and run commands - if args.command == "gen": - config = UnittestGeneratorConfig( - model=args.model, - api_base=args.api_base, - test_file_path=args.test_file_path, - source_file_path=args.source_file_path, - test_command=args.test_command, - code_coverage_report_path=args.code_coverage_report_path, - coverage_type=args.coverage_type, - target_line_coverage_rate=args.target_line_coverage_rate, - target_mutation_coverage_rate=args.target_mutation_coverage_rate, - max_attempts=args.max_attempts, - ) - controller = create_unittest_controller(config) + if args.command == "run": + controller = create_run_mutation_testing_controller(args) controller.run() - else: - config = MutationTestControllerConfig( - model=args.model, - api_base=args.api_base, - test_command=args.test_command, - code_coverage_report_path=args.code_coverage_report_path, - coverage_type=args.coverage_type, - exclude_files=args.exclude_files, - only_mutate_file_paths=args.only_mutate_file_paths, - diff=args.diff, - ) - controller = create_controller(config) + pass + elif args.command == "gen-line": + controller = create_gen_line_controller(args) controller.run() + elif args.command == "gen-mutation": + controller = crete_gen_mutation_controller(args) + controller.run() + else: + print("Invalid command.") + sys.exit(1) if __name__ == "__main__":