diff --git a/README.md b/README.md index 1c7dda215..cc0531bad 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ The `specify` command supports the following options: | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | +| `--spec-dir` | Option | Custom directory path for specifications (default: specs, relative to project root) | ### Examples @@ -208,6 +209,11 @@ specify init . --force --ai copilot # or specify init --here --force --ai copilot +# Initialize with custom spec directory +specify init my-project --spec-dir docs/specs +specify init my-project --ai claude --spec-dir requirements +specify init --here --ai copilot --spec-dir documentation/feature-specs + # Skip git initialization specify init my-project --ai gemini --no-git @@ -252,6 +258,7 @@ Additional commands for enhanced quality and validation: | Variable | Description | |------------------|------------------------------------------------------------------------------------------------| | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.
**Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. | +| `SPECIFY_SPEC_DIR` | Override the default specification directory name. Set to a custom directory (e.g., `docs/specs`) to use a different directory path for specifications instead of the default `specs/`. | ## 📚 Core Philosophy diff --git a/pyproject.toml b/pyproject.toml index 567d48cd4..f339d9b63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/specify_cli"] +[project.optional-dependencies] +test = [ + "pytest>=8.4.2", + "pytest-cov>=7.0.0", +] + diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 6931eccc8..d86508b93 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -28,7 +28,8 @@ get_current_branch() { # For non-git repos, try to find the latest feature directory local repo_root=$(get_repo_root) - local specs_dir="$repo_root/specs" + local spec_dir_name=$(get_spec_dir) + local specs_dir="$repo_root/$spec_dir_name" if [[ -d "$specs_dir" ]]; then local latest_feature="" @@ -81,14 +82,17 @@ check_feature_branch() { return 0 } -get_feature_dir() { echo "$1/specs/$2"; } +get_spec_dir() { echo "${SPECIFY_SPEC_DIR:-specs}"; } + +get_feature_dir() { echo "$1/$(get_spec_dir)/$2"; } # Find feature directory by numeric prefix instead of exact branch match # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) find_feature_dir_by_prefix() { local repo_root="$1" local branch_name="$2" - local specs_dir="$repo_root/specs" + local spec_dir_name=$(get_spec_dir) + local specs_dir="$repo_root/$spec_dir_name" # Extract numeric prefix from branch (e.g., "004" from "004-whatever") if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then @@ -99,7 +103,6 @@ find_feature_dir_by_prefix() { local prefix="${BASH_REMATCH[1]}" - # Search for directories in specs/ that start with this prefix local matches=() if [[ -d "$specs_dir" ]]; then for dir in "$specs_dir"/"$prefix"-*; do diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 86d9ecf83..97c511e0c 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -67,6 +67,14 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then exit 1 fi +# Resolve repository root. Prefer git information when available, but fall back +# to searching for repository markers so the workflow still functions in repositories that +# were initialised with --no-git. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source helper functions +source "$SCRIPT_DIR/common.sh" + # Function to find the repository root by searching for existing project markers find_repo_root() { local dir="$1" @@ -93,8 +101,10 @@ check_existing_branches() { # Also check local branches local local_branches=$(git branch 2>/dev/null | grep -E "^[* ]*[0-9]+-${short_name}$" | sed 's/^[* ]*//' | sed 's/-.*//' | sort -n) - # Check specs directory as well + # Check spec directory as well local spec_dirs="" + local spec_dir_name=$(get_spec_dir) + local SPECS_DIR="$REPO_ROOT/$spec_dir_name" if [ -d "$SPECS_DIR" ]; then spec_dirs=$(find "$SPECS_DIR" -maxdepth 1 -type d -name "[0-9]*-${short_name}" 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/-.*//' | sort -n) fi @@ -111,11 +121,6 @@ check_existing_branches() { echo $((max_num + 1)) } -# Resolve repository root. Prefer git information when available, but fall back -# to searching for repository markers so the workflow still functions in repositories that -# were initialised with --no-git. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - if git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) HAS_GIT=true @@ -130,7 +135,8 @@ fi cd "$REPO_ROOT" -SPECS_DIR="$REPO_ROOT/specs" +SPEC_DIR_NAME=$(get_spec_dir) +SPECS_DIR="$REPO_ROOT/$SPEC_DIR_NAME" mkdir -p "$SPECS_DIR" # Function to generate branch name with stop word filtering and length filtering diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be27354..95427d3ec 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -33,7 +33,8 @@ function Get-CurrentBranch { # For non-git repos, try to find the latest feature directory $repoRoot = Get-RepoRoot - $specsDir = Join-Path $repoRoot "specs" + $specDirName = Get-SpecDir + $specsDir = Join-Path $repoRoot $specDirName if (Test-Path $specsDir) { $latestFeature = "" @@ -87,9 +88,17 @@ function Test-FeatureBranch { return $true } +function Get-SpecDir { + if ($env:SPECIFY_SPEC_DIR) { + return $env:SPECIFY_SPEC_DIR + } + return "specs" +} + function Get-FeatureDir { param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" + $specDir = Get-SpecDir + Join-Path $RepoRoot "$specDir/$Branch" } function Get-FeaturePathsEnv { diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 4daa6d2c0..8f27e1974 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -127,6 +127,10 @@ function Get-NextBranchNumber { # Return next number return $maxNum + 1 } + +# Import helper functions +. "$PSScriptRoot/common.ps1" + $fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) if (-not $fallbackRoot) { Write-Error "Error: Could not determine repository root. Please run this script from within the repository." @@ -147,7 +151,8 @@ try { Set-Location $repoRoot -$specsDir = Join-Path $repoRoot 'specs' +$specDirName = Get-SpecDir +$specsDir = Join-Path $repoRoot $specDirName New-Item -ItemType Directory -Path $specsDir -Force | Out-Null # Function to generate branch name with stop word filtering and length filtering diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a33a1c61a..5ca853800 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -24,34 +24,34 @@ specify init --here """ +import json import os +import re +import shutil +import shlex +import ssl import subprocess import sys -import zipfile import tempfile -import shutil -import shlex -import json +import uuid +import zipfile from pathlib import Path from typing import Optional, Tuple -import typer import httpx +import readchar +import truststore +import typer +from rich.align import Align from rich.console import Console +from rich.live import Live from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.text import Text -from rich.live import Live -from rich.align import Align from rich.table import Table +from rich.text import Text from rich.tree import Tree from typer.core import TyperGroup -# For cross-platform keyboard input -import readchar -import ssl -import truststore - ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -64,6 +64,127 @@ def _github_auth_headers(cli_token: str | None = None) -> dict: token = _github_token(cli_token) return {"Authorization": f"Bearer {token}"} if token else {} +INVALID_CHARS = ['<', '>', '"', '|', '?', '*', '\0'] + +def validate_spec_dir(spec_dir: str) -> str: + """Validate and return a sanitized spec directory path. + + Args: + spec_dir: User-provided spec directory path + + Returns: + Sanitized spec directory path + + Raises: + SystemExit: If validation fails + """ + # Check for leading/trailing whitespace in original (before stripping) + if spec_dir.strip() != spec_dir: + console.print("[red]Error:[/red] Spec directory cannot start or end with whitespace") + raise SystemExit(1) + + # Strip whitespace for validation + spec_dir = spec_dir.strip() + + # Check if result is empty after stripping + if not spec_dir: + console.print("[red]Error:[/red] Spec directory cannot be empty") + raise SystemExit(1) + + # Enhanced path validation: check for absolute paths and parent traversal + drive, _ = os.path.splitdrive(spec_dir) + is_absolute = os.path.isabs(spec_dir) or bool(drive) + + # More robust parent traversal detection - only catch actual directory traversal + has_parent_traversal = ( + spec_dir.startswith("../") or + "/.." in spec_dir or + "\\.." in spec_dir or + spec_dir.endswith("/..") or + spec_dir.endswith("\\..") or + "/../" in spec_dir or + "\\..\\" in spec_dir + ) + + if is_absolute or has_parent_traversal: + if is_absolute: + if drive: + console.print(f"[red]Error:[/red] Spec directory must be relative to project root, not an absolute path or Windows drive (found: '{drive}')") + else: + console.print("[red]Error:[/red] Spec directory must be relative to project root, not an absolute path") + else: + console.print("[red]Error:[/red] Spec directory cannot contain parent directory traversal (..)") + raise SystemExit(1) + + # Enhanced character validation: handle Unicode and invalid characters efficiently + invalid_chars = set(INVALID_CHARS) + # Colon is always invalid since absolute paths are already caught above + if ':' in spec_dir: + invalid_chars.add(':') + + # Check for control characters (including null) + for char in spec_dir: + if ord(char) < 32 and char not in ('\t', '\n', '\r'): + console.print(f"[red]Error:[/red] Spec directory contains invalid control character at position {spec_dir.index(char)}") + console.print("[dim]Tip: Control characters cannot be used in directory names[/dim]") + raise SystemExit(1) + + found_invalid_chars = {char for char in spec_dir if char in invalid_chars} + if found_invalid_chars: + # Provide helpful error message with character descriptions (only when needed) + char_descriptions = { + '<': 'less-than (<)', + '>': 'greater-than (>)', + '"': 'double quote (")', + '|': 'pipe (|)', + '?': 'question mark (?)', + '*': 'asterisk (*)', + '\0': 'null character', + ':': 'colon (:)' + } + + described_chars = [char_descriptions.get(char, f"'{char}'") for char in sorted(found_invalid_chars)] + console.print(f"[red]Error:[/red] Spec directory contains invalid characters: {', '.join(described_chars)}") + console.print("[dim]Tip: These characters are not allowed in directory names on most filesystems[/dim]") + raise SystemExit(1) + + # Check for trailing slashes/backslashes + if spec_dir.endswith(('/', '\\')): + console.print("[red]Error:[/red] Spec directory cannot end with a slash or backslash") + raise SystemExit(1) + + # Check length (filesystem limit with helpful message) + if len(spec_dir) > 255: + console.print(f"[red]Error:[/red] Spec directory path is too long ({len(spec_dir)} characters, max 255)") + console.print("[dim]Tip: Consider using a shorter directory name[/dim]") + raise SystemExit(1) + + # Enhanced alphanumeric check with better error message + if not spec_dir[0].isalnum(): + # Provide specific guidance based on what was found + if spec_dir[0] in '-_.': + console.print(f"[red]Error:[/red] Spec directory cannot start with '{spec_dir[0]}' - it must start with a letter or number") + else: + console.print(f"[red]Error:[/red] Spec directory must start with an alphanumeric character (found: '{spec_dir[0]}')") + console.print("[dim]Tip: Start directory names with a letter (a-z, A-Z) or number (0-9)[/dim]") + raise SystemExit(1) + + # Check for reserved names (Windows compatibility) - lightweight check + reserved_names = { + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + } + + # Check if the directory name (first component) is a reserved name + first_component = spec_dir.replace('\\', '/').split('/')[0].upper() + if first_component in reserved_names: + console.print(f"[red]Error:[/red] '{first_component}' is a reserved system name and cannot be used as a directory") + console.print("[dim]Tip: Windows reserves these names for system devices[/dim]") + raise SystemExit(1) + + return spec_dir + # Agent configuration with name, folder, install URL, and CLI tool requirement AGENT_CONFIG = { "copilot": { @@ -668,7 +789,98 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri } return zip_path, metadata -def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path: +def update_spec_directory_references(project_path: Path, spec_dir: str, verbose: bool = True, tracker: StepTracker | None = None) -> None: + """Update hardcoded 'specs' references to use custom spec directory.""" + + # Files that commonly contain specs/ references + patterns_to_update = [ + "**/*.sh", + "**/*.ps1", + "**/*.md", + "**/*.json", + "**/*.yaml", + "**/*.yml", + "**/*.toml", + ] + + updated_files = 0 + total_replacements = 0 + + # Check if we will rename specs/ directory (target doesn't exist) + specs_dir = project_path / "specs" + will_rename_specs = specs_dir.exists() and specs_dir.is_dir() and not (project_path / spec_dir).exists() + + for pattern in patterns_to_update: + for file_path in project_path.glob(pattern): + if file_path.is_file(): + # Skip files inside specs/ directory if we're not renaming it + if not will_rename_specs and specs_dir in file_path.parents: + continue + + try: + # Read file content + content = file_path.read_text(encoding='utf-8') + original_content = content + + # Replace various specs/ patterns using safer, more precise regex approach + + # Use a unique temporary placeholder that won't conflict with content + temp_placeholder = f"__SPECS_TEMP_{uuid.uuid4().hex}__" + + # More precise regex patterns to avoid data corruption + # Handle patterns in order of specificity to avoid conflicts + + # First handle specs- followed by various characters (specs-word, specs-[123], etc.) + content = re.sub(r'(? {spec_dir}/") + except Exception as e: + if verbose: + console.print(f"[yellow]Warning:[/yellow] Could not rename specs/ directory: {e}") + elif specs_dir.exists() and verbose: + # Target exists, so we're not renaming + console.print(f"[yellow]Warning:[/yellow] Target directory '{spec_dir}/' already exists, keeping specs/") + + if tracker: + tracker.complete("update-spec-dir", f"updated {updated_files} files, {total_replacements} replacements") + elif verbose and updated_files > 0: + console.print(f"[green]Updated {updated_files} files with {total_replacements} spec directory references[/green]") + console.print(f"[dim]Tip: Set SPECIFY_SPEC_DIR environment variable to '{spec_dir}' for shell scripts to use the custom directory[/dim]") + + +def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None, spec_dir: str = "specs") -> Path: """Download the latest release and extract it to create a new project. Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) """ @@ -815,6 +1027,19 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ elif verbose: console.print(f"Cleaned up: {zip_path.name}") + # Update spec directory references if custom directory specified + if spec_dir != "specs": + if tracker: + tracker.add("update-spec-dir", f"Update spec directory references to '{spec_dir}'") + tracker.start("update-spec-dir") + + update_spec_directory_references(project_path, spec_dir, verbose, tracker) + + if tracker: + tracker.complete("update-spec-dir") + elif verbose: + console.print(f"[cyan]Updated spec directory references to '{spec_dir}'[/cyan]") + return project_path @@ -874,6 +1099,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + spec_dir: str = typer.Option("specs", "--spec-dir", help="Custom directory path for specifications (default: specs, relative to project root)"), ): """ Initialize a new Specify project from the latest template. @@ -898,6 +1124,9 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --spec-dir docs/specs # Custom spec directory + specify init my-project --ai claude --spec-dir requirements # Custom spec directory with AI + specify init --here --spec-dir documentation/feature-specs # Custom spec directory in current dir """ show_banner() @@ -914,6 +1143,9 @@ def init( console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") raise typer.Exit(1) + # Validate spec directory + validated_spec_dir = validate_spec_dir(spec_dir) + if here: project_name = Path.cwd().name project_path = Path.cwd() @@ -1044,7 +1276,7 @@ def init( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token, spec_dir=validated_spec_dir) ensure_executable_scripts(project_path, tracker=tracker) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 69258a8bf..0bba3c8b9 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -41,7 +41,7 @@ Given that feature description, do this: b. Find the highest feature number across all sources for the short-name: - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-$'` - Local branches: `git branch | grep -E '^[* ]*[0-9]+-$'` - - Specs directories: Check for directories matching `specs/[0-9]+-` + - Specs directories: Check for directories matching `{SPEC_DIR}/[0-9]+-` c. Determine the next available number: - Extract all numbers from all three sources diff --git a/templates/plan-template.md b/templates/plan-template.md index 6a8bfc6c8..7544d7801 100644 --- a/templates/plan-template.md +++ b/templates/plan-template.md @@ -1,7 +1,7 @@ # Implementation Plan: [FEATURE] **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] -**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` +**Input**: Feature specification from `/{SPEC_DIR}/[###-feature-name]/spec.md` **Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. @@ -38,7 +38,7 @@ ### Documentation (this feature) ```text -specs/[###-feature]/ +{SPEC_DIR}/[###-feature]/ ├── plan.md # This file (/speckit.plan command output) ├── research.md # Phase 0 output (/speckit.plan command) ├── data-model.md # Phase 1 output (/speckit.plan command) diff --git a/templates/tasks-template.md b/templates/tasks-template.md index 60f9be455..e80c833a1 100644 --- a/templates/tasks-template.md +++ b/templates/tasks-template.md @@ -5,7 +5,7 @@ description: "Task list template for feature implementation" # Tasks: [FEATURE NAME] -**Input**: Design documents from `/specs/[###-feature-name]/` +**Input**: Design documents from `/{SPEC_DIR}/[###-feature-name]/` **Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ **Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..edc8f146c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Specify CLI.""" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..86f85b8b0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +"""Pytest configuration and fixtures.""" + +import pytest +import tempfile +import os +from pathlib import Path +from unittest.mock import patch +from typer.testing import CliRunner + +# Import module directly to avoid dependency issues +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +@pytest.fixture +def temp_project_dir(): + """Create a temporary project directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + original_cwd = os.getcwd() + temp_path = Path(temp_dir) + os.chdir(temp_path) + yield temp_path + os.chdir(original_cwd) + + +@pytest.fixture +def cli_runner(): + """Create a CLI runner for testing.""" + return CliRunner() + + +@pytest.fixture +def mock_console(): + """Create a mock console for testing.""" + from unittest.mock import MagicMock + return MagicMock() + + +@pytest.fixture +def mock_check_tool(): + """Mock check_tool function.""" + with patch('specify_cli.check_tool') as mock: + mock.return_value = True + yield mock + + +@pytest.fixture +def mock_download_template(): + """Mock download_and_extract_template function.""" + with patch('specify_cli.download_and_extract_template') as mock: + mock.return_value = Path(tempfile.mkdtemp()) + yield mock \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 000000000..ea34fe4d3 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,284 @@ +"""Integration tests for --spec-dir functionality.""" + +import tempfile +import os +from pathlib import Path +from unittest.mock import patch, MagicMock +from typer.testing import CliRunner +import typer + +# Import module directly to avoid dependency issues +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from specify_cli import app + + +class TestSpecDirIntegration: + """Integration tests for spec directory functionality.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + self.temp_dir = Path(tempfile.mkdtemp()) + self.original_cwd = os.getcwd() + os.chdir(self.temp_dir) + + def teardown_method(self): + """Clean up test environment.""" + os.chdir(self.original_cwd) + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_with_default_spec_dir(self, mock_check_tool, mock_download): + """Test init with default spec directory.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--no-git' + ]) + + assert result.exit_code == 0 + mock_download.assert_called_once() + # Check that specs/ is used by default + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == 'specs' + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_with_custom_spec_dir(self, mock_check_tool, mock_download): + """Test init with custom spec directory.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', 'documentation', + '--no-git' + ]) + + assert result.exit_code == 0 + mock_download.assert_called_once() + # Check that custom spec directory is passed + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == 'documentation' + + @patch('specify_cli.check_tool') + def test_init_with_invalid_spec_dir_absolute_path(self, mock_check_tool): + """Test init with invalid absolute path spec directory.""" + mock_check_tool.return_value = True + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', '/etc/specs', # Invalid: absolute path + '--no-git' + ]) + + assert result.exit_code == 1 + assert "must be relative to project root" in result.stdout + + @patch('specify_cli.check_tool') + def test_init_with_invalid_spec_dir_parent_traversal(self, mock_check_tool): + """Test init with invalid parent traversal spec directory.""" + mock_check_tool.return_value = True + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', '../specs', # Invalid: parent traversal + '--no-git' + ]) + + assert result.exit_code == 1 + assert "cannot contain parent directory traversal" in result.stdout + + @patch('specify_cli.check_tool') + def test_init_with_invalid_spec_dir_trailing_slash(self, mock_check_tool): + """Test init with invalid trailing slash spec directory.""" + mock_check_tool.return_value = True + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', 'specs/', # Invalid: trailing slash + '--no-git' + ]) + + assert result.exit_code == 1 + assert "cannot end with a slash or backslash" in result.stdout + + @patch('specify_cli.check_tool') + def test_init_with_invalid_spec_dir_empty(self, mock_check_tool): + """Test init with empty spec directory.""" + mock_check_tool.return_value = True + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', '', # Invalid: empty + '--no-git' + ]) + + assert result.exit_code == 1 + assert "cannot be empty" in result.stdout + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_with_various_valid_spec_dirs(self, mock_check_tool, mock_download): + """Test init with various valid spec directories.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + valid_spec_dirs = [ + 'docs', + 'requirements', + 'feature-specs', + 'specifications', + 'project_docs', + '123specs', # Numeric start + 'specs_v2', # With underscore + 'my-specs-dir' # With dashes + ] + + for spec_dir in valid_spec_dirs: + result = self.runner.invoke(app, [ + 'init', + f'test-{spec_dir}', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', spec_dir, + '--no-git' + ]) + + assert result.exit_code == 0, f"Failed for valid spec_dir: {spec_dir}" + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == spec_dir + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_with_nested_spec_dir(self, mock_check_tool, mock_download): + """Test init with nested spec directory.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + result = self.runner.invoke(app, [ + 'init', + 'test-project', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', 'documentation/feature-specs', + '--no-git' + ]) + + assert result.exit_code == 0 + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == 'documentation/feature-specs' + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_here_with_custom_spec_dir(self, mock_check_tool, mock_download): + """Test init --here with custom spec directory.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + result = self.runner.invoke(app, [ + 'init', + '--here', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', 'requirements', + '--no-git' + ]) + + assert result.exit_code == 0 + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == 'requirements' + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_with_special_characters(self, mock_check_tool, mock_download): + """Test init with special characters in spec directory.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + # Test valid special characters (should pass) + valid_special_chars = [ + 'spécs', # Accented character + 'αspecs', # Greek character + 'specs_测试', # Chinese character + 'specs-тест', # Cyrillic character + ] + + for spec_dir in valid_special_chars: + result = self.runner.invoke(app, [ + 'init', + f'test-{spec_dir}', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', spec_dir, + '--no-git' + ]) + + assert result.exit_code == 0, f"Failed for valid unicode spec_dir: {spec_dir}" + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == spec_dir + + @patch('specify_cli.download_and_extract_template') + @patch('specify_cli.check_tool') + def test_init_with_edge_cases(self, mock_check_tool, mock_download): + """Test init with edge cases.""" + mock_check_tool.return_value = True + mock_download.return_value = self.temp_dir + + # Test edge cases + edge_cases = [ + ('s', 'Single character'), + ('1', 'Single numeric'), + ('specs123', 'Mixed alphanumeric'), + ('a' * 255, 'Maximum length'), + ] + + for spec_dir, description in edge_cases: + result = self.runner.invoke(app, [ + 'init', + f'test-{description.replace(" ", "-")}', + '--ai', 'claude', + '--script', 'sh', + '--spec-dir', spec_dir, + '--no-git' + ]) + + assert result.exit_code == 0, f"Failed for edge case: {description}" + call_args = mock_download.call_args + assert call_args.kwargs['spec_dir'] == spec_dir + + @patch('specify_cli.check_tool') + def test_init_help_includes_spec_dir(self, mock_check_tool): + """Test that help includes --spec-dir option.""" + mock_check_tool.return_value = True + + result = self.runner.invoke(app, ['init', '--help']) + + assert result.exit_code == 0 + assert '--spec-dir' in result.stdout + assert 'Custom directory path for specifications' in result.stdout + assert 'default: specs' in result.stdout \ No newline at end of file diff --git a/tests/test_update_spec_directory_references.py b/tests/test_update_spec_directory_references.py new file mode 100644 index 000000000..3327b207b --- /dev/null +++ b/tests/test_update_spec_directory_references.py @@ -0,0 +1,310 @@ +"""Test update_spec_directory_references function.""" + +import tempfile +import os +from pathlib import Path +from unittest.mock import patch +import typer + +# Import module directly to avoid dependency issues +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from specify_cli import update_spec_directory_references + + +class TestUpdateSpecDirectoryReferences: + """Test update_spec_directory_references function.""" + + def test_basic_specs_replacement(self): + """Test basic specs/ directory replacement.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test files with various specs/ references + test_files = { + 'script.sh': '#!/bin/bash\necho "specs/ directory"', + 'script.ps1': 'Write-Host "specs/ directory"', + 'README.md': '# Project\n\nSee specs/ for documentation.', + 'config.json': '{"spec_dir": "specs/"}' + } + + for filename, content in test_files.items(): + file_path = temp_path / filename + file_path.write_text(content) + + # Update references to custom directory + update_spec_directory_references(temp_path, 'documentation', verbose=False) + + # Check that files were updated + for filename in test_files: + file_path = temp_path / filename + updated_content = file_path.read_text() + assert 'documentation/' in updated_content, f"File {filename} should contain 'documentation/'" + assert 'specs/' not in updated_content, f"File {filename} should not contain 'specs/'" + + def test_quoted_specs_replacement(self): + """Test replacement of quoted specs/ references.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create file with quoted specs/ references + content = ''' +echo "specs/" +echo 'specs/' +echo `specs/` +''' + file_path = temp_path / 'test.sh' + file_path.write_text(content) + + # Update references + update_spec_directory_references(temp_path, 'requirements', verbose=False) + + # Check quotes were handled correctly + updated_content = file_path.read_text() + assert 'requirements/' in updated_content + assert '"specs/"' not in updated_content + assert "'specs/'" not in updated_content + assert '`specs/' not in updated_content + # Verify the correct quoted replacements occurred + assert '"requirements/"' in updated_content + assert "'requirements/'" in updated_content + assert '`requirements/`' in updated_content + + def test_template_placeholder_replacement(self): + """Test template placeholder updates.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create template files with placeholders + template_content = '''# Template +Input: /{SPEC_DIR}/[feature]/ +Path: specs/[feature]/ +Mixed: {SPEC_DIR} and specs/ +Nested: /{SPEC_DIR}/[feature]/docs +''' + template_file = temp_path / 'template.md' + template_file.write_text(template_content) + + # Update references + update_spec_directory_references(temp_path, 'feature-specs', verbose=False) + + # Check placeholders were replaced + updated_content = template_file.read_text() + assert 'feature-specs/' in updated_content + assert '{SPEC_DIR}' not in updated_content + + # Check that original specs/ patterns were replaced (not part of feature-specs) + # The line "Path: specs/[feature]/" should become "Path: feature-specs/[feature]/" + assert 'Path: specs/[feature]/' not in updated_content + assert 'Mixed: {SPEC_DIR} and specs/' not in updated_content + # But feature-specs/ should be present as the replacement + assert 'Path: feature-specs/[feature]/' in updated_content + assert 'Mixed: feature-specs and feature-specs/' in updated_content + + def test_directory_rename(self): + """Test that specs/ directory gets renamed.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create specs directory with content + specs_dir = temp_path / 'specs' + specs_dir.mkdir() + (specs_dir / 'test.txt').write_text('test content') + (specs_dir / 'subdir').mkdir() + (specs_dir / 'subdir' / 'nested.txt').write_text('nested content') + + # Update references + update_spec_directory_references(temp_path, 'documentation', verbose=False) + + # Check directory was renamed + assert not specs_dir.exists(), "Original specs/ directory should not exist" + + new_dir = temp_path / 'documentation' + assert new_dir.exists(), "New documentation/ directory should exist" + assert (new_dir / 'test.txt').exists(), "Files should be moved" + assert (new_dir / 'subdir' / 'nested.txt').exists(), "Nested files should be moved" + assert (new_dir / 'test.txt').read_text() == 'test content' + + def test_directory_rename_no_overwrite(self): + """Test that existing target directory is not overwritten.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create both specs and target directories + specs_dir = temp_path / 'specs' + target_dir = temp_path / 'documentation' + specs_dir.mkdir() + target_dir.mkdir() + + # Add content to both + (specs_dir / 'specs_file.txt').write_text('specs content') + (target_dir / 'existing_file.txt').write_text('existing content') + + # Update references (should not overwrite existing target) + update_spec_directory_references(temp_path, 'documentation', verbose=False) + + # Check specs directory still exists (target existed) + assert specs_dir.exists(), "specs/ directory should still exist when target exists" + assert target_dir.exists(), "target directory should still exist" + + # Target directory should have existing content unchanged + assert (target_dir / 'existing_file.txt').read_text() == 'existing content' + + # specs directory should still have its content + assert (specs_dir / 'specs_file.txt').read_text() == 'specs content' + + def test_various_file_patterns(self): + """Test that various file patterns are updated.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create files with different extensions + files_to_create = { + 'script.sh': 'echo "specs/"', + 'script.ps1': 'Write-Host "specs/"', + 'README.md': 'See specs/ for docs', + 'config.json': '{"dir": "specs/"}', + 'settings.yaml': 'path: specs/', + 'setup.yml': 'directory: specs/', + 'project.toml': 'spec_dir = "specs/"' + } + + for filename, content in files_to_create.items(): + file_path = temp_path / filename + file_path.write_text(content) + + # Update references + update_spec_directory_references(temp_path, 'my-specs', verbose=False) + + # Check all files were updated + for filename, original_content in files_to_create.items(): + file_path = temp_path / filename + updated_content = file_path.read_text() + assert 'my-specs/' in updated_content or 'my-specs' in updated_content, f"File {filename} should be updated" + # Check that the original specs/ pattern was replaced, not that specs/ doesn't appear as substring + assert original_content not in updated_content, f"File {filename} should be modified" + # Verify specific replacements occurred + if 'specs/' in original_content: + assert 'specs/' not in updated_content or 'my-specs/' in updated_content, f"File {filename} should have specs/ replaced with my-specs/" + + def test_complex_patterns(self): + """Test complex spec directory patterns.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Test file with various complex patterns + test_content = ''' +# Various specs/ patterns +echo "specs/" +echo 'specs/' +echo "specs" +echo `specs/` +echo /specs/ +echo specs/[123]-feature +echo specs-[456]-test +echo "specs/" +echo 'specs/' +echo specs/branch-name +echo git ls-remote | grep specs/ +echo find specs/ -name "*.md" +''' + + test_file = temp_path / 'complex.sh' + test_file.write_text(test_content) + + # Update references + update_spec_directory_references(temp_path, 'feature-docs', verbose=False) + + # Check all patterns were updated + updated_content = test_file.read_text() + assert 'feature-docs/' in updated_content + assert 'specs/' not in updated_content + assert '"specs/"' not in updated_content + assert "'specs/'" not in updated_content + assert '`specs/' not in updated_content + assert '/specs/' not in updated_content + assert 'specs/[123]' not in updated_content + assert 'specs-[456]' not in updated_content + + def test_no_changes_needed(self): + """Test that files without specs/ references are not modified.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create file without specs references (no specs word at all) + content = '''# Project without any specifications +echo "Hello World" +echo "No special directory here" +echo "Just regular content" +''' + + test_file = temp_path / 'no-specs.txt' + test_file.write_text(content) + original_mtime = test_file.stat().st_mtime + + # Update references (should not modify this file) + update_spec_directory_references(temp_path, 'documentation', verbose=False) + + # Check file was not modified + updated_mtime = test_file.stat().st_mtime + assert original_mtime == updated_mtime, "File without specs/ should not be modified" + + def test_error_handling(self): + """Test error handling during file updates.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create a normal file that should be processed successfully + test_file = temp_path / 'test.sh' + test_file.write_text('echo "specs/"') + + # Test that function completes without raising exceptions + # The function has try-catch blocks that should handle individual file errors + try: + update_spec_directory_references(temp_path, 'documentation', verbose=False) + # If we get here, the function handled any potential errors + error_handling_works = True + except Exception as e: + # If an exception escapes, error handling failed + error_handling_works = False + print(f"Unexpected exception: {e}") + + assert error_handling_works, "Function should handle errors gracefully and not raise exceptions" + + # Verify the file was actually updated (normal case) + updated_content = test_file.read_text() + assert 'documentation/' in updated_content, "File should be updated successfully" + + def test_verbose_output(self): + """Test verbose output functionality.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create test file + test_file = temp_path / 'test.md' + test_file.write_text('See specs/ for documentation.') + + # Test with verbose=True + with patch('specify_cli.console') as mock_console: + update_spec_directory_references(temp_path, 'documentation', verbose=True) + + # Check that verbose output was called + mock_console.print.assert_called() + call_args = [call[0][0] for call in mock_console.print.call_args_list] + + # Should have updated file message + update_messages = [msg for msg in call_args if 'Updated:' in msg] + assert len(update_messages) > 0, "Should have at least one update message" + + def test_nonexistent_directory(self): + """Test handling of nonexistent project directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + nonexistent_path = temp_path / 'nonexistent' + + # Should not raise exception for nonexistent directory + update_spec_directory_references(nonexistent_path, 'documentation', verbose=False) + + # Should complete without error + assert True # If we get here, no exception was raised \ No newline at end of file diff --git a/tests/test_validate_spec_dir.py b/tests/test_validate_spec_dir.py new file mode 100644 index 000000000..e01e6032b --- /dev/null +++ b/tests/test_validate_spec_dir.py @@ -0,0 +1,355 @@ +"""Test spec directory validation functionality.""" + +import pytest +from unittest.mock import patch +from typer.testing import CliRunner +import typer + +# Import the module directly to avoid dependency issues +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from specify_cli import validate_spec_dir + + +class TestValidateSpecDir: + """Test the validate_spec_dir function.""" + + def test_valid_spec_directories(self): + """Test that valid spec directory names pass validation.""" + valid_dirs = [ + "specs", + "docs", + "requirements", + "feature-specs", + "documentation", + "specifications", + "s", + "spec_docs", + "my-specs-123", + "123specs", # Numeric start is valid + "a", + "spec_dir_with_underscores", + "spec-dir-with-dashes", + "CamelCaseSpecs" + ] + + for spec_dir in valid_dirs: + result = validate_spec_dir(spec_dir) + assert result == spec_dir, f"Valid spec_dir '{spec_dir}' should pass validation" + + def test_empty_spec_dir(self): + """Test that empty spec directory raises typer.Exit.""" + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir("") + assert exc_info.value.code == 1 + mock_console.print.assert_called_with("[red]Error:[/red] Spec directory cannot be empty") + + def test_whitespace_only_spec_dir(self): + """Test that whitespace-only spec directory raises typer.Exit.""" + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(" ") + assert exc_info.value.code == 1 + mock_console.print.assert_called_with("[red]Error:[/red] Spec directory cannot start or end with whitespace") + + def test_absolute_path_spec_dir_unix(self): + """Test that absolute Unix paths raise SystemExit.""" + absolute_paths = [ + "/etc/specs", + "/home/user/specs", + "/usr/local/specs", + "/tmp/specs" + ] + + for path in absolute_paths: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(path) + assert exc_info.value.code == 1 + mock_console.print.assert_any_call("[red]Error:[/red] Spec directory must be relative to project root, not an absolute path") + + def test_absolute_path_spec_dir_windows(self): + """Test that absolute Windows paths raise SystemExit.""" + import platform + import pytest + # On Unix systems, Windows paths are not detected as absolute by os.path + # This test is only meaningful on Windows systems + if platform.system() != 'Windows': + pytest.skip("Windows absolute path detection only works on Windows") + + absolute_paths = [ + "C:\\specs", + "C:/Users/user/specs", + "D:\\Projects\\specs", + "E:/specs" + ] + + for path in absolute_paths: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(path) + assert exc_info.value.code == 1 + mock_console.print.assert_any_call("[red]Error:[/red] Spec directory must be relative to project root, not an absolute path") + + def test_parent_traversal_spec_dir(self): + """Test that parent directory traversal raises SystemExit.""" + traversal_paths = [ + "../specs", + "specs/../..", + "../../specs", + "docs/../../specs", + "specs/../other", + "../specs/../docs", + "specs/../../../etc" + ] + + for path in traversal_paths: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(path) + assert exc_info.value.code == 1 + mock_console.print.assert_called_with("[red]Error:[/red] Spec directory cannot contain parent directory traversal (..)") + + def test_invalid_characters_spec_dir(self): + """Test that invalid characters raise SystemExit with user-friendly error messages.""" + # Test regular invalid characters + invalid_chars_and_examples = [ + ('<', 'specs', 'less-than (<)'), + ('>', 'specs>test<', 'greater-than (>)'), + (':', 'specs:test', 'colon (:)'), + ('"', '"specs"', 'double quote (")'), + ('|', 'specs|test', 'pipe (|)'), + ('?', 'specs?test', 'question mark (?)'), + ('*', 'specs*test', 'asterisk (*)') + ] + + for char, example, expected_description in invalid_chars_and_examples: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(example) + assert exc_info.value.code == 1 + + # Check that the error message mentions invalid characters with specific description + all_calls = [call[0][0] for call in mock_console.print.call_args_list] + error_message = next((msg for msg in all_calls if "invalid characters" in msg.lower()), None) + assert error_message is not None, f"No error message with 'invalid characters' found in calls: {all_calls}" + assert expected_description in error_message, f"Expected '{expected_description}' in error message: {error_message}" + + # Check for helpful tip (printed as separate call) + tip_message = next((msg for msg in all_calls if "These characters are not allowed in directory names" in msg), None) + assert tip_message is not None, f"Missing helpful tip in calls: {all_calls}" + + # Test control characters separately (they're caught by a different validation) + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir('specs\0test') + assert exc_info.value.code == 1 + + all_calls = [call[0][0] for call in mock_console.print.call_args_list] + error_message = next((msg for msg in all_calls if "invalid control character" in msg), None) + assert error_message is not None, f"No 'invalid control character' error message found: {all_calls}" + assert "position 5" in error_message, f"Missing position information in control character error: {error_message}" + + tip_message = next((msg for msg in all_calls if "Control characters cannot be used" in msg), None) + assert tip_message is not None, f"Missing control character tip: {all_calls}" + + def test_trailing_slash_spec_dir(self): + """Test that trailing slash raises SystemExit.""" + trailing_slash_paths = [ + "specs/", + "docs\\", + "requirements/", + "feature\\", + "documentation/", + "specifications\\" + ] + + for path in trailing_slash_paths: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(path) + assert exc_info.value.code == 1 + mock_console.print.assert_called_with("[red]Error:[/red] Spec directory cannot end with a slash or backslash") + + def test_too_long_spec_dir(self): + """Test that too long path raises SystemExit with user-friendly error message.""" + # Test exactly at limit (should pass) + valid_long_path = "a" * 255 + result = validate_spec_dir(valid_long_path) + assert result == valid_long_path + + # Test over limit (should fail) + long_path = "a" * 256 + + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(long_path) + assert exc_info.value.code == 1 + + # Check for specific error message and helpful tip + all_calls = [call[0][0] for call in mock_console.print.call_args_list] + error_message = next((msg for msg in all_calls if "too long" in msg.lower()), None) + assert error_message is not None, f"No 'too long' error message found in calls: {all_calls}" + assert f"{len(long_path)} characters, max 255" in error_message, f"Missing character count in error message: {error_message}" + + # Check for helpful tip (printed as separate call) + tip_message = next((msg for msg in all_calls if "Consider using a shorter directory name" in msg), None) + assert tip_message is not None, f"Missing helpful tip in calls: {all_calls}" + + def test_non_alphanumeric_start_spec_dir(self): + """Test that non-alphanumeric start raises SystemExit with user-friendly error messages.""" + invalid_starts = [ + ("-specs", "cannot start with '-' - it must start with a letter or number"), + ("_specs", "cannot start with '_' - it must start with a letter or number"), + (".specs", "cannot start with '.' - it must start with a letter or number"), + ("/specs", "must be relative to project root, not an absolute path"), # Leading slash (caught as absolute path) + ] + + for path, expected_message_fragment in invalid_starts: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(path) + assert exc_info.value.code == 1 + + # Check for specific error message content + all_calls = [call[0][0] for call in mock_console.print.call_args_list] + error_message = next((msg for msg in all_calls if expected_message_fragment in msg), None) + assert error_message is not None, f"Expected message fragment '{expected_message_fragment}' not found in calls: {all_calls}" + + # For specific character errors, check for helpful tip (printed as separate call) + if path[0] in '-_.': + tip_message = next((msg for msg in all_calls if "Start directory names with a letter" in msg), None) + assert tip_message is not None, f"Missing helpful tip for {path}: {all_calls}" + + # Test backslash separately (behavior differs by platform) + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir("\\specs") + assert exc_info.value.code == 1 + # On Unix, backslash is treated as invalid starting character + # On Windows, it would be treated as an absolute path + import platform + if platform.system() == 'Windows': + mock_console.print.assert_any_call("[red]Error:[/red] Spec directory must be relative to project root, not an absolute path") + else: + mock_console.print.assert_any_call("[red]Error:[/red] Spec directory must start with an alphanumeric character (found: '\\')") + + # Test leading space separately + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(" specs") + assert exc_info.value.code == 1 + mock_console.print.assert_called_with("[red]Error:[/red] Spec directory cannot start or end with whitespace") + + def test_edge_cases(self): + """Test edge cases and boundary conditions.""" + # Single character (valid) + result = validate_spec_dir("s") + assert result == "s" + + # Single numeric (valid) + result = validate_spec_dir("1") + assert result == "1" + + # Mixed alphanumeric (valid) + result = validate_spec_dir("specs123") + assert result == "specs123" + + # With numbers and letters (valid) + result = validate_spec_dir("123specs456") + assert result == "123specs456" + + def test_unicode_handling(self): + """Test handling of unicode characters.""" + # Valid unicode characters should pass + unicode_valid = "spécs" # Contains accented character + result = validate_spec_dir(unicode_valid) + assert result == unicode_valid + + # Unicode that starts with alphanumeric should pass + unicode_valid_start = "αspecs" # Greek alpha + result = validate_spec_dir(unicode_valid_start) + assert result == unicode_valid_start + + def test_comprehensive_unicode_scenarios(self): + """Test comprehensive unicode scenarios including edge cases.""" + # Various unicode scripts that should work + unicode_dirs = [ + "spécs-documentation", # Latin with accent + "仕様書", # Japanese "specifications" + "문서", # Korean "documents" + "文档", # Chinese "documents" + "документы", # Cyrillic "documents" + "مستندات", # Arabic "documents" + "dokümanlar", # Turkish with accent + "αδιαβαστικά", # Greek + ] + + for unicode_dir in unicode_dirs: + # Only test if the first character is alphanumeric + if unicode_dir[0].isalnum(): + result = validate_spec_dir(unicode_dir) + assert result == unicode_dir, f"Unicode dir '{unicode_dir}' should pass validation" + + def test_long_path_scenarios(self): + """Test very long path handling and edge cases.""" + # Test exactly at the boundary (255 characters) + long_boundary = "a" * 255 + result = validate_spec_dir(long_boundary) + assert result == long_boundary + + # Test over the boundary (256 characters) - should fail + over_boundary_path = "a" * 256 + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(over_boundary_path) + assert exc_info.value.code == 1 + mock_console.print.assert_any_call(f"[red]Error:[/red] Spec directory path is too long ({len(over_boundary_path)} characters, max 255)") + + # Test reasonable long paths that should work + reasonable_long = "specification-documents-very-long-name-but-still-under-limit" + result = validate_spec_dir(reasonable_long) + assert result == reasonable_long + + def test_edge_case_unicode_combinations(self): + """Test edge case combinations of unicode and special characters.""" + # Unicode with dashes and underscores + unicode_mixed = "spécs-docs_123" + result = validate_spec_dir(unicode_mixed) + assert result == unicode_mixed + + # Multiple unicode characters + unicode_multi = "α-β-γ-specs" + result = validate_spec_dir(unicode_multi) + assert result == unicode_multi + + # Unicode at start with alphanumeric property + unicode_start_alnum = "1-specs-测试" + result = validate_spec_dir(unicode_start_alnum) + assert result == unicode_start_alnum + + def test_reserved_names_spec_dir(self): + """Test that reserved names raise SystemExit with user-friendly error message.""" + reserved_names = [ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + ] + + for reserved_name in reserved_names: + with patch('specify_cli.console') as mock_console: + with pytest.raises(SystemExit) as exc_info: + validate_spec_dir(reserved_name) + assert exc_info.value.code == 1 + + # Check for specific error message and helpful tip + all_calls = [call[0][0] for call in mock_console.print.call_args_list] + error_message = next((msg for msg in all_calls if "reserved system name" in msg), None) + assert error_message is not None, f"No 'reserved system name' error message found for {reserved_name}: {all_calls}" + assert f"'{reserved_name}' is a reserved system name" in error_message, f"Missing reserved name in error message: {error_message}" + + tip_message = next((msg for msg in all_calls if "Windows reserves these names" in msg), None) + assert tip_message is not None, f"Missing Windows tip for reserved name {reserved_name}: {all_calls}" \ No newline at end of file