Files
gibidify/cli/errors_test.go
Ismo Vuorinen 95b7ef6dd3 chore: modernize workflows, security scanning, and linting configuration (#50)
* build: update Go 1.25, CI workflows, and build tooling

- Upgrade to Go 1.25
- Add benchmark targets to Makefile
- Implement parallel gosec execution
- Lock tool versions for reproducibility
- Add shellcheck directives to scripts
- Update CI workflows with improved caching

* refactor: migrate from golangci-lint to revive

- Replace golangci-lint with revive for linting
- Configure comprehensive revive rules
- Fix all EditorConfig violations
- Add yamllint and yamlfmt support
- Remove deprecated .golangci.yml

* refactor: rename utils to shared and deduplicate code

- Rename utils package to shared
- Add shared constants package
- Deduplicate constants across packages
- Address CodeRabbit review feedback

* fix: resolve SonarQube issues and add safety guards

- Fix all 73 SonarQube OPEN issues
- Add nil guards for resourceMonitor, backpressure, metricsCollector
- Implement io.Closer for headerFileReader
- Propagate errors from processing helpers
- Add metrics and templates packages
- Improve error handling across codebase

* test: improve test infrastructure and coverage

- Add benchmarks for cli, fileproc, metrics
- Improve test coverage for cli, fileproc, config
- Refactor tests with helper functions
- Add shared test constants
- Fix test function naming conventions
- Reduce cognitive complexity in benchmark tests

* docs: update documentation and configuration examples

- Update CLAUDE.md with current project state
- Refresh README with new features
- Add usage and configuration examples
- Add SonarQube project configuration
- Consolidate config.example.yaml

* fix: resolve shellcheck warnings in scripts

- Use ./*.go instead of *.go to prevent dash-prefixed filenames
  from being interpreted as options (SC2035)
- Remove unreachable return statement after exit (SC2317)
- Remove obsolete gibidiutils/ directory reference

* chore(deps): upgrade go dependencies

* chore(lint): megalinter fixes

* fix: improve test coverage and fix file descriptor leaks

- Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests
- Refactor TestProcessorConfigureFileTypes with helper functions and assertions
- Refactor TestProcessorLogFinalStats with output capture and keyword verification
- Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.)
- Reduce cognitive complexity by extracting helper functions

* fix: align test comments with function names

Remove underscores from test comments to match actual function names:
- benchmark/benchmark_test.go (2 fixes)
- fileproc/filetypes_config_test.go (4 fixes)
- fileproc/filetypes_registry_test.go (6 fixes)
- fileproc/processor_test.go (6 fixes)
- fileproc/resource_monitor_types_test.go (4 fixes)
- fileproc/writer_test.go (3 fixes)

* fix: various test improvements and bug fixes

- Remove duplicate maxCacheSize check in filetypes_registry_test.go
- Shorten long comment in processor_test.go to stay under 120 chars
- Remove flaky time.Sleep in collector_test.go, use >= 0 assertion
- Close pipe reader in benchmark_test.go to fix file descriptor leak
- Use ContinueOnError in flags_test.go to match ResetFlags behavior
- Add nil check for p.ui in processor_workers.go before UpdateProgress
- Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly

* chore(yaml): add missing document start markers

Add --- document start to YAML files to satisfy yamllint:
- .github/workflows/codeql.yml
- .github/workflows/build-test-publish.yml
- .github/workflows/security.yml
- .github/actions/setup/action.yml

* fix: guard nil resourceMonitor and fix test deadlock

- Guard resourceMonitor before CreateFileProcessingContext call
- Add ui.UpdateProgress on emergency stop and path error returns
- Fix potential deadlock in TestProcessFile using wg.Go with defer close
2025-12-10 19:07:11 +02:00

745 lines
19 KiB
Go

package cli
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ivuorinen/gibidify/shared"
)
func TestNewErrorFormatter(t *testing.T) {
ui := NewUIManager()
formatter := NewErrorFormatter(ui)
if formatter == nil {
t.Error("NewErrorFormatter() returned nil")
return
}
if formatter.ui != ui {
t.Error("NewErrorFormatter() did not set ui manager correctly")
}
}
func TestErrorFormatterFormatError(t *testing.T) {
tests := []struct {
name string
err error
expectedOutput []string // Substrings that should be present in output
}{
{
name: "nil error",
err: nil,
expectedOutput: []string{}, // Should produce no output
},
{
name: "structured error with context",
err: &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: shared.CodeFSAccess,
Message: shared.TestErrCannotAccessFile,
FilePath: shared.TestPathBase,
Context: map[string]any{
"permission": "0000",
"owner": "root",
},
},
expectedOutput: []string{
"✗ Error: " + shared.TestErrCannotAccessFile,
"Type: FileSystem, Code: ACCESS_DENIED",
"File: " + shared.TestPathBase,
"Context:",
"permission: 0000",
"owner: root",
shared.TestSuggestionsWarning,
"Check if the path exists",
},
},
{
name: "validation error",
err: &shared.StructuredError{
Type: shared.ErrorTypeValidation,
Code: shared.CodeValidationFormat,
Message: "invalid output format",
},
expectedOutput: []string{
"✗ Error: invalid output format",
"Type: Validation, Code: FORMAT",
shared.TestSuggestionsWarning,
"Use a supported format: markdown, json, yaml",
},
},
{
name: "processing error",
err: &shared.StructuredError{
Type: shared.ErrorTypeProcessing,
Code: shared.CodeProcessingCollection,
Message: "failed to collect files",
},
expectedOutput: []string{
"✗ Error: failed to collect files",
"Type: Processing, Code: COLLECTION",
shared.TestSuggestionsWarning,
"Check if the source directory exists",
},
},
{
name: "I/O error",
err: &shared.StructuredError{
Type: shared.ErrorTypeIO,
Code: shared.CodeIOFileCreate,
Message: "cannot create output file",
},
expectedOutput: []string{
"✗ Error: cannot create output file",
"Type: IO, Code: FILE_CREATE",
shared.TestSuggestionsWarning,
"Check if the destination directory exists",
},
},
{
name: "generic error with permission denied",
err: errors.New("permission denied: access to /secret/file"),
expectedOutput: []string{
"✗ Error: permission denied: access to /secret/file",
shared.TestSuggestionsWarning,
shared.TestSuggestCheckPermissions,
"Try running with appropriate privileges",
},
},
{
name: "generic error with file not found",
err: errors.New("no such file or directory"),
expectedOutput: []string{
"✗ Error: no such file or directory",
shared.TestSuggestionsWarning,
"Verify the file/directory path is correct",
"Check if the file exists",
},
},
{
name: "generic error with flag redefined",
err: errors.New("flag provided but not defined: -invalid"),
expectedOutput: []string{
"✗ Error: flag provided but not defined: -invalid",
shared.TestSuggestionsWarning,
shared.TestSuggestCheckArguments,
"Run with --help for usage information",
},
},
{
name: "unknown generic error",
err: errors.New("some unknown error"),
expectedOutput: []string{
"✗ Error: some unknown error",
shared.TestSuggestionsWarning,
shared.TestSuggestCheckArguments,
"Run with --help for usage information",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture output
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
formatter.FormatError(tt.err)
outputStr := output.String()
// For nil error, output should be empty
if tt.err == nil {
if outputStr != "" {
t.Errorf("Expected no output for nil error, got: %s", outputStr)
}
return
}
// Check that all expected substrings are present
for _, expected := range tt.expectedOutput {
if !strings.Contains(outputStr, expected) {
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
}
}
})
}
}
func TestErrorFormatterSuggestFileAccess(t *testing.T) {
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
// Create a temporary file to test with existing file
tempDir := t.TempDir()
tempFile, err := os.Create(filepath.Join(tempDir, "testfile"))
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
if err := tempFile.Close(); err != nil {
t.Errorf("Failed to close temp file: %v", err)
}
tests := []struct {
name string
filePath string
expectedOutput []string
}{
{
name: shared.TestErrEmptyFilePath,
filePath: "",
expectedOutput: []string{
shared.TestSuggestCheckExists,
"Verify read permissions",
},
},
{
name: "existing file",
filePath: tempFile.Name(),
expectedOutput: []string{
shared.TestSuggestCheckExists,
"Path exists but may not be accessible",
"Mode:",
},
},
{
name: "nonexistent file",
filePath: "/nonexistent/file",
expectedOutput: []string{
shared.TestSuggestCheckExists,
"Verify read permissions",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output.Reset()
formatter.suggestFileAccess(tt.filePath)
outputStr := output.String()
for _, expected := range tt.expectedOutput {
if !strings.Contains(outputStr, expected) {
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
}
}
})
}
}
func TestErrorFormatterSuggestFileNotFound(t *testing.T) {
// Create a test directory with some files
tempDir := t.TempDir()
testFiles := []string{"similar-file.txt", "another-similar.go", "different.md"}
for _, filename := range testFiles {
file, err := os.Create(filepath.Join(tempDir, filename))
if err != nil {
t.Fatalf("Failed to create test file %s: %v", filename, err)
}
if err := file.Close(); err != nil {
t.Errorf("Failed to close test file %s: %v", filename, err)
}
}
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
tests := []struct {
name string
filePath string
expectedOutput []string
}{
{
name: shared.TestErrEmptyFilePath,
filePath: "",
expectedOutput: []string{
shared.TestSuggestCheckFileExists,
},
},
{
name: "file with similar matches",
filePath: tempDir + "/similar",
expectedOutput: []string{
shared.TestSuggestCheckFileExists,
"Similar files in",
"similar-file.txt",
},
},
{
name: "nonexistent directory",
filePath: "/nonexistent/dir/file.txt",
expectedOutput: []string{
shared.TestSuggestCheckFileExists,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output.Reset()
formatter.suggestFileNotFound(tt.filePath)
outputStr := output.String()
for _, expected := range tt.expectedOutput {
if !strings.Contains(outputStr, expected) {
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
}
}
})
}
}
func TestErrorFormatterProvideSuggestions(t *testing.T) {
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
tests := []struct {
name string
err *shared.StructuredError
expectSuggestions []string
}{
{
name: "filesystem error",
err: &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: shared.CodeFSAccess,
},
expectSuggestions: []string{shared.TestSuggestionsPlain, "Check if the path exists"},
},
{
name: "validation error",
err: &shared.StructuredError{
Type: shared.ErrorTypeValidation,
Code: shared.CodeValidationFormat,
},
expectSuggestions: []string{shared.TestSuggestionsPlain, "Use a supported format"},
},
{
name: "processing error",
err: &shared.StructuredError{
Type: shared.ErrorTypeProcessing,
Code: shared.CodeProcessingCollection,
},
expectSuggestions: []string{shared.TestSuggestionsPlain, "Check if the source directory exists"},
},
{
name: "I/O error",
err: &shared.StructuredError{
Type: shared.ErrorTypeIO,
Code: shared.CodeIOWrite,
},
expectSuggestions: []string{shared.TestSuggestionsPlain, "Check available disk space"},
},
{
name: "unknown error type",
err: &shared.StructuredError{
Type: shared.ErrorTypeUnknown,
},
expectSuggestions: []string{"Check your command line arguments"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output.Reset()
formatter.provideSuggestions(tt.err)
outputStr := output.String()
for _, expected := range tt.expectSuggestions {
if !strings.Contains(outputStr, expected) {
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
}
}
})
}
}
func TestMissingSourceError(t *testing.T) {
err := NewCLIMissingSourceError()
if err == nil {
t.Error("NewCLIMissingSourceError() returned nil")
return
}
expectedMsg := "source directory is required"
if err.Error() != expectedMsg {
t.Errorf("MissingSourceError.Error() = %v, want %v", err.Error(), expectedMsg)
}
// Test type assertion
var cliErr *MissingSourceError
if !errors.As(err, &cliErr) {
t.Error("NewCLIMissingSourceError() did not return *MissingSourceError type")
}
}
func TestIsUserError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "nil error",
err: nil,
expected: false,
},
{
name: "CLI missing source error",
err: NewCLIMissingSourceError(),
expected: true,
},
{
name: "validation structured error",
err: &shared.StructuredError{
Type: shared.ErrorTypeValidation,
},
expected: true,
},
{
name: "validation format structured error",
err: &shared.StructuredError{
Code: shared.CodeValidationFormat,
},
expected: true,
},
{
name: "validation size structured error",
err: &shared.StructuredError{
Code: shared.CodeValidationSize,
},
expected: true,
},
{
name: "non-validation structured error",
err: &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
},
expected: false,
},
{
name: "generic error with flag keyword",
err: errors.New("flag provided but not defined"),
expected: true,
},
{
name: "generic error with usage keyword",
err: errors.New("usage: command [options]"),
expected: true,
},
{
name: "generic error with invalid argument",
err: errors.New("invalid argument provided"),
expected: true,
},
{
name: "generic error with file not found",
err: errors.New("file not found"),
expected: true,
},
{
name: "generic error with permission denied",
err: errors.New("permission denied"),
expected: true,
},
{
name: "system error not user-facing",
err: errors.New("internal system error"),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsUserError(tt.err)
if result != tt.expected {
t.Errorf("IsUserError(%v) = %v, want %v", tt.err, result, tt.expected)
}
})
}
}
// Helper functions for testing
// createTestUI creates a UIManager with captured output for testing.
func createTestUI() (*UIManager, *bytes.Buffer) {
output := &bytes.Buffer{}
ui := &UIManager{
enableColors: false, // Disable colors for consistent testing
enableProgress: false, // Disable progress for testing
output: output,
}
return ui, output
}
// TestErrorFormatterIntegration tests the complete error formatting workflow.
func TestErrorFormatterIntegration(t *testing.T) {
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
// Test a complete workflow with a complex structured error
structuredErr := &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: shared.CodeFSNotFound,
Message: "source directory not found",
FilePath: "/missing/directory",
Context: map[string]any{
"attempted_path": "/missing/directory",
"current_dir": "/working/dir",
},
}
formatter.FormatError(structuredErr)
outputStr := output.String()
// Verify all components are present
expectedComponents := []string{
"✗ Error: source directory not found",
"Type: FileSystem, Code: NOT_FOUND",
"File: /missing/directory",
"Context:",
"attempted_path: /missing/directory",
"current_dir: /working/dir",
shared.TestSuggestionsWarning,
"Check if the file/directory exists",
}
for _, expected := range expectedComponents {
if !strings.Contains(outputStr, expected) {
t.Errorf("Integration test output missing expected component: %q\nFull output:\n%s", expected, outputStr)
}
}
}
// TestErrorFormatter_SuggestPathResolution tests the suggestPathResolution function.
func TestErrorFormatterSuggestPathResolution(t *testing.T) {
tests := []struct {
name string
filePath string
expectedOutput []string
}{
{
name: "with file path",
filePath: "relative/path/file.txt",
expectedOutput: []string{
shared.TestSuggestUseAbsolutePath,
"Try:",
},
},
{
name: shared.TestErrEmptyFilePath,
filePath: "",
expectedOutput: []string{
shared.TestSuggestUseAbsolutePath,
},
},
{
name: "current directory reference",
filePath: "./file.txt",
expectedOutput: []string{
shared.TestSuggestUseAbsolutePath,
"Try:",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
// Call the method
formatter.suggestPathResolution(tt.filePath)
// Check output
outputStr := output.String()
for _, expected := range tt.expectedOutput {
if !strings.Contains(outputStr, expected) {
t.Errorf("suggestPathResolution output missing: %q\nFull output: %q", expected, outputStr)
}
}
})
}
}
// TestErrorFormatter_SuggestFileSystemGeneral tests the suggestFileSystemGeneral function.
func TestErrorFormatterSuggestFileSystemGeneral(t *testing.T) {
tests := []struct {
name string
filePath string
expectedOutput []string
}{
{
name: "with file path",
filePath: "/path/to/file.txt",
expectedOutput: []string{
shared.TestSuggestCheckPermissions,
shared.TestSuggestVerifyPath,
"Path: /path/to/file.txt",
},
},
{
name: shared.TestErrEmptyFilePath,
filePath: "",
expectedOutput: []string{
shared.TestSuggestCheckPermissions,
shared.TestSuggestVerifyPath,
},
},
{
name: "relative path",
filePath: "../parent/file.txt",
expectedOutput: []string{
shared.TestSuggestCheckPermissions,
shared.TestSuggestVerifyPath,
"Path: ../parent/file.txt",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
// Call the method
formatter.suggestFileSystemGeneral(tt.filePath)
// Check output
outputStr := output.String()
for _, expected := range tt.expectedOutput {
if !strings.Contains(outputStr, expected) {
t.Errorf("suggestFileSystemGeneral output missing: %q\nFull output: %q", expected, outputStr)
}
}
// When no file path is provided, should not contain "Path:" line
if tt.filePath == "" && strings.Contains(outputStr, "Path:") {
t.Error("suggestFileSystemGeneral should not include Path line when filePath is empty")
}
})
}
}
// TestErrorFormatter_SuggestionFunctions_Integration tests the integration of suggestion functions.
func TestErrorFormatterSuggestionFunctionsIntegration(t *testing.T) {
// Test that suggestion functions work as part of the full error formatting workflow
tests := []struct {
name string
err *shared.StructuredError
expectedSuggestions []string
}{
{
name: "filesystem path resolution error",
err: &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: shared.CodeFSPathResolution,
Message: "path resolution failed",
FilePath: "relative/path",
},
expectedSuggestions: []string{
shared.TestSuggestUseAbsolutePath,
"Try:",
},
},
{
name: "filesystem unknown error",
err: &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: "UNKNOWN_FS_ERROR", // This will trigger default case
Message: "unknown filesystem error",
FilePath: "/some/path",
},
expectedSuggestions: []string{
shared.TestSuggestCheckPermissions,
shared.TestSuggestVerifyPath,
"Path: /some/path",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui, output := createTestUI()
formatter := NewErrorFormatter(ui)
// Format the error (which should include suggestions)
formatter.FormatError(tt.err)
// Check that expected suggestions are present
outputStr := output.String()
for _, expected := range tt.expectedSuggestions {
if !strings.Contains(outputStr, expected) {
t.Errorf("Integrated suggestion missing: %q\nFull output: %q", expected, outputStr)
}
}
})
}
}
// Benchmarks for error formatting performance
// BenchmarkErrorFormatterFormatError benchmarks the FormatError method.
func BenchmarkErrorFormatterFormatError(b *testing.B) {
ui, _ := createTestUI()
formatter := NewErrorFormatter(ui)
err := &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: shared.CodeFSAccess,
Message: shared.TestErrCannotAccessFile,
FilePath: shared.TestPathBase,
}
b.ResetTimer()
for b.Loop() {
formatter.FormatError(err)
}
}
// BenchmarkErrorFormatterFormatErrorWithContext benchmarks error formatting with context.
func BenchmarkErrorFormatterFormatErrorWithContext(b *testing.B) {
ui, _ := createTestUI()
formatter := NewErrorFormatter(ui)
err := &shared.StructuredError{
Type: shared.ErrorTypeValidation,
Code: shared.CodeValidationFormat,
Message: "validation failed",
FilePath: shared.TestPathBase,
Context: map[string]any{
"field": "format",
"value": "invalid",
},
}
b.ResetTimer()
for b.Loop() {
formatter.FormatError(err)
}
}
// BenchmarkErrorFormatterProvideSuggestions benchmarks suggestion generation.
func BenchmarkErrorFormatterProvideSuggestions(b *testing.B) {
ui, _ := createTestUI()
formatter := NewErrorFormatter(ui)
err := &shared.StructuredError{
Type: shared.ErrorTypeFileSystem,
Code: shared.CodeFSAccess,
Message: shared.TestErrCannotAccessFile,
FilePath: shared.TestPathBase,
}
b.ResetTimer()
for b.Loop() {
formatter.provideSuggestions(err)
}
}