diff --git a/markitect/associated_files.py b/markitect/associated_files.py new file mode 100644 index 00000000..3cd4f2c8 --- /dev/null +++ b/markitect/associated_files.py @@ -0,0 +1,355 @@ +""" +Associated Files Manager for Issue #40: Associated Files Management. + +This module provides functionality to manage associated markdown and schema files +with convention-based naming and automatic file placement. +""" + +import os +from pathlib import Path +from typing import Dict, List, Optional, Any + + +class AssociatedFilesError(Exception): + """Base exception for associated files operations.""" + pass + + +class InvalidFileTypeError(AssociatedFilesError): + """Raised when file has unexpected extension.""" + pass + + +class DirectoryAccessError(AssociatedFilesError): + """Raised when directory cannot be accessed.""" + pass + + +class AssociatedFilesManager: + """ + Manages associated markdown and schema files with convention-based naming. + + Provides functionality to find, create, and manage pairs of markdown and JSON schema + files that follow the convention of having identical basenames with different extensions. + """ + + def __init__(self, markdown_extension: str = '.md', schema_extension: str = '.json'): + """ + Initialize the associated files manager. + + Args: + markdown_extension: File extension for markdown files (default: '.md') + schema_extension: File extension for schema files (default: '.json') + + Raises: + ValueError: If extensions are invalid or identical + """ + # Validate extensions + if not markdown_extension.startswith('.'): + raise ValueError("Markdown extension must start with '.'") + if not schema_extension.startswith('.'): + raise ValueError("Schema extension must start with '.'") + if markdown_extension.lower() == schema_extension.lower(): + raise ValueError("Markdown and schema extensions must be different") + + self.markdown_extension = markdown_extension.lower() + self.schema_extension = schema_extension.lower() + + def _validate_file_extension(self, file_path: Path, expected_extension: str, file_type: str) -> None: + """ + Validate file has expected extension. + + Args: + file_path: Path to validate + expected_extension: Expected extension (e.g., '.md') + file_type: Description of file type for error messages + + Raises: + InvalidFileTypeError: If file doesn't have expected extension + """ + if file_path.suffix.lower() != expected_extension: + raise InvalidFileTypeError( + f"Expected {file_type} file with {expected_extension} extension, got {file_path.suffix}" + ) + + def _validate_markdown_file(self, file_path: Path) -> None: + """Validate file is a markdown file.""" + self._validate_file_extension(file_path, self.markdown_extension, "markdown") + + def _validate_schema_file(self, file_path: Path) -> None: + """Validate file is a schema file.""" + self._validate_file_extension(file_path, self.schema_extension, "schema") + + def _validate_directory(self, directory: Path) -> None: + """ + Validate directory access and permissions. + + Args: + directory: Directory path to validate + + Raises: + DirectoryAccessError: If directory cannot be accessed + """ + if not directory.exists(): + raise DirectoryAccessError(f"Directory does not exist: {directory}") + + if not directory.is_dir(): + raise DirectoryAccessError(f"Path is not a directory: {directory}") + + if not os.access(directory, os.R_OK): + raise DirectoryAccessError(f"No read permission for directory: {directory}") + + def get_associated_schema_path(self, markdown_file: Path) -> Path: + """ + Get the path for the associated schema file of a markdown file. + + Args: + markdown_file: Path to the markdown file + + Returns: + Path where the associated schema should be located + + Raises: + InvalidFileTypeError: If the file doesn't have .md extension + """ + self._validate_markdown_file(markdown_file) + + return markdown_file.with_suffix(self.schema_extension) + + def get_associated_markdown_path(self, schema_file: Path) -> Path: + """ + Get the path for the associated markdown file of a schema file. + + Args: + schema_file: Path to the schema file + + Returns: + Path where the associated markdown should be located + + Raises: + InvalidFileTypeError: If the file doesn't have .json extension + """ + self._validate_schema_file(schema_file) + + return schema_file.with_suffix(self.markdown_extension) + + def find_associated_schema(self, markdown_file: Path) -> Optional[Path]: + """ + Find the associated schema file for a markdown file. + + Args: + markdown_file: Path to the markdown file + + Returns: + Path to associated schema file if it exists, None otherwise + """ + schema_path = self.get_associated_schema_path(markdown_file) + return schema_path if schema_path.exists() else None + + def find_associated_markdown(self, schema_file: Path) -> Optional[Path]: + """ + Find the associated markdown file for a schema file. + + Args: + schema_file: Path to the schema file + + Returns: + Path to associated markdown file if it exists, None otherwise + """ + markdown_path = self.get_associated_markdown_path(schema_file) + return markdown_path if markdown_path.exists() else None + + def has_associated_schema(self, markdown_file: Path) -> bool: + """ + Check if a markdown file has an associated schema file. + + Args: + markdown_file: Path to the markdown file + + Returns: + True if associated schema exists, False otherwise + """ + return self.find_associated_schema(markdown_file) is not None + + def has_associated_markdown(self, schema_file: Path) -> bool: + """ + Check if a schema file has an associated markdown file. + + Args: + schema_file: Path to the schema file + + Returns: + True if associated markdown exists, False otherwise + """ + return self.find_associated_markdown(schema_file) is not None + + def list_file_pairs(self, directory: Path) -> List[Dict[str, Any]]: + """ + List all associated file pairs in a directory. + + Optimized version that reduces filesystem calls by collecting all files + at once and finding pairs through basename intersection. + + Args: + directory: Directory to search for file pairs + + Returns: + List of dictionaries containing information about each file pair + """ + pairs = [] + + # Get all files at once and group by extension (more efficient) + try: + all_files = [f for f in directory.iterdir() if f.is_file()] + except (OSError, PermissionError): + # Return empty list if directory cannot be read + return pairs + + md_files = {f.stem: f for f in all_files if f.suffix.lower() == self.markdown_extension} + json_files = {f.stem: f for f in all_files if f.suffix.lower() == self.schema_extension} + + # Find pairs by checking intersection of basenames (no additional filesystem calls) + paired_basenames = set(md_files.keys()) & set(json_files.keys()) + + for basename in sorted(paired_basenames): # Sort for consistent output + pairs.append({ + 'basename': basename, + 'markdown_file': md_files[basename], + 'schema_file': json_files[basename], + 'both_exist': True + }) + + return pairs + + def get_file_pair_info(self, file_path: Path) -> Dict[str, Any]: + """ + Get detailed information about a file pair. + + Args: + file_path: Path to either markdown or schema file + + Returns: + Dictionary with detailed information about the file pair + """ + if file_path.suffix.lower() == self.markdown_extension: + md_file = file_path + schema_file = self.get_associated_schema_path(md_file) + elif file_path.suffix.lower() == self.schema_extension: + schema_file = file_path + md_file = self.get_associated_markdown_path(schema_file) + else: + raise ValueError(f"Unsupported file type: {file_path.suffix}") + + info = { + 'basename': file_path.stem, + 'markdown_file': md_file, + 'schema_file': schema_file, + 'both_exist': md_file.exists() and schema_file.exists() + } + + # Add file sizes if files exist + if md_file.exists(): + info['markdown_size'] = md_file.stat().st_size + info['markdown_modified'] = md_file.stat().st_mtime + + if schema_file.exists(): + info['schema_size'] = schema_file.stat().st_size + info['schema_modified'] = schema_file.stat().st_mtime + + return info + + def list_orphaned_files(self, directory: Path) -> Dict[str, List[Path]]: + """ + List orphaned files (files without their associated counterpart). + + Optimized version that reuses file discovery from list_file_pairs logic. + + Args: + directory: Directory to search + + Returns: + Dictionary with 'orphaned_markdown' and 'orphaned_schemas' lists + """ + orphaned_markdown = [] + orphaned_schemas = [] + + # Get all files at once (reusing optimization pattern) + try: + all_files = [f for f in directory.iterdir() if f.is_file()] + except (OSError, PermissionError): + return { + 'orphaned_markdown': orphaned_markdown, + 'orphaned_schemas': orphaned_schemas + } + + md_files = {f.stem: f for f in all_files if f.suffix.lower() == self.markdown_extension} + json_files = {f.stem: f for f in all_files if f.suffix.lower() == self.schema_extension} + + # Find orphaned files by checking set differences + orphaned_md_basenames = set(md_files.keys()) - set(json_files.keys()) + orphaned_json_basenames = set(json_files.keys()) - set(md_files.keys()) + + # Collect orphaned files + for basename in sorted(orphaned_md_basenames): + orphaned_markdown.append(md_files[basename]) + + for basename in sorted(orphaned_json_basenames): + orphaned_schemas.append(json_files[basename]) + + return { + 'orphaned_markdown': orphaned_markdown, + 'orphaned_schemas': orphaned_schemas + } + + def get_directory_status(self, directory: Path) -> Dict[str, Any]: + """ + Get comprehensive status of associated files in a directory. + + Args: + directory: Directory to analyze + + Returns: + Dictionary with status information + """ + pairs = self.list_file_pairs(directory) + orphaned = self.list_orphaned_files(directory) + + return { + 'directory': directory, + 'paired_files': len(pairs), + 'orphaned_markdown': len(orphaned['orphaned_markdown']), + 'orphaned_schemas': len(orphaned['orphaned_schemas']), + 'pairs': pairs, + 'orphaned': orphaned + } + + def suggest_output_path(self, input_file: Path, target_extension: str) -> Path: + """ + Suggest an output path for generating an associated file. + + Args: + input_file: Source file path + target_extension: Desired extension for output file (e.g., '.json', '.md') + + Returns: + Suggested path for the output file + """ + return input_file.with_suffix(target_extension) + + def validate_file_pair_naming(self, markdown_file: Path, schema_file: Path) -> bool: + """ + Validate that two files follow the associated files naming convention. + + Args: + markdown_file: Path to markdown file + schema_file: Path to schema file + + Returns: + True if files follow naming convention, False otherwise + """ + return ( + markdown_file.stem == schema_file.stem and + markdown_file.suffix.lower() == self.markdown_extension and + schema_file.suffix.lower() == self.schema_extension and + markdown_file.parent == schema_file.parent + ) \ No newline at end of file diff --git a/markitect/cli.py b/markitect/cli.py index f35b735b..004a853c 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -23,8 +23,51 @@ import yaml from pathlib import Path from typing import Optional from tabulate import tabulate +import builtins from .database import DatabaseManager + + +def detect_execution_mode(): + """ + Detect whether we're running in interactive or automation mode. + + Returns: + str: 'interactive' or 'automation' + + Detection logic: + - Environment variable MARKITECT_MODE overrides detection + - Interactive: TTY present, not in CI, not in pipe + - Automation: CI environment, pipe/redirect, or explicit setting + """ + # Explicit mode override + mode = os.environ.get('MARKITECT_MODE', '').lower() + if mode in ['interactive', 'automation']: + return mode + + # Detect CI environments + ci_indicators = [ + 'CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS', + 'GITLAB_CI', 'JENKINS_URL', 'BUILD_NUMBER' + ] + if any(os.environ.get(var) for var in ci_indicators): + return 'automation' + + # Check if output is being piped or redirected + if not sys.stdout.isatty(): + return 'automation' + + # Check if input is being piped + if not sys.stdin.isatty(): + return 'automation' + + # Default to interactive for terminal usage + return 'interactive' + + +def should_use_associated_files(): + """Determine if commands should use associated files behavior.""" + return detect_execution_mode() == 'interactive' from .document_manager import DocumentManager from .serializer import ASTSerializer from .cache_service import CacheDirectoryService @@ -80,11 +123,19 @@ def format_output(data, output_format): return json.dumps(data, indent=2, default=str) elif output_format == 'yaml': return yaml.dump(data, default_flow_style=False, allow_unicode=True) + elif output_format == 'simple': + # Simple format - just basic text output + if isinstance(data, builtins.list): + return '\n'.join(str(item) for item in data) + elif isinstance(data, builtins.dict): + return '\n'.join(f"{key}: {value}" for key, value in data.items()) + else: + return str(data) elif output_format == 'table': try: # Check if it's a list type - if isinstance(data, (type([]), type(()))): - if data and isinstance(data[0], dict): + if isinstance(data, (builtins.list, builtins.tuple)): + if data and isinstance(data[0], builtins.dict): # List of dictionaries - format as table headers = sorted(data[0].keys()) rows = [] @@ -97,7 +148,7 @@ def format_output(data, output_format): else: # List of simple values return tabulate([[item] for item in data], headers=['Value'], tablefmt='grid') - elif isinstance(data, dict): + elif isinstance(data, builtins.dict): # Single dictionary - format as key-value table rows = [[key, value] for key, value in data.items()] return tabulate(rows, headers=['Key', 'Value'], tablefmt='grid') @@ -1022,8 +1073,10 @@ def generate_schema(config, file_path, max_depth, output, output_format): markitect schema-generate document.md --output schema.json """ try: - # Initialize schema generator + # Initialize schema generator and associated files manager generator = SchemaGenerator() + from .associated_files import AssociatedFilesManager + associated_files = AssociatedFilesManager() # Generate schema schema = generator.generate_schema_from_file(file_path, max_depth=max_depth) @@ -1036,6 +1089,15 @@ def generate_schema(config, file_path, max_depth, output, output_format): else: formatted_output = json.dumps(schema, indent=2, ensure_ascii=False) + # Mode-based output logic + if not output and should_use_associated_files(): + # Interactive mode: use associated file path + from .associated_files import AssociatedFilesManager + associated_files = AssociatedFilesManager() + output = associated_files.get_associated_schema_path(file_path) + if config.get('verbose'): + click.echo(f"Interactive mode: using associated file path: {output}", err=True) + # Write to output if output: output.write_text(formatted_output, encoding='utf-8') @@ -1100,12 +1162,25 @@ def validate(config, file_path, schema, schema_json, quiet, detailed_errors, err """ try: validator = SchemaValidator() + from .associated_files import AssociatedFilesManager + associated_files = AssociatedFilesManager() - # Validate exactly one schema source is provided + # Validate schema source or auto-discover schema_sources = [schema, schema_json] provided_sources = [s for s in schema_sources if s is not None] - if len(provided_sources) != 1: + if len(provided_sources) == 0: + # Auto-discover associated schema file + auto_schema = associated_files.find_associated_schema(file_path) + if auto_schema: + schema = auto_schema + if config.get('verbose'): + click.echo(f"Auto-discovered associated schema: {schema}", err=True) + else: + click.echo("Error: No schema specified and no associated schema file found", err=True) + click.echo("Provide --schema FILE or --schema-json JSON, or ensure an associated .json file exists", err=True) + sys.exit(1) + elif len(provided_sources) > 1: click.echo("Error: Specify exactly one schema source (--schema or --schema-json)", err=True) sys.exit(1) @@ -1446,8 +1521,10 @@ def generate_stub(config, schema_file, output, style, title): click.echo(f"Generating stub from schema: {schema_file}", err=True) from .stub_generator import StubGenerator + from .associated_files import AssociatedFilesManager generator = StubGenerator() + associated_files = AssociatedFilesManager() # Load schema and generate stub content import json @@ -1458,6 +1535,13 @@ def generate_stub(config, schema_file, output, style, title): schema, placeholder_style=style, title=title ) + # Mode-based output logic + if not output and should_use_associated_files(): + # Interactive mode: use associated file path + output = associated_files.get_associated_markdown_path(schema_file) + if config.get('verbose'): + click.echo(f"Interactive mode: using associated file path: {output}", err=True) + # Output to file or stdout if output: generator.generate_stub_to_file(schema, output, style, title) @@ -1485,6 +1569,344 @@ def generate_stub(config, schema_file, output, style, title): sys.exit(1) +@cli.group('associated-files') +@pass_config +def associated_files_group(config): + """ + Manage associated markdown and schema file pairs. + + Commands for working with files that follow the convention of having + identical basenames with different extensions (e.g., document.md ↔ document.json). + """ + pass + + +@associated_files_group.command('list') +@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']), + default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']), + help='Output format') +@click.argument('directory', type=click.Path(exists=True, file_okay=False, path_type=Path), default='.') +@pass_config +def list_associated_files(config, format, directory): + """ + List all associated file pairs in a directory. + + Shows markdown/schema file pairs that follow the naming convention. + + Examples: + markitect associated-files list + markitect associated-files list docs/ + markitect associated-files list --format json + """ + try: + from .associated_files import AssociatedFilesManager + manager = AssociatedFilesManager() + + if config.get('verbose'): + click.echo(f"Scanning directory: {directory}", err=True) + + pairs = manager.list_file_pairs(directory) + + if not pairs: + click.echo("No associated file pairs found.") + return + + # Format output + if format == 'table': + click.echo(f"Associated File Pairs in {directory}:") + click.echo("=" * 60) + for pair in pairs: + click.echo(f"šŸ“„ {pair['basename']}") + click.echo(f" Markdown: {pair['markdown_file'].name}") + click.echo(f" Schema: {pair['schema_file'].name}") + click.echo() + elif format == 'json': + import json + output_data = [] + for pair in pairs: + output_data.append({ + 'basename': pair['basename'], + 'markdown_file': str(pair['markdown_file']), + 'schema_file': str(pair['schema_file']), + 'both_exist': pair['both_exist'] + }) + click.echo(json.dumps(output_data, indent=2)) + elif format == 'yaml': + import yaml + output_data = [] + for pair in pairs: + output_data.append({ + 'basename': pair['basename'], + 'markdown_file': str(pair['markdown_file']), + 'schema_file': str(pair['schema_file']), + 'both_exist': pair['both_exist'] + }) + click.echo(yaml.dump(output_data, default_flow_style=False)) + else: + # Simple format + for pair in pairs: + click.echo(f"{pair['basename']} ({pair['markdown_file'].name} ↔ {pair['schema_file'].name})") + + if config.get('verbose'): + click.echo(f"Found {len(pairs)} associated file pairs", err=True) + + except Exception as e: + click.echo(f"Error listing associated files: {e}", err=True) + if config.get('verbose'): + import traceback + click.echo(traceback.format_exc(), err=True) + sys.exit(1) + + +@associated_files_group.command('info') +@click.argument('file_path', type=click.Path(exists=True, path_type=Path)) +@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']), + default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']), + help='Output format') +@pass_config +def associated_files_info(config, file_path, format): + """ + Show detailed information about associated files. + + Displays information about a file and its associated counterpart. + + Examples: + markitect associated-files info document.md + markitect associated-files info schema.json --format json + """ + try: + from .associated_files import AssociatedFilesManager + manager = AssociatedFilesManager() + + info = manager.get_file_pair_info(file_path) + + if format == 'table': + click.echo(f"Associated Files Information:") + click.echo("=" * 40) + click.echo(f"Basename: {info['basename']}") + click.echo(f"Markdown: {info['markdown_file']}") + click.echo(f" Exists: {'āœ…' if info['markdown_file'].exists() else 'āŒ'}") + if 'markdown_size' in info: + click.echo(f" Size: {info['markdown_size']} bytes") + + click.echo(f"Schema: {info['schema_file']}") + click.echo(f" Exists: {'āœ…' if info['schema_file'].exists() else 'āŒ'}") + if 'schema_size' in info: + click.echo(f" Size: {info['schema_size']} bytes") + + click.echo(f"Both exist: {'āœ…' if info['both_exist'] else 'āŒ'}") + + elif format == 'json': + import json + # Convert Path objects to strings for JSON serialization + json_info = {k: str(v) if isinstance(v, Path) else v for k, v in info.items()} + click.echo(json.dumps(json_info, indent=2)) + + elif format == 'yaml': + import yaml + # Convert Path objects to strings for YAML serialization + yaml_info = {k: str(v) if isinstance(v, Path) else v for k, v in info.items()} + click.echo(yaml.dump(yaml_info, default_flow_style=False)) + else: + # Simple format + status = "paired" if info['both_exist'] else "orphaned" + click.echo(f"{info['basename']}: {status}") + + except Exception as e: + click.echo(f"Error getting file info: {e}", err=True) + sys.exit(1) + + +@associated_files_group.command('status') +@click.argument('directory', type=click.Path(exists=True, file_okay=False, path_type=Path), default='.') +@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']), + default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']), + help='Output format') +@pass_config +def associated_files_status(config, directory, format): + """ + Show status of associated files in a directory. + + Displays paired files and orphaned files (files without their counterpart). + + Examples: + markitect associated-files status + markitect associated-files status docs/ + """ + try: + from .associated_files import AssociatedFilesManager + manager = AssociatedFilesManager() + + status = manager.get_directory_status(directory) + + if format == 'table': + click.echo(f"Associated Files Status for {directory}:") + click.echo("=" * 50) + click.echo(f"šŸ“Œ Paired files: {status['paired_files']}") + click.echo(f"šŸ“„ Orphaned markdown: {status['orphaned_markdown']}") + click.echo(f"šŸ”§ Orphaned schemas: {status['orphaned_schemas']}") + + if status['pairs']: + click.echo("\nšŸ“Œ Paired Files:") + for pair in status['pairs']: + click.echo(f" • {pair['basename']}") + + if status['orphaned']['orphaned_markdown']: + click.echo("\nšŸ“„ Orphaned Markdown Files:") + for md in status['orphaned']['orphaned_markdown']: + click.echo(f" • {md.name}") + + if status['orphaned']['orphaned_schemas']: + click.echo("\nšŸ”§ Orphaned Schema Files:") + for schema in status['orphaned']['orphaned_schemas']: + click.echo(f" • {schema.name}") + + elif format == 'json': + import json + # Convert Path objects to strings for JSON serialization + json_status = { + 'directory': str(status['directory']), + 'paired_files': status['paired_files'], + 'orphaned_markdown': status['orphaned_markdown'], + 'orphaned_schemas': status['orphaned_schemas'], + 'pairs': [{'basename': p['basename'], + 'markdown_file': str(p['markdown_file']), + 'schema_file': str(p['schema_file'])} + for p in status['pairs']], + 'orphaned': { + 'orphaned_markdown': [str(f) for f in status['orphaned']['orphaned_markdown']], + 'orphaned_schemas': [str(f) for f in status['orphaned']['orphaned_schemas']] + } + } + click.echo(json.dumps(json_status, indent=2)) + + elif format == 'yaml': + import yaml + yaml_status = { + 'directory': str(status['directory']), + 'paired_files': status['paired_files'], + 'orphaned_markdown': status['orphaned_markdown'], + 'orphaned_schemas': status['orphaned_schemas'], + 'pairs': [{'basename': p['basename'], + 'markdown_file': str(p['markdown_file']), + 'schema_file': str(p['schema_file'])} + for p in status['pairs']], + 'orphaned': { + 'orphaned_markdown': [str(f) for f in status['orphaned']['orphaned_markdown']], + 'orphaned_schemas': [str(f) for f in status['orphaned']['orphaned_schemas']] + } + } + click.echo(yaml.dump(yaml_status, default_flow_style=False)) + else: + # Simple format + click.echo(f"Paired: {status['paired_files']}, Orphaned: {status['orphaned_markdown'] + status['orphaned_schemas']}") + + except Exception as e: + click.echo(f"Error getting status: {e}", err=True) + sys.exit(1) + + +@associated_files_group.command('create-schema') +@click.argument('markdown_file', type=click.Path(exists=True, path_type=Path)) +@click.option('--max-depth', '-d', type=int, help='Maximum heading depth to include in schema') +@pass_config +def create_associated_schema(config, markdown_file, max_depth): + """ + Create an associated schema file for a markdown file. + + Generates a JSON schema and places it next to the source markdown file + with the same basename but .json extension. + + Examples: + markitect associated-files create-schema document.md + markitect associated-files create-schema doc.md --max-depth 3 + """ + try: + from .associated_files import AssociatedFilesManager + from .schema_generator import SchemaGenerator + + manager = AssociatedFilesManager() + generator = SchemaGenerator() + + # Check if associated schema already exists + existing_schema = manager.find_associated_schema(markdown_file) + if existing_schema: + if not click.confirm(f"Associated schema {existing_schema} already exists. Overwrite?"): + click.echo("Operation cancelled.") + return + + # Generate schema + schema = generator.generate_schema_from_file(markdown_file, max_depth=max_depth) + + # Save to associated path + schema_path = manager.get_associated_schema_path(markdown_file) + + import json + with open(schema_path, 'w', encoding='utf-8') as f: + json.dump(schema, f, indent=2, ensure_ascii=False) + + click.echo(f"āœ… Created associated schema: {schema_path}") + + if config.get('verbose'): + properties = schema.get('properties', {}) + click.echo(f"Generated schema with {len(properties)} property types", err=True) + + except Exception as e: + click.echo(f"Error creating schema: {e}", err=True) + sys.exit(1) + + +@associated_files_group.command('create-stub') +@click.argument('schema_file', type=click.Path(exists=True, path_type=Path)) +@click.option('--style', type=click.Choice(['default', 'custom', 'detailed']), + default='default', help='Placeholder content style') +@click.option('--title', type=str, help='Custom document title') +@pass_config +def create_associated_stub(config, schema_file, style, title): + """ + Create an associated markdown stub for a schema file. + + Generates a markdown template and places it next to the source schema file + with the same basename but .md extension. + + Examples: + markitect associated-files create-stub schema.json + markitect associated-files create-stub schema.json --style detailed + """ + try: + from .associated_files import AssociatedFilesManager + from .stub_generator import StubGenerator + + manager = AssociatedFilesManager() + generator = StubGenerator() + + # Check if associated markdown already exists + existing_md = manager.find_associated_markdown(schema_file) + if existing_md: + if not click.confirm(f"Associated markdown {existing_md} already exists. Overwrite?"): + click.echo("Operation cancelled.") + return + + # Load schema and generate stub + import json + with open(schema_file, 'r') as f: + schema = json.load(f) + + # Save to associated path + md_path = manager.get_associated_markdown_path(schema_file) + generator.generate_stub_to_file(schema, md_path, style, title) + + click.echo(f"āœ… Created associated stub: {md_path}") + + if config.get('verbose'): + content = md_path.read_text() + click.echo(f"Generated {len(content)} characters of content", err=True) + + except Exception as e: + click.echo(f"Error creating stub: {e}", err=True) + sys.exit(1) + + def main(): """ Main entry point for the CLI. diff --git a/tests/test_issue_40_associated_files.py b/tests/test_issue_40_associated_files.py new file mode 100644 index 00000000..d1733393 --- /dev/null +++ b/tests/test_issue_40_associated_files.py @@ -0,0 +1,271 @@ +""" +Tests for Issue #40: Associated Files Management. + +This module tests the functionality for managing associated markdown and schema files +with convention-based naming and automatic file placement. +""" + +import pytest +from pathlib import Path +from tempfile import TemporaryDirectory + +from markitect.associated_files import AssociatedFilesManager + + +class TestIssue40AssociatedFiles: + """Test suite for associated files management.""" + + @pytest.fixture + def manager(self): + """Create an AssociatedFilesManager instance.""" + return AssociatedFilesManager() + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory for testing.""" + with TemporaryDirectory() as temp_dir: + yield Path(temp_dir) + + def test_associated_files_manager_can_be_created(self, manager): + """AssociatedFilesManager class should be importable and instantiable.""" + assert manager is not None + assert isinstance(manager, AssociatedFilesManager) + + def test_get_associated_schema_path(self, manager, temp_dir): + """Should generate correct associated schema path for markdown file.""" + md_file = temp_dir / "document.md" + md_file.write_text("# Test Document") + + schema_path = manager.get_associated_schema_path(md_file) + + assert schema_path == temp_dir / "document.json" + assert schema_path.parent == md_file.parent + assert schema_path.stem == md_file.stem + + def test_get_associated_markdown_path(self, manager, temp_dir): + """Should generate correct associated markdown path for schema file.""" + schema_file = temp_dir / "document.json" + schema_file.write_text('{"type": "object"}') + + md_path = manager.get_associated_markdown_path(schema_file) + + assert md_path == temp_dir / "document.md" + assert md_path.parent == schema_file.parent + assert md_path.stem == schema_file.stem + + def test_find_associated_schema(self, manager, temp_dir): + """Should find existing associated schema file.""" + md_file = temp_dir / "blog_post.md" + schema_file = temp_dir / "blog_post.json" + + md_file.write_text("# Blog Post") + schema_file.write_text('{"type": "object"}') + + found_schema = manager.find_associated_schema(md_file) + + assert found_schema == schema_file + assert found_schema.exists() + + def test_find_associated_markdown(self, manager, temp_dir): + """Should find existing associated markdown file.""" + md_file = temp_dir / "article.md" + schema_file = temp_dir / "article.json" + + md_file.write_text("# Article") + schema_file.write_text('{"type": "object"}') + + found_md = manager.find_associated_markdown(schema_file) + + assert found_md == md_file + assert found_md.exists() + + def test_find_associated_files_returns_none_when_not_found(self, manager, temp_dir): + """Should return None when associated files don't exist.""" + md_file = temp_dir / "lonely.md" + md_file.write_text("# Lonely Document") + + schema_file = temp_dir / "orphan.json" + schema_file.write_text('{"type": "object"}') + + assert manager.find_associated_schema(md_file) is None + assert manager.find_associated_markdown(schema_file) is None + + def test_has_associated_schema(self, manager, temp_dir): + """Should correctly detect if markdown file has associated schema.""" + md_file = temp_dir / "test.md" + schema_file = temp_dir / "test.json" + + md_file.write_text("# Test") + + # No schema initially + assert not manager.has_associated_schema(md_file) + + # Create schema + schema_file.write_text('{"type": "object"}') + assert manager.has_associated_schema(md_file) + + def test_has_associated_markdown(self, manager, temp_dir): + """Should correctly detect if schema file has associated markdown.""" + md_file = temp_dir / "guide.md" + schema_file = temp_dir / "guide.json" + + schema_file.write_text('{"type": "object"}') + + # No markdown initially + assert not manager.has_associated_markdown(schema_file) + + # Create markdown + md_file.write_text("# Guide") + assert manager.has_associated_markdown(schema_file) + + def test_list_file_pairs(self, manager, temp_dir): + """Should list all associated file pairs in directory.""" + # Create some paired files + (temp_dir / "doc1.md").write_text("# Doc 1") + (temp_dir / "doc1.json").write_text('{"type": "object"}') + + (temp_dir / "doc2.md").write_text("# Doc 2") + (temp_dir / "doc2.json").write_text('{"type": "object"}') + + # Create orphaned files + (temp_dir / "orphan.md").write_text("# Orphan") + (temp_dir / "lonely.json").write_text('{"type": "object"}') + + pairs = manager.list_file_pairs(temp_dir) + + assert len(pairs) == 2 + pair_names = {pair['basename'] for pair in pairs} + assert 'doc1' in pair_names + assert 'doc2' in pair_names + + def test_get_file_pair_info(self, manager, temp_dir): + """Should provide detailed information about file pairs.""" + md_file = temp_dir / "example.md" + schema_file = temp_dir / "example.json" + + md_file.write_text("# Example Document\n\nContent here.") + schema_file.write_text('{"type": "object", "title": "Example Schema"}') + + pair_info = manager.get_file_pair_info(md_file) + + assert pair_info['basename'] == 'example' + assert pair_info['markdown_file'] == md_file + assert pair_info['schema_file'] == schema_file + assert pair_info['both_exist'] is True + assert 'markdown_size' in pair_info + assert 'schema_size' in pair_info + + def test_supports_nested_directories(self, manager, temp_dir): + """Should work correctly with nested directory structures.""" + nested_dir = temp_dir / "docs" / "architecture" + nested_dir.mkdir(parents=True) + + md_file = nested_dir / "system.md" + schema_file = nested_dir / "system.json" + + md_file.write_text("# System Architecture") + + schema_path = manager.get_associated_schema_path(md_file) + assert schema_path == schema_file + assert not manager.has_associated_schema(md_file) + + schema_file.write_text('{"type": "object"}') + assert manager.has_associated_schema(md_file) + + def test_handles_complex_filenames(self, manager, temp_dir): + """Should handle complex filenames with special characters.""" + complex_name = "my-complex_file.name-v2" + md_file = temp_dir / f"{complex_name}.md" + + md_file.write_text("# Complex File") + + schema_path = manager.get_associated_schema_path(md_file) + expected_schema = temp_dir / f"{complex_name}.json" + + assert schema_path == expected_schema + assert schema_path.stem == complex_name + + def test_validate_file_extensions(self, manager, temp_dir): + """Should validate that files have correct extensions.""" + txt_file = temp_dir / "document.txt" + txt_file.write_text("Not markdown") + + from markitect.associated_files import InvalidFileTypeError + + with pytest.raises(InvalidFileTypeError, match="Expected markdown file"): + manager.get_associated_schema_path(txt_file) + + xml_file = temp_dir / "schema.xml" + xml_file.write_text("") + + with pytest.raises(InvalidFileTypeError, match="Expected schema file"): + manager.get_associated_markdown_path(xml_file) + + +class TestAssociatedFilesIntegration: + """Test integration of associated files with existing commands.""" + + @pytest.fixture + def manager(self): + return AssociatedFilesManager() + + @pytest.fixture + def temp_dir(self): + with TemporaryDirectory() as temp_dir: + yield Path(temp_dir) + + def test_schema_generate_default_output_placement(self, manager, temp_dir): + """Schema generation should default to placing output next to source.""" + md_file = temp_dir / "article.md" + md_file.write_text("# Article\n\n## Introduction\n\nContent here.") + + expected_schema_path = manager.get_associated_schema_path(md_file) + + # This would be the expected behavior for schema-generate command + assert expected_schema_path == temp_dir / "article.json" + + def test_stub_generate_default_output_placement(self, manager, temp_dir): + """Stub generation should default to placing output next to schema.""" + schema_file = temp_dir / "template.json" + schema_file.write_text('''{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Template Schema" + }''') + + expected_md_path = manager.get_associated_markdown_path(schema_file) + + # This would be the expected behavior for generate-stub command + assert expected_md_path == temp_dir / "template.md" + + def test_validation_auto_discovery(self, manager, temp_dir): + """Validation should auto-discover associated schema files.""" + md_file = temp_dir / "document.md" + schema_file = temp_dir / "document.json" + + md_file.write_text("# Document") + schema_file.write_text('{"type": "object"}') + + # Validation command should find schema automatically + found_schema = manager.find_associated_schema(md_file) + assert found_schema == schema_file + + def test_workflow_roundtrip(self, manager, temp_dir): + """Test complete workflow: markdown → schema → stub.""" + # Start with markdown + original_md = temp_dir / "workflow_test.md" + original_md.write_text("# Workflow Test\n\n## Section 1\n\nContent.") + + # Generate schema (should place next to original) + schema_path = manager.get_associated_schema_path(original_md) + assert schema_path == temp_dir / "workflow_test.json" + + # Create the schema (simulating schema-generate command) + schema_path.write_text('{"type": "object", "title": "Workflow Test"}') + + # Generate stub from schema (should use different name to avoid conflict) + stub_path = temp_dir / "workflow_test_stub.md" # Avoiding conflict with original + + # Verify the association logic works + assert manager.has_associated_schema(original_md) + assert manager.has_associated_markdown(schema_path) \ No newline at end of file diff --git a/tests/test_issue_40_cli_integration.py b/tests/test_issue_40_cli_integration.py new file mode 100644 index 00000000..23674b6a --- /dev/null +++ b/tests/test_issue_40_cli_integration.py @@ -0,0 +1,326 @@ +""" +CLI Integration Tests for Issue #40: Associated Files Management. + +Tests the enhanced CLI commands that work with associated files. +""" + +import json +import pytest +from pathlib import Path +from tempfile import TemporaryDirectory +from click.testing import CliRunner + +from markitect.cli import cli + + +class TestIssue40CLIIntegration: + """Test CLI integration for associated files management.""" + + @pytest.fixture + def runner(self): + """Create CLI test runner.""" + return CliRunner() + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory for testing.""" + with TemporaryDirectory() as temp_dir: + yield Path(temp_dir) + + def test_schema_generate_with_explicit_associated_path(self, runner, temp_dir): + """schema-generate can use explicit associated .json file path.""" + md_file = temp_dir / "document.md" + md_file.write_text("# Document\n\n## Introduction\n\nContent here.") + + # Explicitly specify associated file path + expected_schema = temp_dir / "document.json" + result = runner.invoke(cli, [ + 'schema-generate', str(md_file), '--output', str(expected_schema) + ]) + + assert result.exit_code == 0 + + # Should create associated schema file + assert expected_schema.exists() + + # Verify it's valid JSON + schema_content = json.loads(expected_schema.read_text()) + assert schema_content['type'] == 'object' + + def test_schema_generate_interactive_mode_defaults_to_associated_path(self, runner, temp_dir): + """schema-generate in interactive mode should default to associated path.""" + md_file = temp_dir / "document.md" + md_file.write_text("# Document\n\n## Introduction\n\nContent here.") + + # Run schema-generate without --output in interactive mode + result = runner.invoke(cli, [ + 'schema-generate', str(md_file) + ], env={'MARKITECT_MODE': 'interactive'}) + + assert result.exit_code == 0 + + # Should create associated schema file + expected_schema = temp_dir / "document.json" + assert expected_schema.exists() + + # Verify it's valid JSON + schema_content = json.loads(expected_schema.read_text()) + assert schema_content['type'] == 'object' + + def test_generate_stub_with_explicit_associated_path(self, runner, temp_dir): + """generate-stub can use explicit associated .md file path.""" + schema_file = temp_dir / "template.json" + schema_content = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Template Schema", + "properties": { + "headings": { + "type": "object", + "properties": { + "level_1": {"type": "array", "minItems": 1, "maxItems": 1} + } + } + } + } + schema_file.write_text(json.dumps(schema_content)) + + # Explicitly specify associated file path + expected_md = temp_dir / "template.md" + result = runner.invoke(cli, [ + 'generate-stub', str(schema_file), '--output', str(expected_md) + ]) + + assert result.exit_code == 0 + + # Should create associated markdown file + assert expected_md.exists() + + # Verify it's valid markdown + md_content = expected_md.read_text() + assert md_content.startswith('# ') + + def test_generate_stub_interactive_mode_defaults_to_associated_path(self, runner, temp_dir): + """generate-stub in interactive mode should default to associated path.""" + schema_file = temp_dir / "template.json" + schema_content = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Template Schema", + "properties": { + "headings": { + "type": "object", + "properties": { + "level_1": {"type": "array", "minItems": 1, "maxItems": 1} + } + } + } + } + schema_file.write_text(json.dumps(schema_content)) + + # Run generate-stub without --output in interactive mode + result = runner.invoke(cli, [ + 'generate-stub', str(schema_file) + ], env={'MARKITECT_MODE': 'interactive'}) + + assert result.exit_code == 0 + + # Should create associated markdown file + expected_md = temp_dir / "template.md" + assert expected_md.exists() + + # Verify it's valid markdown + md_content = expected_md.read_text() + assert md_content.startswith('# ') + + def test_validate_auto_discovers_associated_schema(self, runner, temp_dir): + """validate should auto-discover associated schema when not specified.""" + md_file = temp_dir / "article.md" + schema_file = temp_dir / "article.json" + + md_file.write_text("# Article\n\n## Introduction\n\nContent.") + schema_file.write_text('{"$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "title": "Article Schema"}') + + # Run validate with only markdown file (should find associated schema) + result = runner.invoke(cli, [ + 'validate', str(md_file) + ]) + + assert result.exit_code == 0 + # Should indicate successful validation using auto-discovered schema + + def test_new_associated_files_list_command(self, runner, temp_dir): + """Should have new command to list associated file pairs.""" + # Create some file pairs + (temp_dir / "doc1.md").write_text("# Doc 1") + (temp_dir / "doc1.json").write_text('{"type": "object"}') + + (temp_dir / "doc2.md").write_text("# Doc 2") + (temp_dir / "doc2.json").write_text('{"type": "object"}') + + # Create orphaned files + (temp_dir / "orphan.md").write_text("# Orphan") + + result = runner.invoke(cli, [ + 'associated-files', 'list', str(temp_dir) + ]) + + assert result.exit_code == 0 + assert 'doc1' in result.output + assert 'doc2' in result.output + # Should show paired status + + def test_new_associated_files_info_command(self, runner, temp_dir): + """Should have command to show info about associated files.""" + md_file = temp_dir / "example.md" + schema_file = temp_dir / "example.json" + + md_file.write_text("# Example\n\nContent here.") + schema_file.write_text('{"type": "object", "title": "Example"}') + + result = runner.invoke(cli, [ + 'associated-files', 'info', str(md_file) + ]) + + assert result.exit_code == 0 + assert 'example' in result.output + assert 'paired' in result.output.lower() + + def test_new_associated_files_create_command(self, runner, temp_dir): + """Should have command to create missing associated files.""" + md_file = temp_dir / "lonely.md" + md_file.write_text("# Lonely Document\n\n## Section\n\nContent.") + + # Create associated schema + result = runner.invoke(cli, [ + 'associated-files', 'create-schema', str(md_file) + ]) + + assert result.exit_code == 0 + + expected_schema = temp_dir / "lonely.json" + assert expected_schema.exists() + + # Verify it's a valid schema + schema = json.loads(expected_schema.read_text()) + assert schema['type'] == 'object' + + def test_new_associated_files_create_stub_command(self, runner, temp_dir): + """Should have command to create stub from existing schema.""" + schema_file = temp_dir / "template.json" + schema_content = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Template", + "properties": { + "headings": { + "type": "object", + "properties": { + "level_1": {"type": "array", "minItems": 1} + } + } + } + } + schema_file.write_text(json.dumps(schema_content)) + + # Create associated markdown stub + result = runner.invoke(cli, [ + 'associated-files', 'create-stub', str(schema_file) + ]) + + assert result.exit_code == 0 + + expected_md = temp_dir / "template.md" + assert expected_md.exists() + + # Verify it's valid markdown + md_content = expected_md.read_text() + assert md_content.startswith('# ') + + def test_associated_files_status_command(self, runner, temp_dir): + """Should show status of associated files in directory.""" + # Create mixed scenarios + (temp_dir / "paired.md").write_text("# Paired") + (temp_dir / "paired.json").write_text('{"type": "object"}') + + (temp_dir / "lonely.md").write_text("# Lonely") + (temp_dir / "orphan.json").write_text('{"type": "object"}') + + result = runner.invoke(cli, [ + 'associated-files', 'status', str(temp_dir) + ]) + + assert result.exit_code == 0 + assert 'paired' in result.output.lower() or 'orphaned' in result.output.lower() + + def test_enhanced_commands_preserve_explicit_output(self, runner, temp_dir): + """Enhanced commands should still respect explicit --output options.""" + md_file = temp_dir / "source.md" + md_file.write_text("# Source\n\n## Content") + + custom_output = temp_dir / "custom_name.json" + + # Use explicit output path (should override associated file logic) + result = runner.invoke(cli, [ + 'schema-generate', str(md_file), '--output', str(custom_output) + ]) + + assert result.exit_code == 0 + assert custom_output.exists() + assert not (temp_dir / "source.json").exists() + + def test_associated_files_help_commands(self, runner): + """Associated files commands should provide helpful usage information.""" + result = runner.invoke(cli, ['associated-files', '--help']) + assert result.exit_code == 0 + assert 'associated' in result.output.lower() + assert 'files' in result.output.lower() + + # Test subcommand help + result = runner.invoke(cli, ['associated-files', 'list', '--help']) + assert result.exit_code == 0 + + def test_error_handling_for_non_existent_files(self, runner, temp_dir): + """Should handle non-existent files gracefully.""" + non_existent = temp_dir / "does_not_exist.md" + + result = runner.invoke(cli, [ + 'associated-files', 'info', str(non_existent) + ]) + + assert result.exit_code != 0 + assert 'not found' in result.output.lower() or 'error' in result.output.lower() + + def test_workflow_integration_complete_cycle(self, runner, temp_dir): + """Test complete workflow with associated files.""" + original_md = temp_dir / "workflow.md" + original_md.write_text("# Workflow Example\n\n## Introduction\n\nTest content.") + + # Step 1: Generate schema explicitly to workflow.json + schema_file = temp_dir / "workflow.json" + result1 = runner.invoke(cli, [ + 'schema-generate', str(original_md), '--output', str(schema_file) + ]) + assert result1.exit_code == 0 + assert schema_file.exists() + + # Step 2: Generate stub with different name to avoid conflict + stub_name = temp_dir / "workflow_template.md" + result2 = runner.invoke(cli, [ + 'generate-stub', str(schema_file), '--output', str(stub_name) + ]) + assert result2.exit_code == 0 + assert stub_name.exists() + + # Step 3: Validate original against its schema + result3 = runner.invoke(cli, [ + 'validate', str(original_md) + ]) + assert result3.exit_code == 0 + + # Step 4: List associated files + result4 = runner.invoke(cli, [ + 'associated-files', 'status', str(temp_dir) + ]) + assert result4.exit_code == 0 + assert 'paired' in result4.output.lower() or 'orphaned' in result4.output.lower() \ No newline at end of file diff --git a/tests/test_l4_service_output_formatting.py b/tests/test_l4_service_output_formatting.py index f90c40a7..c53d4596 100644 --- a/tests/test_l4_service_output_formatting.py +++ b/tests/test_l4_service_output_formatting.py @@ -140,6 +140,9 @@ class TestOutputFormatting: # Without specifying format result = self.runner.invoke(cli, ['query', 'SELECT * FROM markdown_files']) + if result.exit_code != 0: + print("Command output:", result.output) + print("Command exception:", result.exception) assert result.exit_code == 0 # Should look like table format (not JSON or YAML) assert not result.output.strip().startswith('[') # Not JSON array