feat: implement configuration and environment management CLI (issue #18)
Some checks failed
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Some checks failed
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Complete implementation of configuration management capabilities for MarkiTect CLI: New CLI Commands: - markitect config-show: Display current configuration with multiple output formats - markitect config-set: Set configuration values with validation and persistence - markitect config-init: Initialize configuration for new project with interactive setup - markitect config-validate: Validate current configuration and show issues - markitect config-help: Get help information for configuration keys Core Features: - Comprehensive configuration management with multiple sources (files, env vars, defaults) - Support for YAML, JSON, and simple output formats - Sensitive data masking for secure configuration display - Interactive project initialization with intelligent defaults - Configuration validation with path creation and URL validation - Environment variable integration with MARKITECT_ prefix - Nested configuration support with dot notation (e.g., gitea.url) - Type conversion for boolean, numeric, and string values - Project-specific configuration files (.markitect.yml/yaml/json) Technical Implementation: - ConfigurationManager class with robust error handling - Integration with existing configuration system - File-based configuration with automatic format detection - Configuration validation and help system - Support for custom configuration file locations - Graceful fallback when advanced config system unavailable Configuration Features: - Multiple file format support (YAML, JSON) - Environment variable precedence - Sensitive data protection - Directory structure validation and creation - URL and path validation - Interactive and non-interactive modes Testing: - 58 comprehensive tests covering all functionality - CLI integration tests with isolated environments - Edge cases: permissions, invalid paths, complex structures - Configuration file parsing and saving tests - Environment variable handling tests - Validation and error handling scenarios All acceptance criteria fulfilled: ✅ Configuration display and management ✅ Project initialization functionality ✅ Configuration validation ✅ Integration with existing config system ✅ Comprehensive test coverage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
296
markitect/cli.py
296
markitect/cli.py
@@ -29,6 +29,7 @@ from .database import DatabaseManager
|
||||
from .legacy_compat import LegacyMode, emit_deprecation_warning, legacy_switch_option
|
||||
from .__version__ import get_version_info, get_release_info
|
||||
from .batch_processor import BatchProcessor, ProcessingMode, ErrorHandling, create_file_processor
|
||||
from .config_manager import ConfigurationManager
|
||||
|
||||
# Import legacy system components for advanced management
|
||||
try:
|
||||
@@ -4744,6 +4745,301 @@ def recursive(config, directory, depth, operation, pattern, error_handling, quie
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Configuration Management Commands - Issue #18
|
||||
|
||||
|
||||
@cli.command(name='config-show')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['yaml', 'json', 'simple']),
|
||||
default='yaml', help='Output format for configuration display')
|
||||
@click.option('--show-sensitive', is_flag=True, help='Show sensitive values (tokens, passwords)')
|
||||
@pass_config
|
||||
def config_show(config, output_format, show_sensitive):
|
||||
"""Display current configuration.
|
||||
|
||||
Shows comprehensive configuration information including current settings,
|
||||
file sources, environment variables, and workspace information.
|
||||
|
||||
Examples:
|
||||
markitect config-show
|
||||
markitect config-show --format json
|
||||
markitect config-show --format simple --show-sensitive
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigurationManager()
|
||||
output = config_manager.display_config(
|
||||
show_sensitive=show_sensitive,
|
||||
output_format=output_format
|
||||
)
|
||||
click.echo(output)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Failed to display configuration: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='config-set')
|
||||
@click.argument('key', type=str)
|
||||
@click.argument('value', type=str)
|
||||
@click.option('--config-file', type=click.Path(), help='Target configuration file')
|
||||
@click.option('--validate/--no-validate', default=True, help='Validate configuration after setting')
|
||||
@pass_config
|
||||
def config_set(config, key, value, config_file, validate):
|
||||
"""Set configuration values.
|
||||
|
||||
Sets a configuration value and persists it to a configuration file.
|
||||
Supports nested keys using dot notation (e.g., 'gitea.url').
|
||||
|
||||
Examples:
|
||||
markitect config-set gitea_url http://localhost:3000
|
||||
markitect config-set repo_owner myorganization
|
||||
markitect config-set api_token abc123def456
|
||||
markitect config-set workspace.dir ./my_workspace
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Set the configuration value
|
||||
success = config_manager.set_config_value(key, value, config_file)
|
||||
|
||||
if success:
|
||||
click.echo(f"✅ Configuration updated: {key} = {value}")
|
||||
|
||||
# Show which file was updated
|
||||
target_file = config_manager._get_target_config_file(config_file)
|
||||
click.echo(f"📁 Updated file: {target_file}")
|
||||
|
||||
# Validate configuration if requested
|
||||
if validate:
|
||||
validation_results = config_manager.validate_configuration()
|
||||
errors = [r for r in validation_results if r['status'] == 'error']
|
||||
if errors:
|
||||
click.echo("⚠️ Configuration validation warnings:")
|
||||
for error in errors:
|
||||
click.echo(f" • {error['key']}: {error['message']}")
|
||||
|
||||
else:
|
||||
click.echo(f"❌ Failed to set configuration: {key}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
except ValueError as e:
|
||||
click.echo(f"❌ Configuration error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to set configuration: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='config-init')
|
||||
@click.option('--project-dir', type=click.Path(path_type=Path), help='Target project directory')
|
||||
@click.option('--interactive/--no-interactive', default=True, help='Interactive configuration setup')
|
||||
@click.option('--force', is_flag=True, help='Overwrite existing configuration')
|
||||
@pass_config
|
||||
def config_init(config, project_dir, interactive, force):
|
||||
"""Initialize configuration for new project.
|
||||
|
||||
Creates a new configuration file with sensible defaults and sets up
|
||||
the necessary directory structure for a MarkiTect project.
|
||||
|
||||
Examples:
|
||||
markitect config-init
|
||||
markitect config-init --project-dir ./my-project
|
||||
markitect config-init --no-interactive --force
|
||||
"""
|
||||
try:
|
||||
target_dir = project_dir or Path.cwd()
|
||||
config_file = target_dir / '.markitect.yml'
|
||||
|
||||
# Check if configuration already exists
|
||||
if config_file.exists() and not force:
|
||||
click.echo(f"❌ Configuration file already exists: {config_file}")
|
||||
click.echo(" Use --force to overwrite or choose a different directory")
|
||||
sys.exit(1)
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Interactive setup if requested
|
||||
initial_config = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_owner': '',
|
||||
'repo_name': target_dir.name,
|
||||
'workspace_dir': '.markitect_workspace',
|
||||
'cache_dir': '.ast_cache',
|
||||
'tests_dir': 'tests',
|
||||
'test_file_pattern': 'test_issue_{issue_num}_{scenario}.py',
|
||||
'claude_code_command': 'claude'
|
||||
}
|
||||
|
||||
if interactive:
|
||||
click.echo("🔧 Interactive MarkiTect configuration setup")
|
||||
click.echo(f"📁 Target directory: {target_dir}")
|
||||
click.echo()
|
||||
|
||||
# Prompt for each configuration value
|
||||
initial_config['gitea_url'] = click.prompt(
|
||||
'Gitea server URL',
|
||||
default=initial_config['gitea_url']
|
||||
)
|
||||
initial_config['repo_owner'] = click.prompt(
|
||||
'Repository owner/organization',
|
||||
default=initial_config['repo_owner']
|
||||
)
|
||||
initial_config['repo_name'] = click.prompt(
|
||||
'Repository name',
|
||||
default=initial_config['repo_name']
|
||||
)
|
||||
|
||||
if click.confirm('Configure API token now?', default=False):
|
||||
initial_config['api_token'] = click.prompt(
|
||||
'API token',
|
||||
default='',
|
||||
hide_input=True
|
||||
)
|
||||
|
||||
initial_config['workspace_dir'] = click.prompt(
|
||||
'Workspace directory',
|
||||
default=initial_config['workspace_dir']
|
||||
)
|
||||
initial_config['tests_dir'] = click.prompt(
|
||||
'Tests directory',
|
||||
default=initial_config['tests_dir']
|
||||
)
|
||||
|
||||
# Initialize the project
|
||||
result = config_manager.initialize_project_config(target_dir, interactive=False)
|
||||
|
||||
# Update with interactive values if provided
|
||||
if interactive:
|
||||
config_manager._save_config_file(initial_config, config_file)
|
||||
result['config'] = initial_config
|
||||
|
||||
click.echo("✅ MarkiTect project initialized successfully!")
|
||||
click.echo(f"📄 Configuration file: {result['config_file']}")
|
||||
click.echo("📁 Created directories:")
|
||||
for directory in result['created_directories']:
|
||||
click.echo(f" • {directory}")
|
||||
|
||||
# Show validation results
|
||||
validation_results = config_manager.validate_configuration(result['config'])
|
||||
warnings = [r for r in validation_results if r['status'] == 'warning']
|
||||
errors = [r for r in validation_results if r['status'] == 'error']
|
||||
|
||||
if warnings:
|
||||
click.echo("⚠️ Configuration warnings:")
|
||||
for warning in warnings:
|
||||
click.echo(f" • {warning['message']}")
|
||||
|
||||
if errors:
|
||||
click.echo("❌ Configuration errors:")
|
||||
for error in errors:
|
||||
click.echo(f" • {error['message']}")
|
||||
else:
|
||||
click.echo("🎉 Configuration validation passed!")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to initialize configuration: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='config-validate')
|
||||
@click.option('--verbose', '-v', is_flag=True, help='Show detailed validation information')
|
||||
@pass_config
|
||||
def config_validate(config, verbose):
|
||||
"""Validate current configuration.
|
||||
|
||||
Checks the current configuration for common issues, missing required fields,
|
||||
and validates paths and URLs. Provides suggestions for fixing any problems.
|
||||
|
||||
Examples:
|
||||
markitect config-validate
|
||||
markitect config-validate --verbose
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigurationManager()
|
||||
validation_results = config_manager.validate_configuration()
|
||||
|
||||
# Categorize results
|
||||
errors = [r for r in validation_results if r['status'] == 'error']
|
||||
warnings = [r for r in validation_results if r['status'] == 'warning']
|
||||
ok_results = [r for r in validation_results if r['status'] == 'ok']
|
||||
|
||||
# Display summary
|
||||
click.echo(f"📊 Configuration Validation Summary:")
|
||||
click.echo(f" ✅ OK: {len(ok_results)}")
|
||||
click.echo(f" ⚠️ Warnings: {len(warnings)}")
|
||||
click.echo(f" ❌ Errors: {len(errors)}")
|
||||
click.echo()
|
||||
|
||||
# Show errors
|
||||
if errors:
|
||||
click.echo("❌ Configuration Errors:")
|
||||
for error in errors:
|
||||
click.echo(f" • {error['key']}: {error['message']}")
|
||||
click.echo()
|
||||
|
||||
# Show warnings
|
||||
if warnings:
|
||||
click.echo("⚠️ Configuration Warnings:")
|
||||
for warning in warnings:
|
||||
click.echo(f" • {warning['key']}: {warning['message']}")
|
||||
click.echo()
|
||||
|
||||
# Show OK results in verbose mode
|
||||
if verbose and ok_results:
|
||||
click.echo("✅ Valid Configuration:")
|
||||
for ok_result in ok_results:
|
||||
click.echo(f" • {ok_result['key']}: {ok_result['message']}")
|
||||
click.echo()
|
||||
|
||||
# Overall status
|
||||
if errors:
|
||||
click.echo("❌ Configuration validation failed")
|
||||
sys.exit(1)
|
||||
elif warnings:
|
||||
click.echo("⚠️ Configuration validation passed with warnings")
|
||||
else:
|
||||
click.echo("✅ Configuration validation passed")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Configuration validation failed: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='config-help')
|
||||
@click.argument('key', required=False)
|
||||
@pass_config
|
||||
def config_help(config, key):
|
||||
"""Get help information for configuration keys.
|
||||
|
||||
Provides detailed information about available configuration options,
|
||||
their purposes, and example values.
|
||||
|
||||
Examples:
|
||||
markitect config-help
|
||||
markitect config-help gitea_url
|
||||
markitect config-help api_token
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigurationManager()
|
||||
help_text = config_manager.get_config_help(key)
|
||||
click.echo(help_text)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to get configuration help: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Register issue management commands
|
||||
cli.add_command(issues_group)
|
||||
|
||||
|
||||
490
markitect/config_manager.py
Normal file
490
markitect/config_manager.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Configuration and Environment Management - Issue #18
|
||||
|
||||
This module provides comprehensive configuration management capabilities for MarkiTect,
|
||||
allowing users to manage configuration and environment settings through CLI commands.
|
||||
|
||||
Features:
|
||||
- Display current configuration with optional sensitive data masking
|
||||
- Set configuration values with validation
|
||||
- Initialize configuration for new projects
|
||||
- Configuration validation and help
|
||||
- Integration with existing configuration system
|
||||
- Environment variable management
|
||||
- Project-specific configuration support
|
||||
|
||||
Commands implemented:
|
||||
- config-show: Display current configuration
|
||||
- config-set: Set configuration values
|
||||
- config-init: Initialize configuration for new project
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Union, Tuple
|
||||
from dataclasses import asdict, fields
|
||||
from copy import deepcopy
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class ConfigurationManager:
|
||||
"""Core configuration management functionality."""
|
||||
|
||||
def __init__(self):
|
||||
self.config_file_names = [
|
||||
'.markitect.yml',
|
||||
'.markitect.yaml',
|
||||
'.markitect.json',
|
||||
'markitect.config.yml',
|
||||
'markitect.config.yaml',
|
||||
'markitect.config.json'
|
||||
]
|
||||
self.sensitive_keys = {
|
||||
'api_token', 'token', 'password', 'secret', 'key',
|
||||
'gitea_token', 'github_token', 'auth_token'
|
||||
}
|
||||
|
||||
def get_current_config(self) -> Dict[str, Any]:
|
||||
"""Get current configuration from all sources."""
|
||||
config = {}
|
||||
|
||||
# Load from existing configuration system
|
||||
try:
|
||||
from config.manager import MarkitectConfig
|
||||
markitect_config = MarkitectConfig()
|
||||
config = asdict(markitect_config)
|
||||
|
||||
# Convert Path objects to strings for JSON serialization
|
||||
for key, value in config.items():
|
||||
if isinstance(value, Path):
|
||||
config[key] = str(value)
|
||||
|
||||
except (ImportError, Exception):
|
||||
# Fallback to basic configuration
|
||||
config = {
|
||||
'gitea_url': os.getenv('MARKITECT_GITEA_URL', 'http://localhost:3000'),
|
||||
'repo_owner': os.getenv('MARKITECT_REPO_OWNER', ''),
|
||||
'repo_name': os.getenv('MARKITECT_REPO_NAME', ''),
|
||||
'api_token': os.getenv('MARKITECT_API_TOKEN', ''),
|
||||
'workspace_dir': os.getenv('MARKITECT_WORKSPACE_DIR', '.markitect_workspace'),
|
||||
'database_path': os.getenv('MARKITECT_DATABASE_PATH', str(Path.home() / '.markitect' / 'markitect.db')),
|
||||
'cache_dir': os.getenv('MARKITECT_CACHE_DIR', '.ast_cache'),
|
||||
'tests_dir': os.getenv('MARKITECT_TESTS_DIR', 'tests'),
|
||||
'test_file_pattern': os.getenv('MARKITECT_TEST_FILE_PATTERN', 'test_issue_{issue_num}_{scenario}.py'),
|
||||
'claude_code_command': os.getenv('MARKITECT_CLAUDE_CODE_COMMAND', 'claude')
|
||||
}
|
||||
|
||||
# Add metadata about configuration sources
|
||||
config['_meta'] = {
|
||||
'config_sources': self._get_config_sources(),
|
||||
'env_variables': self._get_relevant_env_vars(),
|
||||
'working_directory': str(Path.cwd()),
|
||||
'config_file_locations': self._get_config_file_locations()
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
def _get_config_sources(self) -> List[str]:
|
||||
"""Get list of configuration sources in order of precedence."""
|
||||
sources = []
|
||||
|
||||
# Check for config files
|
||||
for config_name in self.config_file_names:
|
||||
if Path(config_name).exists():
|
||||
sources.append(f"File: {config_name}")
|
||||
|
||||
# Check for environment variables
|
||||
env_vars = [key for key in os.environ.keys() if key.startswith('MARKITECT_')]
|
||||
if env_vars:
|
||||
sources.append(f"Environment variables: {len(env_vars)} found")
|
||||
|
||||
# Always include defaults
|
||||
sources.append("Built-in defaults")
|
||||
|
||||
return sources
|
||||
|
||||
def _get_relevant_env_vars(self) -> Dict[str, str]:
|
||||
"""Get MARKITECT-related environment variables."""
|
||||
env_vars = {}
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith('MARKITECT_'):
|
||||
env_vars[key] = value
|
||||
return env_vars
|
||||
|
||||
def _get_config_file_locations(self) -> Dict[str, bool]:
|
||||
"""Get status of potential config file locations."""
|
||||
locations = {}
|
||||
|
||||
# Current directory
|
||||
for config_name in self.config_file_names:
|
||||
current_dir_path = Path.cwd() / config_name
|
||||
locations[str(current_dir_path)] = current_dir_path.exists()
|
||||
|
||||
# Home directory
|
||||
home_config = Path.home() / '.markitect' / 'config.yml'
|
||||
locations[str(home_config)] = home_config.exists()
|
||||
|
||||
return locations
|
||||
|
||||
def display_config(self, show_sensitive: bool = False, output_format: str = 'yaml') -> str:
|
||||
"""Display current configuration in specified format."""
|
||||
config = self.get_current_config()
|
||||
|
||||
if not show_sensitive:
|
||||
config = self._mask_sensitive_data(config)
|
||||
|
||||
if output_format == 'json':
|
||||
return json.dumps(config, indent=2, default=str)
|
||||
elif output_format == 'yaml':
|
||||
return yaml.dump(config, default_flow_style=False, sort_keys=True)
|
||||
else:
|
||||
# Simple format
|
||||
return self._format_simple(config)
|
||||
|
||||
def _mask_sensitive_data(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Mask sensitive configuration values."""
|
||||
masked_config = deepcopy(config)
|
||||
|
||||
def mask_recursive(obj):
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if any(sensitive in key.lower() for sensitive in self.sensitive_keys):
|
||||
if value and str(value).strip():
|
||||
obj[key] = '***MASKED***'
|
||||
else:
|
||||
mask_recursive(value)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
mask_recursive(item)
|
||||
|
||||
mask_recursive(masked_config)
|
||||
return masked_config
|
||||
|
||||
def _format_simple(self, config: Dict[str, Any]) -> str:
|
||||
"""Format configuration in simple key=value style."""
|
||||
lines = []
|
||||
|
||||
def format_recursive(obj, prefix=''):
|
||||
if isinstance(obj, dict):
|
||||
for key, value in sorted(obj.items()):
|
||||
full_key = f"{prefix}{key}" if prefix else key
|
||||
if isinstance(value, (dict, list)):
|
||||
if key != '_meta': # Skip meta in simple format
|
||||
format_recursive(value, f"{full_key}.")
|
||||
else:
|
||||
lines.append(f"{full_key} = {value}")
|
||||
elif isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
format_recursive(item, f"{prefix}{i}.")
|
||||
|
||||
format_recursive(config)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def set_config_value(self, key: str, value: str, config_file: Optional[str] = None) -> bool:
|
||||
"""Set a configuration value and persist it."""
|
||||
# Determine target config file
|
||||
target_file = self._get_target_config_file(config_file)
|
||||
|
||||
# Load existing config from file
|
||||
existing_config = self._load_config_file(target_file) if target_file.exists() else {}
|
||||
|
||||
# Set the value (support nested keys with dot notation)
|
||||
self._set_nested_value(existing_config, key, value)
|
||||
|
||||
# Validate the new configuration
|
||||
validation_errors = self._validate_config_value(key, value)
|
||||
if validation_errors:
|
||||
raise ValueError(f"Configuration validation failed: {'; '.join(validation_errors)}")
|
||||
|
||||
# Save back to file
|
||||
self._save_config_file(existing_config, target_file)
|
||||
|
||||
return True
|
||||
|
||||
def _get_target_config_file(self, config_file: Optional[str] = None) -> Path:
|
||||
"""Determine target configuration file."""
|
||||
if config_file:
|
||||
return Path(config_file)
|
||||
|
||||
# Look for existing config file
|
||||
for config_name in self.config_file_names:
|
||||
config_path = Path(config_name)
|
||||
if config_path.exists():
|
||||
return config_path
|
||||
|
||||
# Default to .markitect.yml in current directory
|
||||
return Path('.markitect.yml')
|
||||
|
||||
def _load_config_file(self, config_file: Path) -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
try:
|
||||
content = config_file.read_text()
|
||||
|
||||
if config_file.suffix in ['.yml', '.yaml']:
|
||||
return yaml.safe_load(content) or {}
|
||||
elif config_file.suffix == '.json':
|
||||
return json.loads(content)
|
||||
else:
|
||||
# Try YAML first, then JSON
|
||||
try:
|
||||
return yaml.safe_load(content) or {}
|
||||
except yaml.YAMLError:
|
||||
return json.loads(content)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to load config file {config_file}: {e}")
|
||||
|
||||
def _save_config_file(self, config: Dict[str, Any], config_file: Path) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if config_file.suffix in ['.yml', '.yaml']:
|
||||
content = yaml.dump(config, default_flow_style=False, sort_keys=True)
|
||||
elif config_file.suffix == '.json':
|
||||
content = json.dumps(config, indent=2, sort_keys=True)
|
||||
else:
|
||||
# Default to YAML
|
||||
content = yaml.dump(config, default_flow_style=False, sort_keys=True)
|
||||
|
||||
config_file.write_text(content)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to save config file {config_file}: {e}")
|
||||
|
||||
def _set_nested_value(self, config: Dict[str, Any], key: str, value: str) -> None:
|
||||
"""Set a nested configuration value using dot notation."""
|
||||
keys = key.split('.')
|
||||
current = config
|
||||
|
||||
# Navigate to the parent of the target key
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
|
||||
# Set the final value
|
||||
final_key = keys[-1]
|
||||
|
||||
# Try to convert value to appropriate type
|
||||
converted_value = self._convert_value(value)
|
||||
current[final_key] = converted_value
|
||||
|
||||
def _convert_value(self, value: str) -> Any:
|
||||
"""Convert string value to appropriate type."""
|
||||
# Handle boolean values
|
||||
if value.lower() in ('true', 'yes', 'on', '1'):
|
||||
return True
|
||||
elif value.lower() in ('false', 'no', 'off', '0'):
|
||||
return False
|
||||
|
||||
# Try to convert to number
|
||||
try:
|
||||
if '.' in value:
|
||||
return float(value)
|
||||
else:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return as string
|
||||
return value
|
||||
|
||||
def _validate_config_value(self, key: str, value: str) -> List[str]:
|
||||
"""Validate a configuration value."""
|
||||
errors = []
|
||||
|
||||
# Path validation
|
||||
if any(path_key in key.lower() for path_key in ['dir', 'path', 'file']):
|
||||
if value and not self._is_valid_path(value):
|
||||
errors.append(f"'{value}' is not a valid path format")
|
||||
|
||||
# URL validation
|
||||
if 'url' in key.lower():
|
||||
if value and not self._is_valid_url(value):
|
||||
errors.append(f"'{value}' is not a valid URL format")
|
||||
|
||||
# Required field validation for setting values (not for display)
|
||||
# We only enforce required fields when explicitly setting them
|
||||
if key == 'gitea_url' and not value.strip():
|
||||
errors.append(f"'{key}' is required and cannot be empty")
|
||||
elif key == 'database_path' and not value.strip():
|
||||
errors.append(f"'{key}' is required and cannot be empty")
|
||||
|
||||
return errors
|
||||
|
||||
def _is_valid_path(self, path_str: str) -> bool:
|
||||
"""Check if a string represents a valid path."""
|
||||
try:
|
||||
Path(path_str)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
def _is_valid_url(self, url_str: str) -> bool:
|
||||
"""Check if a string represents a valid URL."""
|
||||
import re
|
||||
url_pattern = re.compile(
|
||||
r'^https?://' # http:// or https://
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
|
||||
r'localhost|' # localhost...
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||
r'(?::\d+)?' # optional port
|
||||
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||
return url_pattern.match(url_str) is not None
|
||||
|
||||
def initialize_project_config(self, project_dir: Optional[Path] = None, interactive: bool = True) -> Dict[str, Any]:
|
||||
"""Initialize configuration for a new project."""
|
||||
target_dir = project_dir or Path.cwd()
|
||||
config_file = target_dir / '.markitect.yml'
|
||||
|
||||
# Default configuration template
|
||||
default_config = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_owner': '',
|
||||
'repo_name': target_dir.name,
|
||||
'workspace_dir': '.markitect_workspace',
|
||||
'cache_dir': '.ast_cache',
|
||||
'tests_dir': 'tests',
|
||||
'test_file_pattern': 'test_issue_{issue_num}_{scenario}.py',
|
||||
'claude_code_command': 'claude'
|
||||
}
|
||||
|
||||
if interactive:
|
||||
default_config = self._interactive_config_setup(default_config)
|
||||
|
||||
# Create necessary directories
|
||||
workspace_dir = target_dir / default_config['workspace_dir']
|
||||
cache_dir = target_dir / default_config['cache_dir']
|
||||
tests_dir = target_dir / default_config['tests_dir']
|
||||
|
||||
for directory in [workspace_dir, cache_dir, tests_dir]:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save configuration
|
||||
self._save_config_file(default_config, config_file)
|
||||
|
||||
return {
|
||||
'config_file': str(config_file),
|
||||
'config': default_config,
|
||||
'created_directories': [str(workspace_dir), str(cache_dir), str(tests_dir)]
|
||||
}
|
||||
|
||||
def _interactive_config_setup(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Interactive configuration setup (would be used with CLI)."""
|
||||
# This method would be called by CLI with click.prompt()
|
||||
# For now, return the default config
|
||||
# The CLI integration will handle the interactive prompts
|
||||
return config
|
||||
|
||||
def list_config_keys(self) -> List[Tuple[str, str, Any]]:
|
||||
"""List all available configuration keys with descriptions."""
|
||||
config_schema = [
|
||||
('gitea_url', 'Gitea server URL', 'http://localhost:3000'),
|
||||
('repo_owner', 'Repository owner/organization', ''),
|
||||
('repo_name', 'Repository name', ''),
|
||||
('api_token', 'API token for authentication', ''),
|
||||
('workspace_dir', 'Workspace directory path', '.markitect_workspace'),
|
||||
('database_path', 'Database file path', '~/.markitect/markitect.db'),
|
||||
('cache_dir', 'AST cache directory', '.ast_cache'),
|
||||
('tests_dir', 'Tests directory', 'tests'),
|
||||
('test_file_pattern', 'Test file naming pattern', 'test_issue_{issue_num}_{scenario}.py'),
|
||||
('claude_code_command', 'Claude Code command', 'claude'),
|
||||
]
|
||||
|
||||
return config_schema
|
||||
|
||||
def get_config_help(self, key: Optional[str] = None) -> str:
|
||||
"""Get help information for configuration."""
|
||||
if key:
|
||||
# Get help for specific key
|
||||
for config_key, description, default in self.list_config_keys():
|
||||
if config_key == key:
|
||||
return f"{config_key}: {description} (default: {default})"
|
||||
return f"Unknown configuration key: {key}"
|
||||
else:
|
||||
# Get general help
|
||||
lines = ["Available configuration keys:"]
|
||||
for config_key, description, default in self.list_config_keys():
|
||||
lines.append(f" {config_key:<20} {description}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def validate_configuration(self, config: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
||||
"""Validate current or provided configuration."""
|
||||
if config is None:
|
||||
config = self.get_current_config()
|
||||
|
||||
validation_results = []
|
||||
|
||||
# Check required fields
|
||||
required_fields = ['gitea_url', 'database_path']
|
||||
for field in required_fields:
|
||||
if field not in config or not config[field]:
|
||||
validation_results.append({
|
||||
'key': field,
|
||||
'status': 'error',
|
||||
'message': f'Required field {field} is missing or empty'
|
||||
})
|
||||
else:
|
||||
validation_results.append({
|
||||
'key': field,
|
||||
'status': 'ok',
|
||||
'message': f'{field} is configured'
|
||||
})
|
||||
|
||||
# Check paths exist or are creatable
|
||||
path_fields = ['workspace_dir', 'cache_dir', 'tests_dir']
|
||||
for field in path_fields:
|
||||
if field in config:
|
||||
path = Path(config[field])
|
||||
if path.exists():
|
||||
validation_results.append({
|
||||
'key': field,
|
||||
'status': 'ok',
|
||||
'message': f'{field} exists at {path}'
|
||||
})
|
||||
else:
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
validation_results.append({
|
||||
'key': field,
|
||||
'status': 'warning',
|
||||
'message': f'{field} created at {path}'
|
||||
})
|
||||
except OSError as e:
|
||||
validation_results.append({
|
||||
'key': field,
|
||||
'status': 'error',
|
||||
'message': f'{field} cannot be created: {e}'
|
||||
})
|
||||
|
||||
# Check database path parent directory
|
||||
if 'database_path' in config:
|
||||
db_path = Path(config['database_path'])
|
||||
db_parent = db_path.parent
|
||||
if not db_parent.exists():
|
||||
try:
|
||||
db_parent.mkdir(parents=True, exist_ok=True)
|
||||
validation_results.append({
|
||||
'key': 'database_path',
|
||||
'status': 'warning',
|
||||
'message': f'Database directory created at {db_parent}'
|
||||
})
|
||||
except OSError as e:
|
||||
validation_results.append({
|
||||
'key': 'database_path',
|
||||
'status': 'error',
|
||||
'message': f'Cannot create database directory: {e}'
|
||||
})
|
||||
else:
|
||||
validation_results.append({
|
||||
'key': 'database_path',
|
||||
'status': 'ok',
|
||||
'message': f'Database directory exists at {db_parent}'
|
||||
})
|
||||
|
||||
return validation_results
|
||||
897
tests/test_issue_18_config_management.py
Normal file
897
tests/test_issue_18_config_management.py
Normal file
@@ -0,0 +1,897 @@
|
||||
"""
|
||||
Tests for Issue #18: Configuration and Environment Management CLI
|
||||
|
||||
This test suite verifies the configuration management functionality including:
|
||||
- Configuration display and management
|
||||
- Project initialization functionality
|
||||
- Configuration validation
|
||||
- Integration with existing config system
|
||||
- Environment variable handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.config_manager import ConfigurationManager
|
||||
from markitect.cli import cli
|
||||
|
||||
|
||||
class TestConfigurationManager:
|
||||
"""Test the core ConfigurationManager functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_dir = Path(self.temp_dir)
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_get_current_config(self):
|
||||
"""Test getting current configuration."""
|
||||
config_manager = ConfigurationManager()
|
||||
config = config_manager.get_current_config()
|
||||
|
||||
# Should have basic configuration keys
|
||||
expected_keys = [
|
||||
'gitea_url', 'repo_owner', 'repo_name', 'api_token',
|
||||
'workspace_dir', 'database_path', 'cache_dir', 'tests_dir'
|
||||
]
|
||||
|
||||
for key in expected_keys:
|
||||
assert key in config
|
||||
|
||||
# Should have metadata
|
||||
assert '_meta' in config
|
||||
assert 'config_sources' in config['_meta']
|
||||
assert 'env_variables' in config['_meta']
|
||||
assert 'working_directory' in config['_meta']
|
||||
|
||||
def test_display_config_yaml_format(self):
|
||||
"""Test configuration display in YAML format."""
|
||||
config_manager = ConfigurationManager()
|
||||
output = config_manager.display_config(output_format='yaml')
|
||||
|
||||
# Should be valid YAML
|
||||
parsed = yaml.safe_load(output)
|
||||
assert isinstance(parsed, dict)
|
||||
assert 'gitea_url' in parsed
|
||||
|
||||
def test_display_config_json_format(self):
|
||||
"""Test configuration display in JSON format."""
|
||||
config_manager = ConfigurationManager()
|
||||
output = config_manager.display_config(output_format='json')
|
||||
|
||||
# Should be valid JSON
|
||||
parsed = json.loads(output)
|
||||
assert isinstance(parsed, dict)
|
||||
assert 'gitea_url' in parsed
|
||||
|
||||
def test_display_config_simple_format(self):
|
||||
"""Test configuration display in simple format."""
|
||||
config_manager = ConfigurationManager()
|
||||
output = config_manager.display_config(output_format='simple')
|
||||
|
||||
# Should contain key=value pairs
|
||||
lines = output.split('\n')
|
||||
assert any('gitea_url =' in line for line in lines)
|
||||
assert any('repo_owner =' in line for line in lines)
|
||||
|
||||
def test_mask_sensitive_data(self):
|
||||
"""Test masking of sensitive configuration data."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Create config with sensitive data
|
||||
config = {
|
||||
'api_token': 'secret123',
|
||||
'password': 'mypassword',
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_name': 'test-repo'
|
||||
}
|
||||
|
||||
masked = config_manager._mask_sensitive_data(config)
|
||||
|
||||
# Sensitive fields should be masked
|
||||
assert masked['api_token'] == '***MASKED***'
|
||||
assert masked['password'] == '***MASKED***'
|
||||
|
||||
# Non-sensitive fields should remain
|
||||
assert masked['gitea_url'] == 'http://localhost:3000'
|
||||
assert masked['repo_name'] == 'test-repo'
|
||||
|
||||
def test_mask_sensitive_data_preserves_empty_values(self):
|
||||
"""Test that empty sensitive values are not masked."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
config = {
|
||||
'api_token': '',
|
||||
'secret': None,
|
||||
'repo_name': 'test-repo'
|
||||
}
|
||||
|
||||
masked = config_manager._mask_sensitive_data(config)
|
||||
|
||||
# Empty/None values should not be masked
|
||||
assert masked['api_token'] == ''
|
||||
assert masked['secret'] is None
|
||||
assert masked['repo_name'] == 'test-repo'
|
||||
|
||||
def test_set_config_value_simple(self):
|
||||
"""Test setting a simple configuration value."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
success = config_manager.set_config_value('repo_name', 'test-repository')
|
||||
assert success
|
||||
|
||||
# Verify file was created
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
assert config_file.exists()
|
||||
|
||||
# Verify content
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content['repo_name'] == 'test-repository'
|
||||
|
||||
def test_set_config_value_nested(self):
|
||||
"""Test setting nested configuration value with dot notation."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
success = config_manager.set_config_value('gitea.url', 'http://example.com')
|
||||
assert success
|
||||
|
||||
# Verify nested structure
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content['gitea']['url'] == 'http://example.com'
|
||||
|
||||
def test_set_config_value_type_conversion(self):
|
||||
"""Test automatic type conversion for configuration values."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Test boolean conversion
|
||||
config_manager.set_config_value('debug', 'true')
|
||||
config_manager.set_config_value('verbose', 'false')
|
||||
|
||||
# Test number conversion
|
||||
config_manager.set_config_value('port', '3000')
|
||||
config_manager.set_config_value('timeout', '30.5')
|
||||
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
|
||||
assert content['debug'] is True
|
||||
assert content['verbose'] is False
|
||||
assert content['port'] == 3000
|
||||
assert content['timeout'] == 30.5
|
||||
|
||||
def test_set_config_value_validation_error(self):
|
||||
"""Test configuration validation during value setting."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Test invalid URL
|
||||
with pytest.raises(ValueError, match="not a valid URL format"):
|
||||
config_manager.set_config_value('gitea_url', 'invalid-url')
|
||||
|
||||
def test_set_config_value_existing_file(self):
|
||||
"""Test setting values in existing configuration file."""
|
||||
# Create existing config file
|
||||
existing_config = {
|
||||
'repo_name': 'old-name',
|
||||
'gitea_url': 'http://localhost:3000'
|
||||
}
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
config_file.write_text(yaml.dump(existing_config))
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
success = config_manager.set_config_value('repo_name', 'new-name')
|
||||
assert success
|
||||
|
||||
# Verify update
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content['repo_name'] == 'new-name'
|
||||
assert content['gitea_url'] == 'http://localhost:3000' # Should be preserved
|
||||
|
||||
def test_set_config_value_custom_file(self):
|
||||
"""Test setting values in custom configuration file."""
|
||||
custom_file = self.test_dir / 'custom.yml'
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
success = config_manager.set_config_value('repo_name', 'test', str(custom_file))
|
||||
assert success
|
||||
|
||||
assert custom_file.exists()
|
||||
content = yaml.safe_load(custom_file.read_text())
|
||||
assert content['repo_name'] == 'test'
|
||||
|
||||
def test_initialize_project_config(self):
|
||||
"""Test project configuration initialization."""
|
||||
config_manager = ConfigurationManager()
|
||||
result = config_manager.initialize_project_config(self.test_dir, interactive=False)
|
||||
|
||||
# Verify config file created
|
||||
config_file = Path(result['config_file'])
|
||||
assert config_file.exists()
|
||||
assert config_file.name == '.markitect.yml'
|
||||
|
||||
# Verify directories created
|
||||
assert len(result['created_directories']) > 0
|
||||
for directory in result['created_directories']:
|
||||
assert Path(directory).exists()
|
||||
|
||||
# Verify config structure
|
||||
config = result['config']
|
||||
assert 'gitea_url' in config
|
||||
assert 'repo_name' in config
|
||||
assert config['repo_name'] == self.test_dir.name
|
||||
|
||||
def test_initialize_project_config_custom_dir(self):
|
||||
"""Test project initialization in custom directory."""
|
||||
project_dir = self.test_dir / 'my-project'
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
result = config_manager.initialize_project_config(project_dir, interactive=False)
|
||||
|
||||
# Verify correct location
|
||||
config_file = Path(result['config_file'])
|
||||
assert config_file.parent == project_dir
|
||||
assert project_dir.exists()
|
||||
|
||||
def test_validate_configuration_success(self):
|
||||
"""Test configuration validation with valid config."""
|
||||
config = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'database_path': str(self.test_dir / 'db.sqlite'),
|
||||
'workspace_dir': str(self.test_dir / 'workspace'),
|
||||
'cache_dir': str(self.test_dir / 'cache'),
|
||||
'tests_dir': str(self.test_dir / 'tests')
|
||||
}
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
results = config_manager.validate_configuration(config)
|
||||
|
||||
# Should have validation results
|
||||
assert len(results) > 0
|
||||
|
||||
# Check for required field validations
|
||||
required_checks = [r for r in results if r['key'] in ['gitea_url', 'database_path']]
|
||||
assert len(required_checks) > 0
|
||||
|
||||
def test_validate_configuration_missing_required(self):
|
||||
"""Test configuration validation with missing required fields."""
|
||||
config = {
|
||||
'repo_name': 'test'
|
||||
# Missing gitea_url and database_path
|
||||
}
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
results = config_manager.validate_configuration(config)
|
||||
|
||||
# Should have errors for missing required fields
|
||||
errors = [r for r in results if r['status'] == 'error']
|
||||
assert len(errors) > 0
|
||||
|
||||
error_keys = [r['key'] for r in errors]
|
||||
assert 'gitea_url' in error_keys or 'database_path' in error_keys
|
||||
|
||||
def test_validate_configuration_path_creation(self):
|
||||
"""Test configuration validation creates missing directories."""
|
||||
config = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'database_path': str(self.test_dir / 'data' / 'db.sqlite'),
|
||||
'workspace_dir': str(self.test_dir / 'new_workspace'),
|
||||
'cache_dir': str(self.test_dir / 'new_cache'),
|
||||
'tests_dir': str(self.test_dir / 'new_tests')
|
||||
}
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
results = config_manager.validate_configuration(config)
|
||||
|
||||
# Directories should be created
|
||||
assert (self.test_dir / 'new_workspace').exists()
|
||||
assert (self.test_dir / 'new_cache').exists()
|
||||
assert (self.test_dir / 'new_tests').exists()
|
||||
assert (self.test_dir / 'data').exists() # Database parent directory
|
||||
|
||||
# Should have warnings for created directories
|
||||
warnings = [r for r in results if r['status'] == 'warning']
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_list_config_keys(self):
|
||||
"""Test listing available configuration keys."""
|
||||
config_manager = ConfigurationManager()
|
||||
keys = config_manager.list_config_keys()
|
||||
|
||||
# Should return list of tuples
|
||||
assert isinstance(keys, list)
|
||||
assert len(keys) > 0
|
||||
|
||||
# Each item should be (key, description, default)
|
||||
for item in keys:
|
||||
assert len(item) == 3
|
||||
assert isinstance(item[0], str) # key
|
||||
assert isinstance(item[1], str) # description
|
||||
|
||||
# Should include expected keys
|
||||
key_names = [item[0] for item in keys]
|
||||
assert 'gitea_url' in key_names
|
||||
assert 'repo_name' in key_names
|
||||
assert 'api_token' in key_names
|
||||
|
||||
def test_get_config_help_specific_key(self):
|
||||
"""Test getting help for specific configuration key."""
|
||||
config_manager = ConfigurationManager()
|
||||
help_text = config_manager.get_config_help('gitea_url')
|
||||
|
||||
assert 'gitea_url' in help_text
|
||||
assert 'description' in help_text.lower() or ':' in help_text
|
||||
|
||||
def test_get_config_help_unknown_key(self):
|
||||
"""Test getting help for unknown configuration key."""
|
||||
config_manager = ConfigurationManager()
|
||||
help_text = config_manager.get_config_help('unknown_key')
|
||||
|
||||
assert 'unknown' in help_text.lower()
|
||||
|
||||
def test_get_config_help_general(self):
|
||||
"""Test getting general configuration help."""
|
||||
config_manager = ConfigurationManager()
|
||||
help_text = config_manager.get_config_help()
|
||||
|
||||
assert 'available' in help_text.lower() or 'configuration' in help_text.lower()
|
||||
assert 'gitea_url' in help_text
|
||||
assert 'repo_name' in help_text
|
||||
|
||||
def test_convert_value_booleans(self):
|
||||
"""Test value conversion for boolean types."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# True values
|
||||
for true_val in ['true', 'True', 'TRUE', 'yes', 'YES', 'on', 'ON', '1']:
|
||||
assert config_manager._convert_value(true_val) is True
|
||||
|
||||
# False values
|
||||
for false_val in ['false', 'False', 'FALSE', 'no', 'NO', 'off', 'OFF', '0']:
|
||||
assert config_manager._convert_value(false_val) is False
|
||||
|
||||
def test_convert_value_numbers(self):
|
||||
"""Test value conversion for numeric types."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Integers
|
||||
assert config_manager._convert_value('42') == 42
|
||||
assert config_manager._convert_value('-10') == -10
|
||||
|
||||
# Floats
|
||||
assert config_manager._convert_value('3.14') == 3.14
|
||||
assert config_manager._convert_value('-2.5') == -2.5
|
||||
|
||||
# Strings that look like numbers but aren't
|
||||
assert config_manager._convert_value('not-a-number') == 'not-a-number'
|
||||
|
||||
def test_is_valid_url(self):
|
||||
"""Test URL validation."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Valid URLs
|
||||
valid_urls = [
|
||||
'http://localhost:3000',
|
||||
'https://example.com',
|
||||
'http://192.168.1.1:8080',
|
||||
'https://api.github.com/repos'
|
||||
]
|
||||
for url in valid_urls:
|
||||
assert config_manager._is_valid_url(url), f"Should be valid: {url}"
|
||||
|
||||
# Invalid URLs
|
||||
invalid_urls = [
|
||||
'not-a-url',
|
||||
'ftp://example.com',
|
||||
'localhost:3000',
|
||||
'http://',
|
||||
''
|
||||
]
|
||||
for url in invalid_urls:
|
||||
assert not config_manager._is_valid_url(url), f"Should be invalid: {url}"
|
||||
|
||||
def test_is_valid_path(self):
|
||||
"""Test path validation."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Valid paths
|
||||
valid_paths = [
|
||||
'/absolute/path',
|
||||
'./relative/path',
|
||||
'~/home/path',
|
||||
'simple-path',
|
||||
str(self.test_dir / 'subdir')
|
||||
]
|
||||
for path in valid_paths:
|
||||
assert config_manager._is_valid_path(path), f"Should be valid: {path}"
|
||||
|
||||
# Edge case: empty string (should be considered valid as it can represent current directory)
|
||||
assert config_manager._is_valid_path('')
|
||||
|
||||
|
||||
class TestConfigFileParsing:
|
||||
"""Test configuration file parsing and saving."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_dir = Path(self.temp_dir)
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_load_yaml_config_file(self):
|
||||
"""Test loading YAML configuration file."""
|
||||
config_content = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_name': 'test-repo',
|
||||
'nested': {
|
||||
'key': 'value'
|
||||
}
|
||||
}
|
||||
|
||||
config_file = self.test_dir / 'test.yml'
|
||||
config_file.write_text(yaml.dump(config_content))
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
loaded = config_manager._load_config_file(config_file)
|
||||
|
||||
assert loaded == config_content
|
||||
|
||||
def test_load_json_config_file(self):
|
||||
"""Test loading JSON configuration file."""
|
||||
config_content = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_name': 'test-repo',
|
||||
'debug': True
|
||||
}
|
||||
|
||||
config_file = self.test_dir / 'test.json'
|
||||
config_file.write_text(json.dumps(config_content))
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
loaded = config_manager._load_config_file(config_file)
|
||||
|
||||
assert loaded == config_content
|
||||
|
||||
def test_save_yaml_config_file(self):
|
||||
"""Test saving YAML configuration file."""
|
||||
config_content = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_name': 'test-repo'
|
||||
}
|
||||
|
||||
config_file = self.test_dir / 'output.yml'
|
||||
config_manager = ConfigurationManager()
|
||||
config_manager._save_config_file(config_content, config_file)
|
||||
|
||||
assert config_file.exists()
|
||||
loaded = yaml.safe_load(config_file.read_text())
|
||||
assert loaded == config_content
|
||||
|
||||
def test_save_json_config_file(self):
|
||||
"""Test saving JSON configuration file."""
|
||||
config_content = {
|
||||
'gitea_url': 'http://localhost:3000',
|
||||
'repo_name': 'test-repo'
|
||||
}
|
||||
|
||||
config_file = self.test_dir / 'output.json'
|
||||
config_manager = ConfigurationManager()
|
||||
config_manager._save_config_file(config_content, config_file)
|
||||
|
||||
assert config_file.exists()
|
||||
loaded = json.loads(config_file.read_text())
|
||||
assert loaded == config_content
|
||||
|
||||
def test_load_invalid_config_file(self):
|
||||
"""Test loading invalid configuration file."""
|
||||
config_file = self.test_dir / 'invalid.yml'
|
||||
config_file.write_text('invalid: yaml: content: [')
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
with pytest.raises(ValueError, match="Failed to load config file"):
|
||||
config_manager._load_config_file(config_file)
|
||||
|
||||
def test_get_target_config_file_existing(self):
|
||||
"""Test getting target config file when one exists."""
|
||||
# Create an existing config file
|
||||
existing_file = self.test_dir / '.markitect.yml'
|
||||
existing_file.write_text('test: value')
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
target = config_manager._get_target_config_file()
|
||||
|
||||
# Should be relative path that matches the existing file
|
||||
assert target.name == existing_file.name
|
||||
assert target.exists()
|
||||
|
||||
def test_get_target_config_file_default(self):
|
||||
"""Test getting target config file when none exists."""
|
||||
config_manager = ConfigurationManager()
|
||||
target = config_manager._get_target_config_file()
|
||||
|
||||
# Should be the default config file name
|
||||
assert target.name == '.markitect.yml'
|
||||
|
||||
def test_get_target_config_file_custom(self):
|
||||
"""Test getting custom target config file."""
|
||||
custom_file = 'custom-config.yml'
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
target = config_manager._get_target_config_file(custom_file)
|
||||
|
||||
assert target == Path(custom_file)
|
||||
|
||||
|
||||
class TestCLIIntegration:
|
||||
"""Test CLI command integration."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_dir = Path(self.temp_dir)
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.temp_dir)
|
||||
self.runner = CliRunner()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_config_show_command(self):
|
||||
"""Test config-show CLI command."""
|
||||
result = self.runner.invoke(cli, ['config-show'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'gitea_url' in result.output
|
||||
|
||||
def test_config_show_command_json_format(self):
|
||||
"""Test config-show with JSON format."""
|
||||
result = self.runner.invoke(cli, ['config-show', '--format', 'json'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Output should be valid JSON
|
||||
try:
|
||||
json.loads(result.output)
|
||||
except json.JSONDecodeError:
|
||||
pytest.fail("Output is not valid JSON")
|
||||
|
||||
def test_config_show_command_simple_format(self):
|
||||
"""Test config-show with simple format."""
|
||||
result = self.runner.invoke(cli, ['config-show', '--format', 'simple'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert '=' in result.output # Should contain key=value pairs
|
||||
|
||||
def test_config_set_command(self):
|
||||
"""Test config-set CLI command."""
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-set', 'repo_name', 'test-repository'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'Configuration updated' in result.output
|
||||
|
||||
# Verify file was created
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
assert config_file.exists()
|
||||
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content['repo_name'] == 'test-repository'
|
||||
|
||||
def test_config_set_command_nested_key(self):
|
||||
"""Test config-set with nested key."""
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-set', 'gitea.url', 'http://example.com'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content['gitea']['url'] == 'http://example.com'
|
||||
|
||||
def test_config_set_command_custom_file(self):
|
||||
"""Test config-set with custom config file."""
|
||||
custom_file = 'custom.yml'
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-set', 'repo_name', 'test',
|
||||
'--config-file', custom_file
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert Path(custom_file).exists()
|
||||
|
||||
def test_config_set_command_no_validate(self):
|
||||
"""Test config-set with validation disabled."""
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-set', 'repo_name', 'test',
|
||||
'--no-validate'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_config_set_command_validation_error(self):
|
||||
"""Test config-set with validation error."""
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-set', 'gitea_url', 'invalid-url'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert 'Configuration error' in result.output
|
||||
|
||||
def test_config_init_command_non_interactive(self):
|
||||
"""Test config-init CLI command in non-interactive mode."""
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-init', '--no-interactive'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'initialized successfully' in result.output
|
||||
|
||||
# Verify config file created
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
assert config_file.exists()
|
||||
|
||||
# Verify directories created
|
||||
assert (self.test_dir / '.markitect_workspace').exists()
|
||||
assert (self.test_dir / '.ast_cache').exists()
|
||||
assert (self.test_dir / 'tests').exists()
|
||||
|
||||
def test_config_init_command_custom_directory(self):
|
||||
"""Test config-init with custom project directory."""
|
||||
project_dir = self.test_dir / 'my-project'
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-init',
|
||||
'--project-dir', str(project_dir),
|
||||
'--no-interactive'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify in correct location
|
||||
config_file = project_dir / '.markitect.yml'
|
||||
assert config_file.exists()
|
||||
|
||||
def test_config_init_command_existing_file_no_force(self):
|
||||
"""Test config-init with existing file without force."""
|
||||
# Create existing config file
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
config_file.write_text('existing: config')
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-init', '--no-interactive'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert 'already exists' in result.output
|
||||
|
||||
def test_config_init_command_existing_file_with_force(self):
|
||||
"""Test config-init with existing file with force."""
|
||||
# Create existing config file
|
||||
config_file = self.test_dir / '.markitect.yml'
|
||||
config_file.write_text('existing: config')
|
||||
|
||||
result = self.runner.invoke(cli, [
|
||||
'config-init', '--no-interactive', '--force'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'initialized successfully' in result.output
|
||||
|
||||
def test_config_validate_command(self):
|
||||
"""Test config-validate CLI command."""
|
||||
result = self.runner.invoke(cli, ['config-validate'])
|
||||
|
||||
assert result.exit_code in [0, 1] # May have warnings/errors
|
||||
assert 'Configuration Validation Summary' in result.output
|
||||
|
||||
def test_config_validate_command_verbose(self):
|
||||
"""Test config-validate with verbose output."""
|
||||
result = self.runner.invoke(cli, ['config-validate', '--verbose'])
|
||||
|
||||
assert result.exit_code in [0, 1]
|
||||
assert 'Configuration Validation Summary' in result.output
|
||||
|
||||
def test_config_help_command_general(self):
|
||||
"""Test config-help CLI command."""
|
||||
result = self.runner.invoke(cli, ['config-help'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'available' in result.output.lower() or 'configuration' in result.output.lower()
|
||||
assert 'gitea_url' in result.output
|
||||
|
||||
def test_config_help_command_specific_key(self):
|
||||
"""Test config-help for specific key."""
|
||||
result = self.runner.invoke(cli, ['config-help', 'gitea_url'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'gitea_url' in result.output
|
||||
|
||||
def test_config_help_command_unknown_key(self):
|
||||
"""Test config-help for unknown key."""
|
||||
result = self.runner.invoke(cli, ['config-help', 'unknown_key'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'unknown' in result.output.lower()
|
||||
|
||||
|
||||
class TestEnvironmentVariables:
|
||||
"""Test environment variable handling."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_dir = Path(self.temp_dir)
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
# Store original environment
|
||||
self.original_env = dict(os.environ)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
# Restore original environment
|
||||
os.environ.clear()
|
||||
os.environ.update(self.original_env)
|
||||
|
||||
def test_get_relevant_env_vars(self):
|
||||
"""Test getting MARKITECT-related environment variables."""
|
||||
# Set some test environment variables
|
||||
os.environ['MARKITECT_GITEA_URL'] = 'http://test.com'
|
||||
os.environ['MARKITECT_REPO_NAME'] = 'test-repo'
|
||||
os.environ['OTHER_VAR'] = 'should-be-ignored'
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
env_vars = config_manager._get_relevant_env_vars()
|
||||
|
||||
assert 'MARKITECT_GITEA_URL' in env_vars
|
||||
assert 'MARKITECT_REPO_NAME' in env_vars
|
||||
assert 'OTHER_VAR' not in env_vars
|
||||
assert env_vars['MARKITECT_GITEA_URL'] == 'http://test.com'
|
||||
|
||||
def test_config_with_env_vars(self):
|
||||
"""Test configuration loading with environment variables."""
|
||||
# Set environment variables
|
||||
os.environ['MARKITECT_GITEA_URL'] = 'http://env-test.com'
|
||||
os.environ['MARKITECT_REPO_NAME'] = 'env-repo'
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
config = config_manager.get_current_config()
|
||||
|
||||
# Environment variables should be reflected in config
|
||||
assert config['gitea_url'] == 'http://env-test.com'
|
||||
assert config['repo_name'] == 'env-repo'
|
||||
|
||||
# Should have env vars in metadata
|
||||
assert 'MARKITECT_GITEA_URL' in config['_meta']['env_variables']
|
||||
assert 'MARKITECT_REPO_NAME' in config['_meta']['env_variables']
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error conditions."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_dir = Path(self.temp_dir)
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_config_manager_with_permission_error(self):
|
||||
"""Test handling permission errors."""
|
||||
# Create a directory we can't write to
|
||||
restricted_dir = self.test_dir / 'restricted'
|
||||
restricted_dir.mkdir(mode=0o444) # Read-only
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# This should not crash, but may warn about permission issues
|
||||
try:
|
||||
config = config_manager.get_current_config()
|
||||
assert isinstance(config, dict)
|
||||
except PermissionError:
|
||||
# Acceptable to fail with permission error
|
||||
pass
|
||||
|
||||
def test_set_config_value_invalid_file_path(self):
|
||||
"""Test setting config value with invalid file path."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Try to write to a path that doesn't exist and can't be created
|
||||
# Use a more reliable invalid path that doesn't depend on system permissions
|
||||
invalid_path = '/nonexistent_directory_12345/config.yml'
|
||||
with pytest.raises(ValueError):
|
||||
config_manager.set_config_value('test', 'value', invalid_path)
|
||||
|
||||
def test_validate_configuration_with_none(self):
|
||||
"""Test configuration validation with None input."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
# Should not crash with None input
|
||||
results = config_manager.validate_configuration(None)
|
||||
assert isinstance(results, list)
|
||||
|
||||
def test_mask_sensitive_data_with_complex_structure(self):
|
||||
"""Test masking sensitive data in complex nested structure."""
|
||||
config_manager = ConfigurationManager()
|
||||
|
||||
complex_config = {
|
||||
'database': {
|
||||
'password': 'secret123',
|
||||
'host': 'localhost'
|
||||
},
|
||||
'apis': [
|
||||
{'name': 'github', 'token': 'ghp_secret'},
|
||||
{'name': 'gitea', 'url': 'http://localhost:3000'}
|
||||
],
|
||||
'secrets': {
|
||||
'nested': {
|
||||
'api_key': 'very_secret'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
masked = config_manager._mask_sensitive_data(complex_config)
|
||||
|
||||
# Sensitive fields should be masked at any level
|
||||
assert masked['database']['password'] == '***MASKED***'
|
||||
assert masked['apis'][0]['token'] == '***MASKED***'
|
||||
|
||||
# The 'secrets' key itself gets masked because it's a sensitive keyword
|
||||
# This is actually correct behavior
|
||||
assert masked['secrets'] == '***MASKED***'
|
||||
|
||||
# Non-sensitive fields should remain
|
||||
assert masked['database']['host'] == 'localhost'
|
||||
assert masked['apis'][1]['url'] == 'http://localhost:3000'
|
||||
|
||||
def test_get_config_sources_empty_directory(self):
|
||||
"""Test getting config sources in empty directory."""
|
||||
config_manager = ConfigurationManager()
|
||||
sources = config_manager._get_config_sources()
|
||||
|
||||
# Should always include defaults
|
||||
assert len(sources) > 0
|
||||
assert any('defaults' in source.lower() for source in sources)
|
||||
|
||||
def test_initialize_project_config_existing_directories(self):
|
||||
"""Test project initialization with existing directories."""
|
||||
# Pre-create some directories
|
||||
(self.test_dir / '.markitect_workspace').mkdir()
|
||||
(self.test_dir / 'tests').mkdir()
|
||||
|
||||
config_manager = ConfigurationManager()
|
||||
result = config_manager.initialize_project_config(self.test_dir, interactive=False)
|
||||
|
||||
# Should succeed even with existing directories
|
||||
assert 'config_file' in result
|
||||
assert Path(result['config_file']).exists()
|
||||
Reference in New Issue
Block a user