diff --git a/src/gdiffer/output.py b/src/gdiffer/output.py new file mode 100644 index 0000000..aff8e0d --- /dev/null +++ b/src/gdiffer/output.py @@ -0,0 +1,256 @@ +"""Output formatter for color-coded terminal display.""" + +from enum import Enum + +from rich.console import Console +from rich.theme import Theme + +from gdiffer.models import DiffAnalysis + + +class OutputFormat(Enum): + TERMINAL = "terminal" + JSON = "json" + PLAIN = "plain" + + +class SeverityColors: + CRITICAL = "red" + HIGH = "orange3" + MEDIUM = "yellow" + LOW = "cyan" + INFO = "blue" + + +class OutputFormatter: + def __init__(self, output_format: OutputFormat = OutputFormat.TERMINAL): + self.output_format = output_format + self.console = Console(theme=Theme({ + "critical": "bold red", + "high": "orange3", + "medium": "yellow", + "low": "cyan", + "info": "blue", + "added": "green", + "removed": "red", + "modified": "yellow", + "filename": "bold blue", + })) + + def format_analysis(self, analysis: DiffAnalysis) -> str: + if self.output_format == OutputFormat.JSON: + return self._format_json(analysis) + elif self.output_format == OutputFormat.PLAIN: + return self._format_plain(analysis) + else: + return self._format_terminal(analysis) + + def _format_terminal(self, analysis: DiffAnalysis) -> str: + output_parts = [] + + output_parts.append(self._format_summary(analysis)) + output_parts.append(self._format_files(analysis)) + + if analysis.all_issues: + output_parts.append(self._format_issues(analysis.all_issues)) + + if analysis.all_suggestions: + output_parts.append(self._format_suggestions(analysis.all_suggestions)) + + return '\n'.join(output_parts) + + def _format_summary(self, analysis: DiffAnalysis) -> str: + lines = [] + lines.append("[bold blue]=== Git Diff Analysis Summary ===[/bold blue]") + lines.append(f"[info]Total files changed:[/info] [bold]{analysis.total_files}[/bold]") + lines.append(f"[added]Files added:[/added] {analysis.files_added}") + lines.append(f"[removed]Files deleted:[/removed] {analysis.files_deleted}") + lines.append(f"[modified]Files modified:[/modified] {analysis.files_modified}") + lines.append(f"[info]Total changes:[/info] {analysis.total_changes}") + + if analysis.language_breakdown: + lines.append("\n[bold blue]Languages:[/bold blue]") + for lang, count in sorted(analysis.language_breakdown.items()): + lines.append(f" - {lang}: {count}") + + return '\n'.join(lines) + + def _format_files(self, analysis: DiffAnalysis) -> str: + lines = [] + lines.append("\n[bold blue]=== File Changes ===[/bold blue]") + + for i, file_obj in enumerate(analysis.files, 1): + lines.append(f"\n[filename]{i}. {file_obj.filename}[/filename]") + + change_emoji = { + "add": "[added][✚][/added]", + "delete": "[removed][✖][/removed]", + "rename": "[info][↪][/info]", + "modify": "[modified][✎][/modified]", + } + change_label = change_emoji.get(file_obj.change_type, "") + lines.append(f" Status: {change_label} {file_obj.change_type}") + + if file_obj.is_rename: + lines.append(f" Renamed from: {file_obj.rename_from}") + + if file_obj.hunks: + total_changes = sum(h.new_lines for h in file_obj.hunks) + lines.append(f" Changes: {total_changes} lines") + + for j, hunk in enumerate(file_obj.hunks, 1): + lines.append(f" Hunk {j} (lines {hunk.old_start}-{hunk.old_start + hunk.old_lines}):") + lines.append(self._format_hunk(hunk)) + + return '\n'.join(lines) + + def _format_hunk(self, hunk) -> str: + lines = [] + for line in hunk.new_lines_content: + if line.startswith('+++'): + continue + if line.startswith('+'): + lines.append(f" [added]{line}[/added]") + elif line.startswith('-'): + lines.append(f" [removed]{line}[/removed]") + elif line.startswith('@@'): + lines.append(f" [info]{line}[/info]") + else: + lines.append(f" {line}") + return '\n'.join(lines) + + def _format_issues(self, issues: list[dict]) -> str: + lines = [] + lines.append("\n[bold blue]=== Detected Issues ===[/bold blue]") + + severity_priority = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} + sorted_issues = sorted(issues, key=lambda x: severity_priority.get(x.get('severity', ''), 4)) + + for issue in sorted_issues: + severity = issue.get('severity', 'info').lower() + color = getattr(SeverityColors, severity.upper(), 'info') + lines.append(f"\n[{color}][✖] {issue.get('title', 'Issue')}[/]") + lines.append(f" Severity: [{color}]{severity.upper()}[/]") + lines.append(f" Description: {issue.get('description', '')}") + if issue.get('line'): + lines.append(f" Line: {issue['line']}") + if issue.get('suggestion'): + lines.append(f" Suggestion: {issue['suggestion']}") + + return '\n'.join(lines) + + def _format_suggestions(self, suggestions: list[str]) -> str: + lines = [] + lines.append("\n[bold blue]=== Suggestions ===[/bold blue]") + + for i, suggestion in enumerate(suggestions, 1): + lines.append(f"\n[info]{i}. {suggestion}[/info]") + + return '\n'.join(lines) + + def _format_json(self, analysis: DiffAnalysis) -> str: + import json + + result = { + 'summary': { + 'total_files': analysis.total_files, + 'files_added': analysis.files_added, + 'files_deleted': analysis.files_deleted, + 'files_modified': analysis.files_modified, + 'files_renamed': analysis.files_renamed, + 'total_changes': analysis.total_changes, + 'language_breakdown': analysis.language_breakdown, + }, + 'files': [], + 'issues': analysis.all_issues, + 'suggestions': analysis.all_suggestions, + } + + for file_obj in analysis.files: + file_data = { + 'filename': file_obj.filename, + 'change_type': file_obj.change_type, + 'old_path': file_obj.old_path, + 'new_path': file_obj.new_path, + 'is_new': file_obj.is_new, + 'is_deleted': file_obj.is_deleted, + 'is_rename': file_obj.is_rename, + 'language': file_obj.extension, + 'hunks': [], + } + + for hunk in file_obj.hunks: + hunk_data = { + 'old_start': hunk.old_start, + 'old_lines': hunk.old_lines, + 'new_start': hunk.new_start, + 'new_lines': hunk.new_lines, + 'added_lines': hunk.get_added_lines(), + 'removed_lines': hunk.get_removed_lines(), + } + file_data['hunks'].append(hunk_data) + + result['files'].append(file_data) + + return json.dumps(result, indent=2) + + def _format_plain(self, analysis: DiffAnalysis) -> str: + lines = [] + lines.append("=== Git Diff Analysis Summary ===") + lines.append(f"Total files changed: {analysis.total_files}") + lines.append(f"Files added: {analysis.files_added}") + lines.append(f"Files deleted: {analysis.files_deleted}") + lines.append(f"Files modified: {analysis.files_modified}") + lines.append(f"Total changes: {analysis.total_changes}") + + if analysis.language_breakdown: + lines.append("\nLanguages:") + for lang, count in sorted(analysis.language_breakdown.items()): + lines.append(f" - {lang}: {count}") + + lines.append("\n=== File Changes ===") + + for i, file_obj in enumerate(analysis.files, 1): + lines.append(f"\n{i}. {file_obj.filename}") + lines.append(f" Status: {file_obj.change_type}") + + if file_obj.is_rename: + lines.append(f" Renamed from: {file_obj.rename_from}") + + for j, hunk in enumerate(file_obj.hunks, 1): + lines.append(f" Hunk {j}:") + for line in hunk.new_lines_content: + if line.startswith('+++'): + continue + lines.append(f" {line}") + + if analysis.all_issues: + lines.append("\n=== Detected Issues ===") + for issue in analysis.all_issues: + lines.append(f"- {issue.get('title', 'Issue')}") + lines.append(f" Severity: {issue.get('severity', 'unknown')}") + lines.append(f" Description: {issue.get('description', '')}") + + if analysis.all_suggestions: + lines.append("\n=== Suggestions ===") + for i, suggestion in enumerate(analysis.all_suggestions, 1): + lines.append(f"{i}. {suggestion}") + + return '\n'.join(lines) + + def print(self, content: str) -> None: + self.console.print(content) + + def print_analysis(self, analysis: DiffAnalysis) -> None: + formatted = self.format_analysis(analysis) + self.print(formatted) + + +def format_analysis(analysis: DiffAnalysis, output_format: str = "terminal") -> str: + fmt = OutputFormatter(OutputFormat(output_format)) + return fmt.format_analysis(analysis) + + +def print_analysis(analysis: DiffAnalysis, output_format: str = "terminal") -> None: + fmt = OutputFormatter(OutputFormat(output_format)) + fmt.print_analysis(analysis)