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