diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0cfcc7c --- /dev/null +++ b/.env.example @@ -0,0 +1,94 @@ +# 7000%AUTO Environment Variables +# Copy this file to .env and fill in your values +# ALL OpenCode settings are REQUIRED - the app will not start without them! + +# ----------------------------------------------------------------------------- +# Application Settings +# ----------------------------------------------------------------------------- +APP_NAME=7000%AUTO +DEBUG=true +LOG_LEVEL=INFO + +# ----------------------------------------------------------------------------- +# OpenCode AI Settings (ALL REQUIRED - no defaults!) +# ----------------------------------------------------------------------------- +# The application will NOT start if any of these are missing. +# +# Examples for different providers: +# +# MiniMax (Anthropic-compatible): +# OPENCODE_API_KEY=your-minimax-key +# OPENCODE_API_BASE=https://api.minimax.io/anthropic/v1 +# OPENCODE_SDK=@ai-sdk/anthropic +# OPENCODE_MODEL=MiniMax-M2.1 +# OPENCODE_MAX_TOKENS=196608 +# +# Claude (Anthropic): +# OPENCODE_API_KEY=your-anthropic-key +# OPENCODE_API_BASE=https://api.anthropic.com +# OPENCODE_SDK=@ai-sdk/anthropic +# OPENCODE_MODEL=claude-sonnet-4-5 +# OPENCODE_MAX_TOKENS=196608 +# +# OpenAI: +# OPENCODE_API_KEY=your-openai-key +# OPENCODE_API_BASE=https://api.openai.com/v1 +# OPENCODE_SDK=@ai-sdk/openai +# OPENCODE_MODEL=gpt-5.2 +# OPENCODE_MAX_TOKENS=196608 +# +# Together (OpenAI-compatible): +# OPENCODE_API_KEY=your-together-key +# OPENCODE_API_BASE=https://api.together.xyz/v1 +# OPENCODE_SDK=@ai-sdk/openai +# OPENCODE_MODEL=meta-llama/Llama-3.1-70B-Instruct-Turbo +# OPENCODE_MAX_TOKENS=8192 +# +# Groq (OpenAI-compatible): +# OPENCODE_API_KEY=your-groq-key +# OPENCODE_API_BASE=https://api.groq.com/openai/v1 +# OPENCODE_SDK=@ai-sdk/openai +# OPENCODE_MODEL=llama-3.1-70b-versatile +# OPENCODE_MAX_TOKENS=8000 + +# API Key (REQUIRED) +OPENCODE_API_KEY=your-api-key-here + +# API Base URL (REQUIRED) +OPENCODE_API_BASE=https://api.minimax.io/anthropic/v1 + +# AI SDK npm package (REQUIRED) +# Use @ai-sdk/anthropic for Anthropic-compatible APIs (Claude, MiniMax) +# Use @ai-sdk/openai for OpenAI-compatible APIs (OpenAI, Together, Groq) +OPENCODE_SDK=@ai-sdk/anthropic + +# Model name (REQUIRED) +OPENCODE_MODEL=MiniMax-M2.1 + +# Maximum output tokens (REQUIRED) +OPENCODE_MAX_TOKENS=196608 + +# ----------------------------------------------------------------------------- +# Gitea Settings (Required for uploading) +# ----------------------------------------------------------------------------- +GITEA_TOKEN=your-gitea-token-here +GITEA_USERNAME=your-gitea-username +GITEA_URL=your-gitea-instance-url + +# ----------------------------------------------------------------------------- +# X (Twitter) API Settings (Required for posting) +# ----------------------------------------------------------------------------- +X_API_KEY=your-x-api-key +X_API_SECRET=your-x-api-secret +X_ACCESS_TOKEN=your-x-access-token +X_ACCESS_TOKEN_SECRET=your-x-access-token-secret + +# ----------------------------------------------------------------------------- +# Optional Settings (have sensible defaults) +# ----------------------------------------------------------------------------- +# DATABASE_URL=sqlite+aiosqlite:///./data/7000auto.db +# HOST=0.0.0.0 +# PORT=8000 +# AUTO_START=true +# MAX_CONCURRENT_PROJECTS=1 +# WORKSPACE_DIR=./workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2467b44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,98 @@ +# ============================================================================= +# 7000%AUTO .gitignore +# ============================================================================= + +# Environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# Testing +.tox/ +.nox/ +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +*.py,cover + +# Logs +logs/ +*.log + +# Database +data/ +*.db +*.sqlite +*.sqlite3 + +# Workspace (generated projects) +workspace/ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +.docker/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp + +# Secrets +*.pem +*.key +secrets/ + +# Project specific +*.readme_generated +.readme_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fd34395 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: detect-private-key + - id: fix-byte-order-marker + + - repo: https://github.com/psf/black + rev: 24.1.0 + hooks: + - id: black + language_version: python3.9 + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: [types-requests, types-pyyaml] + args: [--ignore-missing-imports, --disallow-untyped-defs] + +ci: + autofix_commit_msg: | + style: pre-commit fixes + autofix_pr_body: | + {{$message}} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0411f91 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## [0.1.0] - 2024-02-05 + +### Added + +- Initial MCP Server CLI implementation +- FastAPI-based MCP protocol server +- Click CLI interface +- Built-in file operation tools (read, write, list, glob, search) +- Git integration tools (status, log, diff) +- Shell execution with security controls +- Local LLM support (Ollama, LM Studio compatible) +- YAML/JSON custom tool definitions +- Configuration management with environment variable overrides +- CORS support for AI assistant integration +- Comprehensive test suite + +### Features + +- MCP protocol handshake (initialize/initialized) +- Tools/list and tools/call endpoints +- Async tool execution +- Tool schema validation +- Hot-reload support for custom tools + +### Tools + +- `file_tools`: File read, write, list, search, glob operations +- `git_tools`: Git status, log, diff, commit operations +- `shell_tools`: Safe shell command execution + +### Configuration + +- `config.yaml` support +- Environment variable overrides (MCP_PORT, MCP_HOST, etc.) +- Security settings (allowed commands, blocked paths) +- Local LLM configuration diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..27088f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RepoHealth Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b8b97ec --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=7.0 +pytest-cov>=4.0 +black>=23.0 +flake8>=6.0 +ruff>=0.1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..80607d7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,27 @@ +[metadata] +max-line-length = 100 +exclude = test.*?$, *.pyc, *.pyo, __pycache__, .mypy_cache, .tox, .nox, dist, build + +[flake8] +max-line-length = 100 +ignore = E203, E501, W503 +per-file-ignores = __init__.py:F401 + +[mypy] +python_version = 3.9 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True + +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* + +[tool:coverage:run] +source = project_scaffold_cli +omit = tests/* + +[tool:black] +line-length = 100 +target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2446847 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +"""Pytest configuration and fixtures for regex humanizer tests.""" + +import pytest +from regex_humanizer.parser import RegexParser, parse_regex +from regex_humanizer.translator import RegexTranslator, translate_regex +from regex_humanizer.test_generator import TestCaseGenerator, generate_test_cases + + +@pytest.fixture +def parser(): + """Provide a RegexParser instance.""" + return RegexParser + + +@pytest.fixture +def translator(): + """Provide a RegexTranslator instance.""" + return RegexTranslator("pcre") + + +@pytest.fixture +def test_generator(): + """Provide a TestCaseGenerator instance.""" + return TestCaseGenerator("pcre") + + +@pytest.fixture +def sample_patterns(): + """Provide sample regex patterns for testing.""" + return { + "simple_literal": "hello", + "character_class": "[a-z]", + "quantifier_plus": "a+", + "quantifier_star": "b*", + "quantifier_question": "c?", + "digit": "\\d{3}", + "word": "\\w+", + "email": "[a-z]+@[a-z]+\\.[a-z]+", + "phone": "\\d{3}-\\d{4}", + "ip_address": "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}", + "url": "https?://[^\\s]+", + "date": "\\d{4}-\\d{2}-\\d{2}", + "group": "(hello)\\s+(world)", + "named_group": "(?P[a-z]+)", + "lookahead": "\\d+(?=px)", + "negative_lookahead": "\\d+(?!px)", + "lookbehind": "(?<=\\$)\\d+", + "non_capturing": "(?:hello)\\s+(?:world)", + "alternation": "cat|dog", + "anchor_start": "^start", + "anchor_end": "end$", + "word_boundary": "\\bword\\b", + "complex": "^(?:http|https)://[\\w.-]+\\.(?:com|org|net)$", + } + + +@pytest.fixture +def flavor_manager(): + """Provide the flavor manager.""" + from regex_humanizer.flavors import get_flavor_manager + return get_flavor_manager() diff --git a/tests/fixtures/README_EXAMPLE.md b/tests/fixtures/README_EXAMPLE.md new file mode 100644 index 0000000..c725a1d --- /dev/null +++ b/tests/fixtures/README_EXAMPLE.md @@ -0,0 +1,75 @@ +# Example Generated README + +This is an example of a README file that can be generated by Auto README Generator CLI. + +## Overview + +A Python project located at `/example/project` containing multiple files. + +## Supported Languages + +This project uses: +- **Python** + +## Installation + +```bash +pip install -r requirements.txt +pip install -e . +``` + +### Dependencies + +- `requests` v2.31.0 +- `click` v8.0.0 + +## Usage + +### Basic Usage + +```python +from project_name import main +main() +``` + +## Features + +- Test suite included +- Uses 2 dependencies +- Contains 1 classes +- Contains 3 functions + +## API Reference + +### `hello()` + +Say hello. + +**Parameters:** + +### `add(a, b)` + +Add two numbers. + +**Parameters:** `a, b` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +For Python development: +- Run tests with `pytest` +- Format code with `black` and `isort` +- Check types with `mypy` + +## License + +MIT + +--- + +*Generated by Auto README Generator on 2024-01-15* diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..fabdc2c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,253 @@ +"""Tests for the CLI interface.""" + +import json +import pytest +from click.testing import CliRunner +from regex_humanizer.cli import main, explain, test, flavors, validate, convert + + +class TestCLIMain: + """Test the main CLI entry point.""" + + def test_main_help(self): + """Test that main help works.""" + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + + assert result.exit_code == 0 + assert "Regex Humanizer" in result.output + assert "explain" in result.output + assert "test" in result.output + assert "interactive" in result.output + + +class TestCLIExplain: + """Test the explain command.""" + + def test_explain_simple_literal(self): + """Test explaining a simple literal.""" + runner = CliRunner() + result = runner.invoke(explain, ["hello"]) + + assert result.exit_code == 0 + assert "Pattern" in result.output or "hello" in result.output + + def test_explain_with_flavor_option(self): + """Test explaining with a flavor option.""" + runner = CliRunner() + result = runner.invoke(explain, ["--flavor", "javascript", "test"]) + + assert result.exit_code == 0 + assert "Flavor" in result.output or "javascript" in result.output + + def test_explain_with_json_output(self): + """Test explaining with JSON output.""" + runner = CliRunner() + result = runner.invoke(explain, ["--output", "json", "\\d+"]) + + assert result.exit_code == 0 + assert "{", "}" in result.output + + def test_explain_with_verbose(self): + """Test explaining with verbose flag.""" + runner = CliRunner() + result = runner.invoke(explain, ["--verbose", "\\d+"]) + + assert result.exit_code == 0 + assert "Features" in result.output or "digit" in result.output.lower() + + def test_explain_complex_pattern(self): + """Test explaining a complex pattern.""" + runner = CliRunner() + pattern = r"^(?:http|https)://[\w.-]+\.(?:com|org|net)$" + result = runner.invoke(explain, [pattern]) + + assert result.exit_code == 0 + assert "Pattern" in result.output + + def test_explain_phone_pattern(self): + """Test explaining a phone pattern.""" + runner = CliRunner() + result = runner.invoke(explain, [r"\d{3}-\d{4}"]) + + assert result.exit_code == 0 + + def test_explain_character_class(self): + """Test explaining a character class.""" + runner = CliRunner() + result = runner.invoke(explain, ["[a-zA-Z]+"]) + + assert result.exit_code == 0 + + +class TestCLITest: + """Test the test command.""" + + def test_test_simple_literal(self): + """Test generating test cases for a simple literal.""" + runner = CliRunner() + result = runner.invoke(test, ["hello"]) + + assert result.exit_code == 0 + assert "Matching" in result.output or "hello" in result.output + assert "Non-matching" in result.output + + def test_test_with_count_option(self): + """Test generating a specific number of test cases.""" + runner = CliRunner() + result = runner.invoke(test, ["--count", "3", "a"]) + + assert result.exit_code == 0 + + def test_test_with_json_output(self): + """Test generating test cases with JSON output.""" + runner = CliRunner() + result = runner.invoke(test, ["--output", "json", "test"]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert "matching" in data + assert "non_matching" in data + + def test_test_phone_pattern(self): + """Test generating test cases for a phone pattern.""" + runner = CliRunner() + result = runner.invoke(test, [r"\d{3}-\d{4}"]) + + assert result.exit_code == 0 + assert "Matching" in result.output + + def test_test_email_pattern(self): + """Test generating test cases for an email pattern.""" + runner = CliRunner() + result = runner.invoke(test, [r"[a-z]+@[a-z]+\.[a-z]+"]) + + assert result.exit_code == 0 + + def test_test_quantifier_pattern(self): + """Test generating test cases for a quantifier pattern.""" + runner = CliRunner() + result = runner.invoke(test, ["a{2,4}"]) + + assert result.exit_code == 0 + + +class TestCLIFlavors: + """Test the flavors command.""" + + def test_flavors_list(self): + """Test listing available flavors.""" + runner = CliRunner() + result = runner.invoke(flavors) + + assert result.exit_code == 0 + assert "pcre" in result.output.lower() + assert "javascript" in result.output.lower() + assert "python" in result.output.lower() + + +class TestCLIValidate: + """Test the validate command.""" + + def test_validate_valid_pattern(self): + """Test validating a valid pattern.""" + runner = CliRunner() + result = runner.invoke(validate, ["hello"]) + + assert result.exit_code == 0 + assert "PASSED" in result.output or "Validation" in result.output + + def test_validate_with_flavor(self): + """Test validating with a specific flavor.""" + runner = CliRunner() + result = runner.invoke(validate, ["--flavor", "javascript", "test"]) + + assert result.exit_code == 0 + + def test_validate_complex_pattern(self): + """Test validating a complex pattern.""" + runner = CliRunner() + pattern = r"^(?:http|https)://[\w.-]+\.(?:com|org|net)$" + result = runner.invoke(validate, [pattern]) + + assert result.exit_code == 0 + + +class TestCLIConvert: + """Test the convert command.""" + + def test_convert_pcre_to_js(self): + """Test converting a pattern from PCRE to JavaScript.""" + runner = CliRunner() + result = runner.invoke(convert, ["(?Phello)", "--from-flavor", "pcre", "--to-flavor", "javascript"]) + + assert result.exit_code == 0 + assert "Converted" in result.output + + def test_convert_with_defaults(self): + """Test converting with default flavors.""" + runner = CliRunner() + result = runner.invoke(convert, ["test"]) + + assert result.exit_code == 0 + assert "Original" in result.output + assert "Converted" in result.output + + +class TestCLIInteractive: + """Test the interactive command.""" + + def test_interactive_command_exists(self): + """Test that interactive command is available.""" + runner = CliRunner() + result = runner.invoke(main, ["interactive", "--help"]) + + assert result.exit_code == 0 + assert "interactive" in result.output.lower() + + def test_interactive_with_flavor(self): + """Test interactive mode with a specific flavor.""" + runner = CliRunner() + result = runner.invoke(main, ["interactive", "--flavor", "python"]) + + assert result.exit_code == 0 + + +class TestCLIIntegration: + """Integration tests for CLI.""" + + def test_flavor_option_global(self): + """Test that global flavor option works.""" + runner = CliRunner() + result = runner.invoke(main, ["--flavor", "python", "explain", "test"]) + + assert result.exit_code == 0 + + def test_error_handling_invalid_pattern(self): + """Test error handling for invalid patterns.""" + runner = CliRunner() + result = runner.invoke(explain, ["["]) + + assert result.exit_code == 0 + + def test_output_json_structure(self): + """Test JSON output has correct structure.""" + runner = CliRunner() + result = runner.invoke(explain, ["--output", "json", "\\d+"]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert "pattern" in data + assert "flavor" in data + assert "explanation" in data + + def test_output_test_json_structure(self): + """Test JSON test output has correct structure.""" + runner = CliRunner() + result = runner.invoke(test, ["--output", "json", "\\d"]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert "pattern" in data + assert "matching" in data + assert "non_matching" in data diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..01406e1 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,134 @@ +"""Tests for configuration management.""" + +import os + +import pytest + +from mcp_server_cli.config import ( + ConfigManager, + create_config_template, + load_config_from_path, +) +from mcp_server_cli.models import AppConfig, LocalLLMConfig, ServerConfig + + +class TestConfigManager: + """Tests for ConfigManager.""" + + def test_load_default_config(self, tmp_path): + """Test loading default configuration.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("") + + manager = ConfigManager(config_path) + config = manager.load() + + assert isinstance(config, AppConfig) + assert config.server.port == 3000 + assert config.server.host == "127.0.0.1" + + def test_load_config_with_values(self, tmp_path): + """Test loading configuration with custom values.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(""" +server: + host: "127.0.0.1" + port: 8080 + log_level: "DEBUG" + +llm: + enabled: false + base_url: "http://localhost:11434" + model: "llama2" + +security: + allowed_commands: + - ls + - cat + - echo + blocked_paths: + - /etc + - /root +""") + + manager = ConfigManager(config_file) + config = manager.load() + + assert config.server.port == 8080 + assert config.server.host == "127.0.0.1" + assert config.server.log_level == "DEBUG" + + +class TestConfigFromPath: + """Tests for loading config from path.""" + + def test_load_from_path_success(self, tmp_path): + """Test successful config loading from path.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("server:\n port: 8080") + + config = load_config_from_path(str(config_file)) + assert config.server.port == 8080 + + def test_load_from_path_not_found(self): + """Test loading from nonexistent path.""" + with pytest.raises(FileNotFoundError): + load_config_from_path("/nonexistent/path/config.yaml") + + +class TestConfigTemplate: + """Tests for configuration template.""" + + def test_create_template(self): + """Test creating a config template.""" + template = create_config_template() + + assert "server" in template + assert "llm" in template + assert "security" in template + assert "tools" in template + + def test_template_has_required_fields(self): + """Test that template has all required fields.""" + template = create_config_template() + + assert template["server"]["port"] == 3000 + assert "allowed_commands" in template["security"] + + +class TestConfigValidation: + """Tests for configuration validation.""" + + def test_valid_config(self): + """Test creating a valid config.""" + config = AppConfig( + server=ServerConfig(port=4000, host="localhost"), + llm=LocalLLMConfig(enabled=True, base_url="http://localhost:1234"), + ) + assert config.server.port == 4000 + assert config.llm.enabled is True + + def test_config_with_empty_tools(self): + """Test config with empty tools list.""" + config = AppConfig(tools=[]) + assert len(config.tools) == 0 + + +class TestEnvVarMapping: + """Tests for environment variable mappings.""" + + def test_get_env_var_name(self): + """Test environment variable name generation.""" + manager = ConfigManager() + assert manager.get_env_var_name("server.port") == "MCP_SERVER_PORT" + assert manager.get_env_var_name("host") == "MCP_HOST" + + def test_get_from_env(self): + """Test getting values from environment.""" + manager = ConfigManager() + os.environ["MCP_TEST_VAR"] = "test_value" + try: + result = manager.get_from_env("test_var") + assert result == "test_value" + finally: + del os.environ["MCP_TEST_VAR"] diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..7a55896 --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,224 @@ +"""Tests for the test case generator.""" + +import re +import pytest +from regex_humanizer.test_generator import ( + TestCaseGenerator, + generate_test_cases, +) + + +class TestTestCaseGenerator: + """Test cases for TestCaseGenerator.""" + + def test_generate_simple_literal(self): + """Test generating test cases for a simple literal.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("hello", count=3) + + assert len(matching) >= 1 + assert "hello" in matching or len(matching[0]) > 0 + + def test_generate_character_class(self): + """Test generating test cases for a character class.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("[abc]", count=3) + + assert len(matching) >= 1 + assert len(matching[0]) == 1 + assert matching[0] in ["a", "b", "c"] + + def test_generate_quantifier_plus(self): + """Test generating test cases for plus quantifier.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("a+", count=3) + + assert len(matching) >= 1 + assert "a" in matching[0] + + def test_generate_quantifier_star(self): + """Test generating test cases for star quantifier.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("b*", count=3) + + assert len(matching) >= 1 + assert all("b" in m for m in matching) + + def test_generate_quantifier_question(self): + """Test generating test cases for question quantifier.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("c?", count=3) + + assert len(matching) >= 1 + assert len(matching[0]) <= 1 + + def test_generate_digit_class(self): + """Test generating test cases for digit class.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("\\d+", count=3) + + assert len(matching) >= 1 + assert matching[0].isdigit() + + def test_generate_word_class(self): + """Test generating test cases for word class.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("\\w+", count=3) + + assert len(matching) >= 1 + + def test_generate_non_matching_simple(self): + """Test generating non-matching test cases.""" + generator = TestCaseGenerator("pcre") + non_matching = generator.generate_non_matching("hello", count=3) + + assert len(non_matching) >= 1 + pattern = re.compile("hello") + for test_str in non_matching: + assert pattern.search(test_str) is None + + def test_generate_non_matching_character_class(self): + """Test generating non-matching cases for character class.""" + generator = TestCaseGenerator("pcre") + non_matching = generator.generate_non_matching("[abc]", count=3) + + assert len(non_matching) >= 1 + for test_str in non_matching: + if len(test_str) > 0: + assert test_str[0] not in ["a", "b", "c"] + + def test_generate_phone_pattern(self): + """Test generating test cases for phone pattern.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("\\d{3}-\\d{4}", count=3) + + assert len(matching) >= 1 + assert "-" in matching[0] + parts = matching[0].split("-") + assert len(parts) == 2 + assert len(parts[0]) == 3 + assert len(parts[1]) == 4 + + def test_generate_with_flavor_js(self): + """Test generating test cases with JavaScript flavor.""" + generator = TestCaseGenerator("javascript") + matching = generator.generate_matching("test", count=2) + + assert len(matching) >= 1 + + def test_generate_with_flavor_python(self): + """Test generating test cases with Python flavor.""" + generator = TestCaseGenerator("python") + matching = generator.generate_matching("test", count=2) + + assert len(matching) >= 1 + + def test_generate_matching_count(self): + """Test that correct number of matching cases are generated.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("a", count=10) + + assert len(matching) <= 10 + + def test_generate_non_matching_count(self): + """Test that correct number of non-matching cases are generated.""" + generator = TestCaseGenerator("pcre") + non_matching = generator.generate_non_matching("a", count=10) + + assert len(non_matching) <= 10 + + def test_generate_complex_pattern(self): + """Test generating test cases for a complex pattern.""" + pattern = r"^(?:http|https)://[\w.-]+\.(?:com|org|net)$" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching(pattern, count=3) + + assert len(matching) >= 1 + + def test_generate_test_cases_function(self): + """Test the generate_test_cases convenience function.""" + result = generate_test_cases("test", flavor="pcre", matching_count=2, non_matching_count=2) + + assert "pattern" in result + assert "flavor" in result + assert "matching" in result + assert "non_matching" in result + assert len(result["matching"]) >= 1 + assert len(result["non_matching"]) >= 1 + + def test_generate_group_pattern(self): + """Test generating test cases for a pattern with groups.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("(hello)\\s+(world)", count=3) + + assert len(matching) >= 1 + assert " " in matching[0] or len(matching[0]) > 5 + + def test_generate_alternation(self): + """Test generating test cases for alternation.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("cat|dog", count=3) + + assert len(matching) >= 1 + assert matching[0] in ["cat", "dog"] + + def test_generate_empty_pattern(self): + """Test generating test cases for an empty pattern.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("", count=3) + + assert len(matching) >= 0 + + def test_generate_anchored_pattern(self): + """Test generating test cases for anchored patterns.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("^start", count=3) + + assert len(matching) >= 1 + assert matching[0].startswith("start") + + def test_generate_dotted_pattern(self): + """Test generating test cases for a pattern with dots.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("a.b", count=3) + + assert len(matching) >= 1 + assert len(matching[0]) == 3 + assert matching[0][0] == "a" + assert matching[0][1] != "." + + +class TestTestCaseGeneratorEdgeCases: + """Test edge cases for TestCaseGenerator.""" + + def test_generate_with_max_length(self): + """Test that max_length is respected.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("a+", count=5, max_length=5) + + for m in matching: + if len(m) > 0: + assert len(m) <= 5 + + def test_generate_nested_groups(self): + """Test generating test cases for nested groups.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("((a)(b))", count=3) + + assert len(matching) >= 1 + + def test_generate_quantifier_range(self): + """Test generating test cases for range quantifier.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("a{2,4}", count=3) + + assert len(matching) >= 1 + assert 2 <= len(matching[0]) <= 4 + assert matching[0] == "a" * len(matching[0]) + + def test_generate_lookahead_pattern(self): + """Test generating test cases for a lookahead pattern.""" + generator = TestCaseGenerator("pcre") + matching = generator.generate_matching("(?=test)", count=3) + + assert len(matching) >= 0 diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py new file mode 100644 index 0000000..628271c --- /dev/null +++ b/tests/test_gitignore.py @@ -0,0 +1,116 @@ +"""Tests for gitignore generation.""" + +import tempfile +from pathlib import Path + +import pytest + +from project_scaffold_cli.gitignore import GitignoreGenerator + + +class TestGitignoreGenerator: + """Test GitignoreGenerator class.""" + + def test_generator_initialization(self): + """Test generator can be initialized.""" + gen = GitignoreGenerator() + assert gen is not None + + def test_generate_python_gitignore(self): + """Test generating Python .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("python", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "__pycache__" in content + assert "*.pyc" in content + assert "venv/" in content + assert ".pytest_cache" in content + + def test_generate_nodejs_gitignore(self): + """Test generating Node.js .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("nodejs", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "node_modules" in content + assert "npm-debug.log" in content + + def test_generate_go_gitignore(self): + """Test generating Go .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("go", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "*.exe" in content + assert "vendor/" in content + + def test_generate_rust_gitignore(self): + """Test generating Rust .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("rust", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "target/" in content + assert "Cargo.lock" in content + + def test_generate_unsupported_language(self): + """Test generating gitignore for unsupported language.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(ValueError) as exc_info: + gen.generate("unsupported", Path(tmpdir) / ".gitignore") + assert "Unsupported language" in str(exc_info.value) + + def test_append_patterns(self): + """Test appending patterns to existing .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + gitignore_path = Path(tmpdir) / ".gitignore" + gitignore_path.write_text("# Original content\n") + + gen.append_patterns(gitignore_path, {"*.custom", "secret.txt"}) + + content = gitignore_path.read_text() + assert "*.custom" in content + assert "secret.txt" in content + + def test_get_template_content(self): + """Test getting raw template content.""" + gen = GitignoreGenerator() + + python_content = gen.get_template_content("python") + assert isinstance(python_content, str) + assert len(python_content) > 0 + + nodejs_content = gen.get_template_content("nodejs") + assert isinstance(nodejs_content, str) + assert len(nodejs_content) > 0 + + def test_list_available_templates(self): + """Test listing available templates.""" + gen = GitignoreGenerator() + templates = gen.list_available_templates() + assert isinstance(templates, list) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..5dafff1 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,313 @@ +"""Tests for the regex parser.""" + +import pytest +from regex_humanizer.parser import ( + RegexParser, + parse_regex, + NodeType, + RegexNode, + LiteralNode, + CharacterClassNode, + QuantifierNode, + GroupNode, +) + + +class TestRegexParser: + """Test cases for RegexParser.""" + + def test_parse_simple_literal(self): + """Test parsing a simple literal string.""" + parser = RegexParser("hello") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) == 1 + assert ast.children[0].node_type == NodeType.LITERAL + assert ast.children[0].value == "hello" + + def test_parse_digit_shorthand(self): + """Test parsing digit shorthand character class.""" + parser = RegexParser("\\d+") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) == 1 + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + assert quantifier.min_count == 1 + + def test_parse_character_class(self): + """Test parsing a character class.""" + parser = RegexParser("[a-z]") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) == 1 + child = ast.children[0] + assert child.node_type == NodeType.POSITIVE_SET + assert "a" in child.characters or len(child.ranges) > 0 + + def test_parse_negated_character_class(self): + """Test parsing a negated character class.""" + parser = RegexParser("[^0-9]") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + child = ast.children[0] + assert child.node_type == NodeType.NEGATIVE_SET + assert child.negated is True + + def test_parse_plus_quantifier(self): + """Test parsing a plus quantifier.""" + parser = RegexParser("a+") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) == 1 + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + assert quantifier.min_count == 1 + assert quantifier.max_count == float('inf') + + def test_parse_star_quantifier(self): + """Test parsing a star quantifier.""" + parser = RegexParser("b*") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) == 1 + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + assert quantifier.min_count == 0 + assert quantifier.max_count == float('inf') + + def test_parse_question_quantifier(self): + """Test parsing a question mark quantifier.""" + parser = RegexParser("c?") + ast = parser.parse() + + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + assert quantifier.min_count == 0 + assert quantifier.max_count == 1 + + def test_parse_range_quantifier(self): + """Test parsing a range quantifier like {2,5}.""" + parser = RegexParser("a{2,5}") + ast = parser.parse() + + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + assert quantifier.min_count == 2 + assert quantifier.max_count == 5 + + def test_parse_lazy_quantifier(self): + """Test parsing a lazy quantifier.""" + parser = RegexParser("a+?") + ast = parser.parse() + + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + assert quantifier.is_lazy is True + + def test_parse_capturing_group(self): + """Test parsing a capturing group.""" + parser = RegexParser("(hello)") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) == 1 + group = ast.children[0] + assert group.node_type == NodeType.CAPTURING_GROUP + assert not group.is_non_capturing + + def test_parse_non_capturing_group(self): + """Test parsing a non-capturing group.""" + parser = RegexParser("(?:hello)") + ast = parser.parse() + + group = ast.children[0] + assert group.node_type == NodeType.NON_CAPTURING_GROUP + assert group.is_non_capturing is True + + def test_parse_named_group(self): + """Test parsing a named group.""" + parser = RegexParser("(?Phello)") + ast = parser.parse() + + group = ast.children[0] + assert group.node_type == NodeType.NAMED_GROUP + assert group.name == "name" + + def test_parse_positive_lookahead(self): + """Test parsing a positive lookahead.""" + parser = RegexParser("(?=test)") + ast = parser.parse() + + group = ast.children[0] + assert group.node_type == NodeType.LOOKAHEAD + + def test_parse_negative_lookahead(self): + """Test parsing a negative lookahead.""" + parser = RegexParser("(?!test)") + ast = parser.parse() + + group = ast.children[0] + assert group.node_type == NodeType.NEGATIVE_LOOKAHEAD + + def test_parse_lookbehind(self): + """Test parsing a lookbehind.""" + parser = RegexParser("(?<=test)") + ast = parser.parse() + + group = ast.children[0] + assert group.node_type == NodeType.LOOKBEHIND + + def test_parse_anchor_start(self): + """Test parsing a start anchor.""" + parser = RegexParser("^start") + ast = parser.parse() + + assert ast.children[0].node_type == NodeType.ANCHOR_START + + def test_parse_anchor_end(self): + """Test parsing an end anchor.""" + parser = RegexParser("end$") + ast = parser.parse() + + anchor = ast.children[-1] + assert anchor.node_type == NodeType.ANCHOR_END + + def test_parse_word_boundary(self): + """Test parsing a word boundary.""" + parser = RegexParser("\\bword\\b") + ast = parser.parse() + + assert any(child.node_type == NodeType.WORD_BOUNDARY for child in ast.children) + + def test_parse_dot(self): + """Test parsing a dot (any character).""" + parser = RegexParser(".") + ast = parser.parse() + + assert ast.children[0].node_type == NodeType.DOT + + def test_parse_complex_pattern(self): + """Test parsing a complex regex pattern.""" + pattern = r"^(?:http|https)://[\w.-]+\.(?:com|org|net)$" + parser = RegexParser(pattern) + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) > 0 + + def test_parse_alternation(self): + """Test parsing alternation with pipe.""" + parser = RegexParser("cat|dog") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) >= 1 + + def test_parse_escaped_character(self): + """Test parsing escaped characters.""" + parser = RegexParser("\\.") + ast = parser.parse() + + assert len(ast.children) > 0 + + def test_parse_whitespace_shorthand(self): + """Test parsing whitespace shorthand.""" + parser = RegexParser("\\s+") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + quantifier = ast.children[0] + assert quantifier.node_type == NodeType.QUANTIFIER + + def test_parse_word_char_shorthand(self): + """Test parsing word character shorthand.""" + parser = RegexParser("\\w*") + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + + def test_parse_hex_escape(self): + """Test parsing hex escape sequence.""" + parser = RegexParser("\\x41") + ast = parser.parse() + + assert len(ast.children) > 0 + + def test_parse_backreference(self): + """Test parsing a backreference.""" + parser = RegexParser("(a)\\1") + ast = parser.parse() + + assert len(ast.children) > 0 + + def test_parse_empty_group(self): + """Test parsing an empty group.""" + parser = RegexParser("()") + ast = parser.parse() + + group = ast.children[0] + assert group.node_type == NodeType.CAPTURING_GROUP + + def test_parse_nested_groups(self): + """Test parsing nested groups.""" + parser = RegexParser("((a)(b))") + ast = parser.parse() + + assert len(ast.children) == 1 + + def test_errors_empty(self): + """Test that valid patterns have no errors.""" + parser = RegexParser("hello") + parser.parse() + + assert len(parser.get_errors()) == 0 + + def test_parse_email_pattern(self): + """Test parsing a typical email regex pattern.""" + pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + parser = RegexParser(pattern) + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + assert len(ast.children) > 0 + + def test_parse_phone_pattern(self): + """Test parsing a phone number regex.""" + pattern = r"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}" + parser = RegexParser(pattern) + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + + def test_parse_date_pattern(self): + """Test parsing a date regex.""" + pattern = r"\d{4}-\d{2}-\d{2}" + parser = RegexParser(pattern) + ast = parser.parse() + + assert ast.node_type == NodeType.SEQUENCE + + +class TestNodeTypes: + """Test node type enumeration.""" + + def test_node_type_values(self): + """Test that all expected node types exist.""" + expected_types = [ + "LITERAL", "CHARACTER_CLASS", "POSITIVE_SET", "NEGATIVE_SET", + "DOT", "GROUP", "CAPTURING_GROUP", "NON_CAPTURING_GROUP", + "NAMED_GROUP", "LOOKAHEAD", "LOOKBEHIND", "NEGATIVE_LOOKAHEAD", + "NEGATIVE_LOOKBEHIND", "QUANTIFIER", "ANCHOR_START", "ANCHOR_END", + "WORD_BOUNDARY", "START_OF_STRING", "END_OF_STRING", "DIGIT", + "NON_DIGIT", "WORD_CHAR", "WHITESPACE", "BACKREFERENCE", + ] + + for type_name in expected_types: + assert hasattr(NodeType, type_name), f"Missing node type: {type_name}" diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..d7ad290 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,241 @@ +"""Tests for dependency parsers.""" + +import tempfile +from pathlib import Path + +from src.auto_readme.parsers import ( + DependencyParserFactory, + GoDependencyParser, + JavaScriptDependencyParser, + PythonDependencyParser, + RustDependencyParser, +) + + +class TestPythonDependencyParser: + """Tests for PythonDependencyParser.""" + + def test_can_parse_requirements_txt(self): + """Test that parser recognizes requirements.txt.""" + parser = PythonDependencyParser() + with tempfile.TemporaryDirectory() as tmp_dir: + req_file = Path(tmp_dir) / "requirements.txt" + req_file.write_text("requests>=2.31.0\n") + assert parser.can_parse(req_file) + + def test_can_parse_pyproject_toml(self): + """Test that parser recognizes pyproject.toml.""" + parser = PythonDependencyParser() + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_file = Path(tmp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\ndependencies = ["requests>=2.0"]') + assert parser.can_parse(pyproject_file) + + def test_parse_requirements_txt(self): + """Test parsing requirements.txt file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + req_file = Path(tmp_dir) / "requirements.txt" + req_file.write_text(""" +requests>=2.31.0 +click>=8.0.0 +pytest==7.0.0 +-e git+https://github.com/user/repo.git#egg=package +# This is a comment +numpy~=1.24.0 +""") + + parser = PythonDependencyParser() + deps = parser.parse(req_file) + + assert len(deps) == 5 + names = {d.name for d in deps} + assert "requests" in names + assert "click" in names + assert "pytest" in names + assert "numpy" in names + + def test_parse_pyproject_toml(self): + """Test parsing pyproject.toml file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_file = Path(tmp_dir) / "pyproject.toml" + pyproject_file.write_text(""" +[project] +name = "test-project" +version = "0.1.0" +dependencies = [ + "requests>=2.31.0", + "click>=8.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0"] +""") + + parser = PythonDependencyParser() + deps = parser.parse(pyproject_file) + + assert len(deps) == 4 + + +class TestJavaScriptDependencyParser: + """Tests for JavaScriptDependencyParser.""" + + def test_can_parse_package_json(self): + """Test that parser recognizes package.json.""" + parser = JavaScriptDependencyParser() + with tempfile.TemporaryDirectory() as tmp_dir: + package_file = Path(tmp_dir) / "package.json" + package_file.write_text('{"name": "test"}') + assert parser.can_parse(package_file) + + def test_parse_package_json(self): + """Test parsing package.json file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + package_file = Path(tmp_dir) / "package.json" + package_file.write_text(""" +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.0", + "lodash": "~4.17.0" + }, + "devDependencies": { + "jest": "^29.0.0" + }, + "optionalDependencies": { + "bcrypt": "^5.0.0" + } +} +""") + + parser = JavaScriptDependencyParser() + deps = parser.parse(package_file) + + assert len(deps) == 4 + + express = next((d for d in deps if d.name == "express"), None) + assert express is not None + assert express.version == "4.18.0" + assert not express.is_dev + + jest = next((d for d in deps if d.name == "jest"), None) + assert jest is not None + assert jest.is_dev + + bcrypt = next((d for d in deps if d.name == "bcrypt"), None) + assert bcrypt is not None + assert bcrypt.is_optional + + +class TestGoDependencyParser: + """Tests for GoDependencyParser.""" + + def test_can_parse_go_mod(self): + """Test that parser recognizes go.mod.""" + parser = GoDependencyParser() + with tempfile.TemporaryDirectory() as tmp_dir: + go_mod_file = Path(tmp_dir) / "go.mod" + go_mod_file.write_text("module test\n") + assert parser.can_parse(go_mod_file) + + def test_parse_go_mod(self): + """Test parsing go.mod file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + go_mod_file = Path(tmp_dir) / "go.mod" + go_mod_file.write_text(""" +module test-go-project + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.0 + github.com/stretchr/testify v1.8.0 +) + +require github.com/user/repo v1.0.0 +""") + + parser = GoDependencyParser() + deps = parser.parse(go_mod_file) + + assert len(deps) == 3 + + gin = next((d for d in deps if d.name == "github.com/gin-gonic/gin"), None) + assert gin is not None + assert gin.version is not None and "1.9.0" in gin.version + + +class TestRustDependencyParser: + """Tests for RustDependencyParser.""" + + def test_can_parse_cargo_toml(self): + """Test that parser recognizes Cargo.toml.""" + parser = RustDependencyParser() + with tempfile.TemporaryDirectory() as tmp_dir: + cargo_file = Path(tmp_dir) / "Cargo.toml" + cargo_file.write_text('[package]\nname = "test"') + assert parser.can_parse(cargo_file) + + def test_parse_cargo_toml(self): + """Test parsing Cargo.toml file.""" + with tempfile.TemporaryDirectory() as tmp_dir: + cargo_file = Path(tmp_dir) / "Cargo.toml" + cargo_file.write_text(""" +[package] +name = "test-project" +version = "0.1.0" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } + +[dev-dependencies] +assertions = "0.1" +""") + + parser = RustDependencyParser() + deps = parser.parse(cargo_file) + + assert len(deps) == 3 + + serde = next((d for d in deps if d.name == "serde"), None) + assert serde is not None + assert serde.version is not None and "1.0" in serde.version + assert not serde.is_dev + + assertions = next((d for d in deps if d.name == "assertions"), None) + assert assertions is not None + assert assertions.is_dev + + +class TestDependencyParserFactory: + """Tests for DependencyParserFactory.""" + + def test_get_parser_python(self): + """Test getting parser for Python file.""" + parser = DependencyParserFactory.get_parser(Path("requirements.txt")) + assert isinstance(parser, PythonDependencyParser) + + def test_get_parser_js(self): + """Test getting parser for JavaScript file.""" + parser = DependencyParserFactory.get_parser(Path("package.json")) + assert isinstance(parser, JavaScriptDependencyParser) + + def test_get_parser_go(self): + """Test getting parser for Go file.""" + parser = DependencyParserFactory.get_parser(Path("go.mod")) + assert isinstance(parser, GoDependencyParser) + + def test_get_parser_rust(self): + """Test getting parser for Rust file.""" + parser = DependencyParserFactory.get_parser(Path("Cargo.toml")) + assert isinstance(parser, RustDependencyParser) + + def test_can_parse(self): + """Test can_parse returns correct results.""" + assert DependencyParserFactory.can_parse(Path("requirements.txt")) + assert DependencyParserFactory.can_parse(Path("package.json")) + assert DependencyParserFactory.can_parse(Path("go.mod")) + assert DependencyParserFactory.can_parse(Path("Cargo.toml")) + assert not DependencyParserFactory.can_parse(Path("random.txt")) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..4f88334 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,99 @@ +"""Tests for MCP server.""" + +import pytest + +from mcp_server_cli.models import MCPMethod, MCPRequest +from mcp_server_cli.server import MCPServer +from mcp_server_cli.tools import FileTools, GitTools, ShellTools + + +@pytest.fixture +def mcp_server(): + """Create an MCP server with registered tools.""" + server = MCPServer() + server.register_tool(FileTools()) + server.register_tool(GitTools()) + server.register_tool(ShellTools()) + return server + + +class TestMCPServer: + """Tests for MCP server class.""" + + def test_server_creation(self): + """Test server creation.""" + server = MCPServer() + assert server.connection_state.value == "disconnected" + assert len(server.tool_registry) == 0 + + def test_register_tool(self, mcp_server): + """Test tool registration.""" + assert "file_tools" in mcp_server.tool_registry + assert len(mcp_server.list_tools()) == 3 + + def test_get_tool(self, mcp_server): + """Test getting a registered tool.""" + retrieved = mcp_server.get_tool("file_tools") + assert retrieved is not None + assert retrieved.name == "file_tools" + + def test_list_tools(self, mcp_server): + """Test listing all tools.""" + tools = mcp_server.list_tools() + assert len(tools) >= 2 + names = [t.name for t in tools] + assert "file_tools" in names + assert "git_tools" in names + + +class TestMCPProtocol: + """Tests for MCP protocol implementation.""" + + def test_mcp_initialize(self, mcp_server): + """Test MCP initialize request.""" + request = MCPRequest( + id=1, + method=MCPMethod.INITIALIZE, + params={"protocol_version": "2024-11-05"}, + ) + import asyncio + response = asyncio.run(mcp_server.handle_request(request)) + assert response.id == 1 + assert response.result is not None + + def test_mcp_tools_list(self, mcp_server): + """Test MCP tools/list request.""" + request = MCPRequest( + id=2, + method=MCPMethod.TOOLS_LIST, + ) + import asyncio + response = asyncio.run(mcp_server.handle_request(request)) + assert response.id == 2 + assert response.result is not None + + def test_mcp_invalid_method(self, mcp_server): + """Test MCP request with invalid tool.""" + request = MCPRequest( + id=3, + method=MCPMethod.TOOLS_CALL, + params={"name": "nonexistent"}, + ) + import asyncio + response = asyncio.run(mcp_server.handle_request(request)) + assert response.error is not None or response.result.get("is_error") is True + + +class TestToolCall: + """Tests for tool calling.""" + + def test_call_read_file_nonexistent(self, mcp_server): + """Test calling read on nonexistent file.""" + from mcp_server_cli.models import ToolCallParams + params = ToolCallParams( + name="read_file", + arguments={"path": "/nonexistent/file.txt"}, + ) + import asyncio + result = asyncio.run(mcp_server._handle_tool_call(params)) + assert result.is_error is True diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..ba3eb10 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,145 @@ +"""Tests for template engine.""" + +import tempfile +from pathlib import Path + +import pytest + +from project_scaffold_cli.template_engine import TemplateEngine + + +class TestTemplateEngine: + """Test TemplateEngine class.""" + + def test_engine_initialization(self): + """Test engine can be initialized.""" + engine = TemplateEngine() + assert engine is not None + assert engine.SUPPORTED_LANGUAGES == ["python", "nodejs", "go", "rust"] + + def test_render_language_template_python(self): + """Test rendering Python template.""" + engine = TemplateEngine() + context = { + "project_name": "test-project", + "project_slug": "test-project", + "author": "Test Author", + "email": "test@example.com", + "description": "A test project", + "license": "MIT", + "year": "2024", + "language": "python", + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test-project" + output_dir.mkdir() + engine.render_language_template("python", context, output_dir) + + assert (output_dir / "setup.py").exists() + assert (output_dir / "README.md").exists() + + def test_render_language_template_go(self): + """Test rendering Go template.""" + engine = TemplateEngine() + context = { + "project_name": "test-go-project", + "project_slug": "test-go-project", + "author": "Test Author", + "email": "test@example.com", + "description": "A test Go project", + "license": "MIT", + "year": "2024", + "language": "go", + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test-go-project" + output_dir.mkdir() + engine.render_language_template("go", context, output_dir) + + assert (output_dir / "go.mod").exists() + assert (output_dir / "main.go").exists() + assert (output_dir / "README.md").exists() + + def test_render_language_template_rust(self): + """Test rendering Rust template.""" + engine = TemplateEngine() + context = { + "project_name": "test-rust-project", + "project_slug": "test-rust-project", + "author": "Test Author", + "email": "test@example.com", + "description": "A test Rust project", + "license": "MIT", + "year": "2024", + "language": "rust", + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test-rust-project" + output_dir.mkdir() + engine.render_language_template("rust", context, output_dir) + + assert (output_dir / "Cargo.toml").exists() + assert (output_dir / "src" / "main.rs").exists() + assert (output_dir / "README.md").exists() + + def test_render_language_template_unsupported(self): + """Test rendering unsupported language.""" + engine = TemplateEngine() + context = {"project_name": "test"} + + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(ValueError) as exc_info: + engine.render_language_template( + "unsupported", context, Path(tmpdir) + ) + assert "Unsupported language" in str(exc_info.value) + + def test_render_ci_template_github(self): + """Test rendering GitHub Actions CI template.""" + engine = TemplateEngine() + context = { + "project_name": "test-project", + "project_slug": "test-project", + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + engine.render_ci_template("github", context, output_dir) + + workflow_path = ( + output_dir / ".github" / "workflows" / "ci.yml" + ) + assert workflow_path.exists() + content = workflow_path.read_text() + assert "CI" in content + + def test_render_ci_template_gitlab(self): + """Test rendering GitLab CI template.""" + engine = TemplateEngine() + context = { + "project_name": "test-project", + "project_slug": "test-project", + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + engine.render_ci_template("gitlab", context, output_dir) + + assert (output_dir / ".gitlab-ci.yml").exists() + + def test_validate_context_missing_required(self): + """Test context validation with missing required fields.""" + engine = TemplateEngine() + missing = engine.validate_context({}) + assert "project_name" in missing + assert "author" in missing + + def test_validate_context_complete(self): + """Test context validation with all required fields.""" + engine = TemplateEngine() + context = {"project_name": "test", "author": "Test Author"} + missing = engine.validate_context(context) + assert len(missing) == 0 diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..89aad6e --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,321 @@ +"""Tests for tool execution engine and built-in tools.""" + +from pathlib import Path + +import pytest + +from mcp_server_cli.models import ToolParameter, ToolSchema +from mcp_server_cli.tools.base import ToolBase, ToolRegistry, ToolResult +from mcp_server_cli.tools.file_tools import ( + GlobFilesTool, + ListDirectoryTool, + ReadFileTool, + WriteFileTool, +) +from mcp_server_cli.tools.git_tools import GitTools +from mcp_server_cli.tools.shell_tools import ExecuteCommandTool + + +class TestToolBase: + """Tests for ToolBase abstract class.""" + + def test_tool_validation(self): + """Test tool argument validation.""" + class TestTool(ToolBase): + def __init__(self): + super().__init__( + name="test_tool", + description="A test tool", + ) + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema( + properties={ + "name": ToolParameter( + name="name", + type="string", + required=True, + ), + "count": ToolParameter( + name="count", + type="integer", + enum=["1", "2", "3"], + ), + }, + required=["name"], + ) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + tool = TestTool() + + result = tool.validate_arguments({"name": "test"}) + assert result["name"] == "test" + + def test_missing_required_param(self): + """Test that missing required parameters raise error.""" + class TestTool(ToolBase): + def __init__(self): + super().__init__(name="test_tool", description="A test tool") + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema( + properties={ + "required_param": ToolParameter( + name="required_param", + type="string", + required=True, + ), + }, + required=["required_param"], + ) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + tool = TestTool() + + with pytest.raises(ValueError, match="Missing required parameter"): + tool.validate_arguments({}) + + def test_invalid_enum_value(self): + """Test that invalid enum values raise error.""" + class TestTool(ToolBase): + def __init__(self): + super().__init__(name="test_tool", description="A test tool") + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema( + properties={ + "color": ToolParameter( + name="color", + type="string", + enum=["red", "green", "blue"], + ), + }, + ) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + tool = TestTool() + + with pytest.raises(ValueError, match="Invalid value"): + tool.validate_arguments({"color": "yellow"}) + + +class TestToolRegistry: + """Tests for ToolRegistry.""" + + def test_register_and_get(self, tool_registry: ToolRegistry): + """Test registering and retrieving a tool.""" + class TestTool(ToolBase): + def __init__(self): + super().__init__(name="test_tool", description="A test tool") + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema(properties={}, required=[]) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + tool = TestTool() + tool_registry.register(tool) + + retrieved = tool_registry.get("test_tool") + assert retrieved is tool + assert retrieved.name == "test_tool" + + def test_unregister(self, tool_registry: ToolRegistry): + """Test unregistering a tool.""" + class TestTool(ToolBase): + def __init__(self): + super().__init__(name="test_tool", description="A test tool") + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema(properties={}, required=[]) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + tool = TestTool() + tool_registry.register(tool) + + assert tool_registry.unregister("test_tool") is True + assert tool_registry.get("test_tool") is None + + def test_list_tools(self, tool_registry: ToolRegistry): + """Test listing all tools.""" + class TestTool1(ToolBase): + def __init__(self): + super().__init__(name="tool1", description="Tool 1") + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema(properties={}, required=[]) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + class TestTool2(ToolBase): + def __init__(self): + super().__init__(name="tool2", description="Tool 2") + + def _create_input_schema(self) -> ToolSchema: + return ToolSchema(properties={}, required=[]) + + async def execute(self, arguments) -> ToolResult: + return ToolResult(success=True, output="OK") + + tool_registry.register(TestTool1()) + tool_registry.register(TestTool2()) + + tools = tool_registry.list() + assert len(tools) == 2 + assert tool_registry.list_names() == ["tool1", "tool2"] + + +class TestFileTools: + """Tests for file operation tools.""" + + @pytest.mark.asyncio + async def test_read_file(self, temp_file: Path): + """Test reading a file.""" + tool = ReadFileTool() + result = await tool.execute({"path": str(temp_file)}) + + assert result.success is True + assert "Hello, World!" in result.output + + @pytest.mark.asyncio + async def test_read_nonexistent_file(self, temp_dir: Path): + """Test reading a nonexistent file.""" + tool = ReadFileTool() + result = await tool.execute({"path": str(temp_dir / "nonexistent.txt")}) + + assert result.success is False + assert "not found" in result.error.lower() + + @pytest.mark.asyncio + async def test_write_file(self, temp_dir: Path): + """Test writing a file.""" + tool = WriteFileTool() + result = await tool.execute({ + "path": str(temp_dir / "new_file.txt"), + "content": "New content", + }) + + assert result.success is True + assert (temp_dir / "new_file.txt").read_text() == "New content" + + @pytest.mark.asyncio + async def test_list_directory(self, temp_dir: Path): + """Test listing a directory.""" + tool = ListDirectoryTool() + result = await tool.execute({"path": str(temp_dir)}) + + assert result.success is True + + @pytest.mark.asyncio + async def test_glob_files(self, temp_dir: Path): + """Test glob file search.""" + (temp_dir / "test1.txt").touch() + (temp_dir / "test2.txt").touch() + (temp_dir / "subdir").mkdir() + (temp_dir / "subdir" / "test3.txt").touch() + + tool = GlobFilesTool() + result = await tool.execute({ + "path": str(temp_dir), + "pattern": "**/*.txt", + }) + + assert result.success is True + assert "test1.txt" in result.output + assert "test3.txt" in result.output + + +class TestShellTools: + """Tests for shell execution tools.""" + + @pytest.mark.asyncio + async def test_execute_ls(self): + """Test executing ls command.""" + tool = ExecuteCommandTool() + result = await tool.execute({ + "cmd": ["ls", "-1"], + "timeout": 10, + }) + + assert result.success is True + + @pytest.mark.asyncio + async def test_execute_with_cwd(self, temp_dir: Path): + """Test executing command with working directory.""" + tool = ExecuteCommandTool() + result = await tool.execute({ + "cmd": ["pwd"], + "cwd": str(temp_dir), + }) + + assert result.success is True + + @pytest.mark.asyncio + async def test_execute_nonexistent_command(self): + """Test executing nonexistent command.""" + tool = ExecuteCommandTool() + result = await tool.execute({ + "cmd": ["nonexistent_command_12345"], + }) + + assert result.success is False + assert "no such file" in result.error.lower() + + @pytest.mark.asyncio + async def test_command_timeout(self): + """Test command timeout.""" + tool = ExecuteCommandTool() + result = await tool.execute({ + "cmd": ["sleep", "10"], + "timeout": 1, + }) + + assert result.success is False + assert "timed out" in result.error.lower() + + +class TestGitTools: + """Tests for git tools.""" + + @pytest.mark.asyncio + async def test_git_status_not_in_repo(self, temp_dir: Path): + """Test git status in non-git directory.""" + tool = GitTools() + result = await tool.execute({ + "operation": "status", + "path": str(temp_dir), + }) + + assert result.success is False + assert "not in a git repository" in result.error.lower() + + +class TestToolResult: + """Tests for ToolResult model.""" + + def test_success_result(self): + """Test creating a success result.""" + result = ToolResult(success=True, output="Test output") + assert result.success is True + assert result.output == "Test output" + assert result.error is None + + def test_error_result(self): + """Test creating an error result.""" + result = ToolResult( + success=False, + output="", + error="Something went wrong", + ) + assert result.success is False + assert result.error == "Something went wrong" diff --git a/tests/test_translator.py b/tests/test_translator.py new file mode 100644 index 0000000..8cab3bb --- /dev/null +++ b/tests/test_translator.py @@ -0,0 +1,221 @@ +"""Tests for the regex translator.""" + +import pytest +from regex_humanizer.translator import RegexTranslator, translate_regex + + +class TestRegexTranslator: + """Test cases for RegexTranslator.""" + + def test_translate_simple_literal(self): + """Test translating a simple literal.""" + result = translate_regex("hello") + assert "hello" in result.lower() or "hello" in result + + def test_translate_character_class(self): + """Test translating a character class.""" + result = translate_regex("[a-z]") + assert "any" in result.lower() and ("a" in result.lower() or "z" in result.lower()) + + def test_translate_negated_class(self): + """Test translating a negated character class.""" + result = translate_regex("[^0-9]") + assert "except" in result.lower() or "not" in result.lower() + + def test_translate_digit_class(self): + """Test translating digit shorthand.""" + result = translate_regex("\\d") + assert "digit" in result.lower() + + def test_translate_non_digit_class(self): + """Test translating non-digit shorthand.""" + result = translate_regex("\\D") + assert "non-digit" in result.lower() or "non digit" in result.lower() + + def test_translate_word_class(self): + """Test translating word character shorthand.""" + result = translate_regex("\\w") + assert "word" in result.lower() + + def test_translate_whitespace_class(self): + """Test translating whitespace shorthand.""" + result = translate_regex("\\s") + assert "whitespace" in result.lower() + + def test_translate_plus_quantifier(self): + """Test translating plus quantifier.""" + result = translate_regex("a+") + assert "one or more" in result.lower() or "1 or more" in result.lower() + + def test_translate_star_quantifier(self): + """Test translating star quantifier.""" + result = translate_regex("b*") + assert "zero or more" in result.lower() or "0 or more" in result.lower() + + def test_translate_question_quantifier(self): + """Test translating question mark quantifier.""" + result = translate_regex("c?") + assert "optional" in result.lower() or "0 or 1" in result.lower() + + def test_translate_range_quantifier(self): + """Test translating range quantifier.""" + result = translate_regex("a{2,5}") + assert "between" in result.lower() and ("2" in result or "2" in result.lower()) + + def test_translate_exact_quantifier(self): + """Test translating exact count quantifier.""" + result = translate_regex("x{3}") + assert "exactly" in result.lower() and "3" in result + + def test_translate_lazy_quantifier(self): + """Test translating lazy quantifier.""" + result = translate_regex(".+?") + assert "lazy" in result.lower() + + def test_translate_capturing_group(self): + """Test translating a capturing group.""" + result = translate_regex("(test)") + assert "capturing" in result.lower() or "group" in result.lower() + + def test_translate_non_capturing_group(self): + """Test translating a non-capturing group.""" + result = translate_regex("(?:test)") + assert "non-capturing" in result.lower() + + def test_translate_named_group(self): + """Test translating a named group.""" + result = translate_regex("(?Ptest)") + assert "name" in result.lower() or "named" in result.lower() + + def test_translate_lookahead(self): + """Test translating a positive lookahead.""" + result = translate_regex("(?=test)") + assert "followed" in result.lower() + + def test_translate_negative_lookahead(self): + """Test translating a negative lookahead.""" + result = translate_regex("(?!test)") + assert "not followed" in result.lower() or "not" in result.lower() + + def test_translate_lookbehind(self): + """Test translating a lookbehind.""" + result = translate_regex("(?<=test)") + assert "preceded" in result.lower() + + def test_translate_negative_lookbehind(self): + """Test translating a negative lookbehind.""" + result = translate_regex("(? 0 + assert "digit" in result.lower() or "3" in result + + def test_translate_email_pattern(self): + """Test translating an email pattern.""" + pattern = r"[a-z]+@[a-z]+\.[a-z]+" + result = translate_regex(pattern) + + assert "any" in result.lower() or "@" in result + + def test_translate_url_pattern(self): + """Test translating a URL pattern.""" + pattern = r"https?://[^\s]+" + result = translate_regex(pattern) + + assert len(result) > 0 + + def test_translate_escaped_char(self): + """Test translating an escaped character.""" + result = translate_regex("\\.") + assert "." in result or "period" in result.lower() + + def test_translate_whitespace_literal(self): + """Test translating a literal space.""" + result = translate_regex(" ") + assert "space" in result.lower() + + def test_translate_tab_literal(self): + """Test translating a tab character.""" + result = translate_regex("\\t") + assert "escape" in result.lower() or "\\t" in result or "tab" in result.lower() + + def test_translate_backreference(self): + """Test translating a backreference.""" + result = translate_regex(r"(a)\1") + assert "same as" in result.lower() or "capture" in result.lower() or "\\1" in result + + def test_translate_empty_pattern(self): + """Test translating an empty pattern.""" + result = translate_regex("") + assert len(result) >= 0 + + def test_translate_with_js_flavor(self): + """Test translating with JavaScript flavor.""" + result = translate_regex("test", flavor="javascript") + assert len(result) > 0 + + def test_translate_with_python_flavor(self): + """Test translating with Python flavor.""" + result = translate_regex("test", flavor="python") + assert len(result) > 0 + + +class TestRegexTranslatorClass: + """Test the RegexTranslator class directly.""" + + def test_translator_initialization(self): + """Test translator initialization with default flavor.""" + translator = RegexTranslator() + assert translator.flavor == "pcre" + + def test_translator_custom_flavor(self): + """Test translator initialization with custom flavor.""" + translator = RegexTranslator("javascript") + assert translator.flavor == "javascript" + + def test_translate_returns_string(self): + """Test that translate returns a string.""" + translator = RegexTranslator() + result = translator.translate("test") + assert isinstance(result, str) + + def test_translate_empty_sequence(self): + """Test translating an empty sequence node.""" + from regex_humanizer.parser import RegexParser, NodeType, RegexNode + + parser = RegexParser("") + ast = parser.parse() + translator = RegexTranslator() + + result = translator._translate_node(ast) + assert isinstance(result, str) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..c78cf5c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,131 @@ +"""Tests for utility modules.""" + +import tempfile +from pathlib import Path + +from src.auto_readme.utils.file_scanner import FileScanner +from src.auto_readme.utils.path_utils import PathUtils + + +class TestPathUtils: + """Tests for PathUtils.""" + + def test_normalize_path(self): + """Test path normalization.""" + path = PathUtils.normalize_path("./foo/bar") + assert path.is_absolute() + + def test_is_ignored_dir(self): + """Test ignored directory detection.""" + assert PathUtils.is_ignored_dir(Path("__pycache__")) + assert PathUtils.is_ignored_dir(Path(".git")) + assert PathUtils.is_ignored_dir(Path("node_modules")) + assert not PathUtils.is_ignored_dir(Path("src")) + + def test_is_ignored_file(self): + """Test ignored file detection.""" + assert PathUtils.is_ignored_file(Path(".gitignore")) + assert not PathUtils.is_ignored_file(Path("main.py")) + + def test_is_source_file(self): + """Test source file detection.""" + assert PathUtils.is_source_file(Path("main.py")) + assert PathUtils.is_source_file(Path("index.js")) + assert PathUtils.is_source_file(Path("main.go")) + assert PathUtils.is_source_file(Path("lib.rs")) + assert not PathUtils.is_source_file(Path("README.md")) + + def test_is_config_file(self): + """Test config file detection.""" + assert PathUtils.is_config_file(Path("config.json")) + assert PathUtils.is_config_file(Path("settings.yaml")) + assert PathUtils.is_config_file(Path("pyproject.toml")) + + def test_is_test_file(self): + """Test test file detection.""" + assert PathUtils.is_test_file(Path("test_main.py")) + assert PathUtils.is_test_file(Path("tests/utils.py")) + assert PathUtils.is_test_file(Path("example.test.js")) + assert not PathUtils.is_test_file(Path("main.py")) + + def test_is_hidden(self): + """Test hidden file detection.""" + assert PathUtils.is_hidden(Path(".gitignore")) + assert not PathUtils.is_hidden(Path("main.py")) + + def test_detect_project_root(self): + """Test project root detection.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + subdir = root / "src" / "package" + subdir.mkdir(parents=True) + + (root / "pyproject.toml").touch() + + detected = PathUtils.detect_project_root(subdir) + assert detected == root + + def test_get_relative_path(self): + """Test relative path calculation.""" + base = Path("/home/user/project") + target = Path("/home/user/project/src/main.py") + relative = PathUtils.get_relative_path(target, base) + assert relative == Path("src/main.py") + + +class TestFileScanner: + """Tests for FileScanner.""" + + def test_scan_python_project(self, create_python_project): + """Test scanning a Python project.""" + scanner = FileScanner(create_python_project) + files = scanner.scan_and_create() + + file_names = {f.path.name for f in files} + assert "main.py" in file_names + assert "__init__.py" in file_names + assert "requirements.txt" in file_names + assert "pyproject.toml" in file_names + + def test_detect_project_type_python(self, create_python_project): + """Test project type detection for Python.""" + from src.auto_readme.models import ProjectType + + scanner = FileScanner(create_python_project) + project_type = scanner.detect_project_type() + + assert project_type == ProjectType.PYTHON + + def test_detect_project_type_javascript(self, create_javascript_project): + """Test project type detection for JavaScript.""" + from src.auto_readme.models import ProjectType + + scanner = FileScanner(create_javascript_project) + project_type = scanner.detect_project_type() + + assert project_type == ProjectType.JAVASCRIPT + + def test_detect_project_type_go(self, create_go_project): + """Test project type detection for Go.""" + from src.auto_readme.models import ProjectType + + scanner = FileScanner(create_go_project) + project_type = scanner.detect_project_type() + + assert project_type == ProjectType.GO + + def test_detect_project_type_rust(self, create_rust_project): + """Test project type detection for Rust.""" + from src.auto_readme.models import ProjectType + + scanner = FileScanner(create_rust_project) + project_type = scanner.detect_project_type() + + assert project_type == ProjectType.RUST + + def test_scan_mixed_project(self, create_mixed_project): + """Test scanning a project with multiple languages.""" + scanner = FileScanner(create_mixed_project) + files = scanner.scan_and_create() + + assert len(files) > 0