diff --git a/scaffoldforge/generators/structure.py b/scaffoldforge/generators/structure.py new file mode 100644 index 0000000..b9218e4 --- /dev/null +++ b/scaffoldforge/generators/structure.py @@ -0,0 +1,270 @@ +"""Project structure generation functionality.""" + +import os +import stat +from pathlib import Path +from typing import Any, Dict, List, Optional + +from scaffoldforge.generators.code import CodeGenerator +from scaffoldforge.generators.models import FileSpec +from scaffoldforge.parsers import IssueData + + +class StructureGenerator: + """Generator for project directory structure and files.""" + + def __init__( + self, + output_dir: Optional[str] = None, + preview: bool = False, + ): + """Initialize the structure generator. + + Args: + output_dir: Base output directory for generated projects. + preview: If True, don't write files, just print what would be done. + """ + self.output_dir = output_dir or "./generated" + self.preview = preview + self.created_files: List[str] = [] + self.created_dirs: List[str] = [] + + def generate( + self, + language: str, + issue_data: IssueData, + code_generator: CodeGenerator, + project_name: Optional[str] = None, + ) -> None: + """Generate the complete project structure. + + Args: + language: Programming language. + issue_data: Parsed issue data. + code_generator: CodeGenerator instance for file content. + project_name: Optional custom project name. + """ + if project_name is None: + project_name = self._sanitize_name(issue_data.title) + + base_path = Path(self.output_dir) / project_name + + if self.preview: + print(f"\n{'='*60}") + print(f"PREVIEW: Project would be created at: {base_path}") + print(f"{'='*60}\n") + + files = code_generator.generate_all_files(language, issue_data) + + self._create_directories(base_path, issue_data) + + for file_spec in files: + self._write_file(base_path, file_spec) + + self._create_readme(base_path, issue_data, project_name) + self._create_gitignore(base_path, project_name, language) + + if self.preview: + self._print_preview_summary() + + def _create_directories( + self, base_path: Path, issue_data: IssueData + ) -> None: + """Create project directories. + + Args: + base_path: Base path for the project. + issue_data: Issue data containing suggested directories. + """ + directories = ["src", "tests"] + + for dir_name in issue_data.suggested_directories: + clean_name = Path(dir_name).parts[0] + if clean_name not in directories: + directories.append(clean_name) + + for directory in directories: + dir_path = base_path / directory + self._create_directory(dir_path) + + def _create_directory(self, path: Path) -> None: + """Create a single directory. + + Args: + path: Path to create. + """ + if self.preview: + print(f"[PREVIEW] Would create directory: {path}") + return + + try: + path.mkdir(parents=True, exist_ok=True) + self.created_dirs.append(str(path)) + except OSError as e: + raise OSError(f"Failed to create directory {path}: {e}") + + def _write_file(self, base_path: Path, file_spec: FileSpec) -> None: + """Write a file to the project. + + Args: + base_path: Base path for the project. + file_spec: File specification. + """ + file_path = base_path / file_spec.path + + if self.preview: + print(f"[PREVIEW] Would create file: {file_path}") + if file_spec.executable: + print(f" (executable)") + return + + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + mode = 0o755 if file_spec.executable else 0o644 + with open(file_path, "w", encoding=file_spec.encoding) as f: + f.write(file_spec.content) + os.chmod(file_path, mode) + self.created_files.append(str(file_path)) + except OSError as e: + raise OSError(f"Failed to write file {file_path}: {e}") + + def _create_readme( + self, base_path: Path, issue_data: IssueData, project_name: str + ) -> None: + """Create README.md file. + + Args: + base_path: Base path for the project. + issue_data: Issue data for content. + project_name: Name of the project. + """ + readme_content = f"""# {project_name} + +{issue_data.title} + +## Description + +{issue_data.body[:500]}{'...' if len(issue_data.body) > 500 else ''} + +**GitHub Issue:** #{issue_data.number} +**Repository:** {issue_data.repository} +**URL:** {issue_data.url} + +## Requirements + +{chr(10).join(f"- {req}" for req in issue_data.requirements) if issue_data.requirements else '- See GitHub issue for requirements'} + +## TODO Items + +{chr(10).join(f"- [ ] {item}" for item in issue_data.get_todo_items()) if issue_data.get_todo_items() else '- No TODO items found'} + +## Getting Started + +### Installation + +```bash +# Clone the repository +git clone https://github.com/{issue_data.repository}.git +cd {project_name} +``` + +### Usage + +```bash +# Run the project +python main.py +``` + +## License + +MIT +""" + self._write_file( + base_path, + FileSpec(path="README.md", content=readme_content), + ) + + def _create_gitignore( + self, base_path: Path, project_name: str, language: str + ) -> None: + """Create .gitignore file based on language. + + Args: + base_path: Base path for the project. + project_name: Name of the project. + language: Programming language. + """ + gitignore_templates = { + "python": """__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +venv/ +.env +""", + "javascript": """node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dist/ +coverage/ +.env +""", + "go": """*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +""", + "rust": """target/ +Cargo.lock +*.swp +*.swo +""", + } + + gitignore_content = gitignore_templates.get(language, "") + + self._write_file( + base_path, + FileSpec(path=".gitignore", content=gitignore_content), + ) + + def _sanitize_name(self, name: str) -> str: + """Sanitize a string for use as a directory/project name. + + Args: + name: Original name. + + Returns: + Sanitized name. + """ + import re + name = re.sub(r"[^a-zA-Z0-9\s_-]", "", name) + name = re.sub(r"\s+", "-", name.strip()) + return name.lower()[:50] or "project" + + def _print_preview_summary(self) -> None: + """Print a summary of what would be created in preview mode.""" + print(f"\n{'='*60}") + print("PREVIEW SUMMARY") + print(f"{'='*60}") + print(f"Directories: {len(self.created_dirs)}") + print(f"Files: {len(self.created_files)}") + print(f"{'='*60}\n") + + def get_created_files(self) -> List[str]: + """Get list of created files.""" + return self.created_files.copy() + + def get_created_directories(self) -> List[str]: + """Get list of created directories.""" + return self.created_dirs.copy()