mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-01-26 03:24:05 +00:00
feat: update go to 1.25, add permissions and envs (#49)
* chore(ci): update go to 1.25, add permissions and envs * fix(ci): update pr-lint.yml * chore: update go, fix linting * fix: tests and linting * fix(lint): lint fixes, renovate should now pass * fix: updates, security upgrades * chore: workflow updates, lint * fix: more lint, checkmake, and other fixes * fix: more lint, convert scripts to POSIX compliant * fix: simplify codeql workflow * tests: increase test coverage, fix found issues * fix(lint): editorconfig checking, add to linters * fix(lint): shellcheck, add to linters * fix(lint): apply cr comment suggestions * fix(ci): remove step-security/harden-runner * fix(lint): remove duplication, apply cr fixes * fix(ci): tests in CI/CD pipeline * chore(lint): deduplication of strings * fix(lint): apply cr comment suggestions * fix(ci): actionlint * fix(lint): apply cr comment suggestions * chore: lint, add deps management
This commit is contained in:
154
cli/errors.go
154
cli/errors.go
@@ -1,3 +1,4 @@
|
||||
// Package cli provides command-line interface utilities for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -6,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/utils"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
// ErrorFormatter handles CLI-friendly error formatting with suggestions.
|
||||
@@ -19,6 +20,11 @@ func NewErrorFormatter(ui *UIManager) *ErrorFormatter {
|
||||
return &ErrorFormatter{ui: ui}
|
||||
}
|
||||
|
||||
// Suggestion messages for error formatting.
|
||||
const (
|
||||
suggestionCheckPermissions = " %s Check file/directory permissions\n"
|
||||
)
|
||||
|
||||
// FormatError formats an error with context and suggestions.
|
||||
func (ef *ErrorFormatter) FormatError(err error) {
|
||||
if err == nil {
|
||||
@@ -26,7 +32,8 @@ func (ef *ErrorFormatter) FormatError(err error) {
|
||||
}
|
||||
|
||||
// Handle structured errors
|
||||
if structErr, ok := err.(*utils.StructuredError); ok {
|
||||
var structErr *gibidiutils.StructuredError
|
||||
if errors.As(err, &structErr) {
|
||||
ef.formatStructuredError(structErr)
|
||||
return
|
||||
}
|
||||
@@ -36,12 +43,12 @@ func (ef *ErrorFormatter) FormatError(err error) {
|
||||
}
|
||||
|
||||
// formatStructuredError formats a structured error with context and suggestions.
|
||||
func (ef *ErrorFormatter) formatStructuredError(err *utils.StructuredError) {
|
||||
func (ef *ErrorFormatter) formatStructuredError(err *gibidiutils.StructuredError) {
|
||||
// Print main error
|
||||
ef.ui.PrintError("Error: %s", err.Message)
|
||||
|
||||
// Print error type and code
|
||||
if err.Type != utils.ErrorTypeUnknown || err.Code != "" {
|
||||
if err.Type != gibidiutils.ErrorTypeUnknown || err.Code != "" {
|
||||
ef.ui.PrintInfo("Type: %s, Code: %s", err.Type.String(), err.Code)
|
||||
}
|
||||
|
||||
@@ -69,15 +76,15 @@ func (ef *ErrorFormatter) formatGenericError(err error) {
|
||||
}
|
||||
|
||||
// provideSuggestions provides helpful suggestions based on the error.
|
||||
func (ef *ErrorFormatter) provideSuggestions(err *utils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideSuggestions(err *gibidiutils.StructuredError) {
|
||||
switch err.Type {
|
||||
case utils.ErrorTypeFileSystem:
|
||||
case gibidiutils.ErrorTypeFileSystem:
|
||||
ef.provideFileSystemSuggestions(err)
|
||||
case utils.ErrorTypeValidation:
|
||||
case gibidiutils.ErrorTypeValidation:
|
||||
ef.provideValidationSuggestions(err)
|
||||
case utils.ErrorTypeProcessing:
|
||||
case gibidiutils.ErrorTypeProcessing:
|
||||
ef.provideProcessingSuggestions(err)
|
||||
case utils.ErrorTypeIO:
|
||||
case gibidiutils.ErrorTypeIO:
|
||||
ef.provideIOSuggestions(err)
|
||||
default:
|
||||
ef.provideDefaultSuggestions()
|
||||
@@ -85,17 +92,17 @@ func (ef *ErrorFormatter) provideSuggestions(err *utils.StructuredError) {
|
||||
}
|
||||
|
||||
// provideFileSystemSuggestions provides suggestions for file system errors.
|
||||
func (ef *ErrorFormatter) provideFileSystemSuggestions(err *utils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideFileSystemSuggestions(err *gibidiutils.StructuredError) {
|
||||
filePath := err.FilePath
|
||||
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
|
||||
switch err.Code {
|
||||
case utils.CodeFSAccess:
|
||||
case gibidiutils.CodeFSAccess:
|
||||
ef.suggestFileAccess(filePath)
|
||||
case utils.CodeFSPathResolution:
|
||||
case gibidiutils.CodeFSPathResolution:
|
||||
ef.suggestPathResolution(filePath)
|
||||
case utils.CodeFSNotFound:
|
||||
case gibidiutils.CodeFSNotFound:
|
||||
ef.suggestFileNotFound(filePath)
|
||||
default:
|
||||
ef.suggestFileSystemGeneral(filePath)
|
||||
@@ -103,91 +110,91 @@ func (ef *ErrorFormatter) provideFileSystemSuggestions(err *utils.StructuredErro
|
||||
}
|
||||
|
||||
// provideValidationSuggestions provides suggestions for validation errors.
|
||||
func (ef *ErrorFormatter) provideValidationSuggestions(err *utils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideValidationSuggestions(err *gibidiutils.StructuredError) {
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
|
||||
switch err.Code {
|
||||
case utils.CodeValidationFormat:
|
||||
ef.ui.printf(" • Use a supported format: markdown, json, yaml\n")
|
||||
ef.ui.printf(" • Example: -format markdown\n")
|
||||
case utils.CodeValidationSize:
|
||||
ef.ui.printf(" • Increase file size limit in config.yaml\n")
|
||||
ef.ui.printf(" • Use smaller files or exclude large files\n")
|
||||
case gibidiutils.CodeValidationFormat:
|
||||
ef.ui.printf(" %s Use a supported format: markdown, json, yaml\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Example: -format markdown\n", gibidiutils.IconBullet)
|
||||
case gibidiutils.CodeValidationSize:
|
||||
ef.ui.printf(" %s Increase file size limit in config.yaml\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Use smaller files or exclude large files\n", gibidiutils.IconBullet)
|
||||
default:
|
||||
ef.ui.printf(" • Check your command line arguments\n")
|
||||
ef.ui.printf(" • Run with --help for usage information\n")
|
||||
ef.ui.printf(" %s Check your command line arguments\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Run with --help for usage information\n", gibidiutils.IconBullet)
|
||||
}
|
||||
}
|
||||
|
||||
// provideProcessingSuggestions provides suggestions for processing errors.
|
||||
func (ef *ErrorFormatter) provideProcessingSuggestions(err *utils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideProcessingSuggestions(err *gibidiutils.StructuredError) {
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
|
||||
switch err.Code {
|
||||
case utils.CodeProcessingCollection:
|
||||
ef.ui.printf(" • Check if the source directory exists and is readable\n")
|
||||
ef.ui.printf(" • Verify directory permissions\n")
|
||||
case utils.CodeProcessingFileRead:
|
||||
ef.ui.printf(" • Check file permissions\n")
|
||||
ef.ui.printf(" • Verify the file is not corrupted\n")
|
||||
case gibidiutils.CodeProcessingCollection:
|
||||
ef.ui.printf(" %s Check if the source directory exists and is readable\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify directory permissions\n", gibidiutils.IconBullet)
|
||||
case gibidiutils.CodeProcessingFileRead:
|
||||
ef.ui.printf(" %s Check file permissions\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify the file is not corrupted\n", gibidiutils.IconBullet)
|
||||
default:
|
||||
ef.ui.printf(" • Try reducing concurrency: -concurrency 1\n")
|
||||
ef.ui.printf(" • Check available system resources\n")
|
||||
ef.ui.printf(" %s Try reducing concurrency: -concurrency 1\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Check available system resources\n", gibidiutils.IconBullet)
|
||||
}
|
||||
}
|
||||
|
||||
// provideIOSuggestions provides suggestions for I/O errors.
|
||||
func (ef *ErrorFormatter) provideIOSuggestions(err *utils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideIOSuggestions(err *gibidiutils.StructuredError) {
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
|
||||
switch err.Code {
|
||||
case utils.CodeIOFileCreate:
|
||||
ef.ui.printf(" • Check if the destination directory exists\n")
|
||||
ef.ui.printf(" • Verify write permissions for the output file\n")
|
||||
ef.ui.printf(" • Ensure sufficient disk space\n")
|
||||
case utils.CodeIOWrite:
|
||||
ef.ui.printf(" • Check available disk space\n")
|
||||
ef.ui.printf(" • Verify write permissions\n")
|
||||
case gibidiutils.CodeIOFileCreate:
|
||||
ef.ui.printf(" %s Check if the destination directory exists\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify write permissions for the output file\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Ensure sufficient disk space\n", gibidiutils.IconBullet)
|
||||
case gibidiutils.CodeIOWrite:
|
||||
ef.ui.printf(" %s Check available disk space\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify write permissions\n", gibidiutils.IconBullet)
|
||||
default:
|
||||
ef.ui.printf(" • Check file/directory permissions\n")
|
||||
ef.ui.printf(" • Verify available disk space\n")
|
||||
ef.ui.printf(suggestionCheckPermissions, gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify available disk space\n", gibidiutils.IconBullet)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for specific suggestions
|
||||
func (ef *ErrorFormatter) suggestFileAccess(filePath string) {
|
||||
ef.ui.printf(" • Check if the path exists: %s\n", filePath)
|
||||
ef.ui.printf(" • Verify read permissions\n")
|
||||
ef.ui.printf(" %s Check if the path exists: %s\n", gibidiutils.IconBullet, filePath)
|
||||
ef.ui.printf(" %s Verify read permissions\n", gibidiutils.IconBullet)
|
||||
if filePath != "" {
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
ef.ui.printf(" • Path exists but may not be accessible\n")
|
||||
ef.ui.printf(" • Mode: %s\n", stat.Mode())
|
||||
ef.ui.printf(" %s Path exists but may not be accessible\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Mode: %s\n", gibidiutils.IconBullet, stat.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestPathResolution(filePath string) {
|
||||
ef.ui.printf(" • Use an absolute path instead of relative\n")
|
||||
ef.ui.printf(" %s Use an absolute path instead of relative\n", gibidiutils.IconBullet)
|
||||
if filePath != "" {
|
||||
if abs, err := filepath.Abs(filePath); err == nil {
|
||||
ef.ui.printf(" • Try: %s\n", abs)
|
||||
ef.ui.printf(" %s Try: %s\n", gibidiutils.IconBullet, abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestFileNotFound(filePath string) {
|
||||
ef.ui.printf(" • Check if the file/directory exists: %s\n", filePath)
|
||||
ef.ui.printf(" %s Check if the file/directory exists: %s\n", gibidiutils.IconBullet, filePath)
|
||||
if filePath != "" {
|
||||
dir := filepath.Dir(filePath)
|
||||
if entries, err := os.ReadDir(dir); err == nil {
|
||||
ef.ui.printf(" • Similar files in %s:\n", dir)
|
||||
ef.ui.printf(" %s Similar files in %s:\n", gibidiutils.IconBullet, dir)
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if count >= 3 {
|
||||
break
|
||||
}
|
||||
if strings.Contains(entry.Name(), filepath.Base(filePath)) {
|
||||
ef.ui.printf(" - %s\n", entry.Name())
|
||||
ef.ui.printf(" %s %s\n", gibidiutils.IconBullet, entry.Name())
|
||||
count++
|
||||
}
|
||||
}
|
||||
@@ -196,18 +203,18 @@ func (ef *ErrorFormatter) suggestFileNotFound(filePath string) {
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestFileSystemGeneral(filePath string) {
|
||||
ef.ui.printf(" • Check file/directory permissions\n")
|
||||
ef.ui.printf(" • Verify the path is correct\n")
|
||||
ef.ui.printf(suggestionCheckPermissions, gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify the path is correct\n", gibidiutils.IconBullet)
|
||||
if filePath != "" {
|
||||
ef.ui.printf(" • Path: %s\n", filePath)
|
||||
ef.ui.printf(" %s Path: %s\n", gibidiutils.IconBullet, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// provideDefaultSuggestions provides general suggestions.
|
||||
func (ef *ErrorFormatter) provideDefaultSuggestions() {
|
||||
ef.ui.printf(" • Check your command line arguments\n")
|
||||
ef.ui.printf(" • Run with --help for usage information\n")
|
||||
ef.ui.printf(" • Try with -concurrency 1 to reduce resource usage\n")
|
||||
ef.ui.printf(" %s Check your command line arguments\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Run with --help for usage information\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Try with -concurrency 1 to reduce resource usage\n", gibidiutils.IconBullet)
|
||||
}
|
||||
|
||||
// provideGenericSuggestions provides suggestions for generic errors.
|
||||
@@ -219,14 +226,14 @@ func (ef *ErrorFormatter) provideGenericSuggestions(err error) {
|
||||
// Pattern matching for common errors
|
||||
switch {
|
||||
case strings.Contains(errorMsg, "permission denied"):
|
||||
ef.ui.printf(" • Check file/directory permissions\n")
|
||||
ef.ui.printf(" • Try running with appropriate privileges\n")
|
||||
ef.ui.printf(suggestionCheckPermissions, gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Try running with appropriate privileges\n", gibidiutils.IconBullet)
|
||||
case strings.Contains(errorMsg, "no such file or directory"):
|
||||
ef.ui.printf(" • Verify the file/directory path is correct\n")
|
||||
ef.ui.printf(" • Check if the file exists\n")
|
||||
ef.ui.printf(" %s Verify the file/directory path is correct\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Check if the file exists\n", gibidiutils.IconBullet)
|
||||
case strings.Contains(errorMsg, "flag") && strings.Contains(errorMsg, "redefined"):
|
||||
ef.ui.printf(" • This is likely a test environment issue\n")
|
||||
ef.ui.printf(" • Try running the command directly instead of in tests\n")
|
||||
ef.ui.printf(" %s This is likely a test environment issue\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Try running the command directly instead of in tests\n", gibidiutils.IconBullet)
|
||||
default:
|
||||
ef.provideDefaultSuggestions()
|
||||
}
|
||||
@@ -234,16 +241,16 @@ func (ef *ErrorFormatter) provideGenericSuggestions(err error) {
|
||||
|
||||
// CLI-specific error types
|
||||
|
||||
// CLIMissingSourceError represents a missing source directory error.
|
||||
type CLIMissingSourceError struct{}
|
||||
// MissingSourceError represents a missing source directory error.
|
||||
type MissingSourceError struct{}
|
||||
|
||||
func (e CLIMissingSourceError) Error() string {
|
||||
func (e MissingSourceError) Error() string {
|
||||
return "source directory is required"
|
||||
}
|
||||
|
||||
// NewCLIMissingSourceError creates a new CLI missing source error with suggestions.
|
||||
func NewCLIMissingSourceError() error {
|
||||
return &CLIMissingSourceError{}
|
||||
// NewMissingSourceError creates a new CLI missing source error with suggestions.
|
||||
func NewMissingSourceError() error {
|
||||
return &MissingSourceError{}
|
||||
}
|
||||
|
||||
// IsUserError checks if an error is a user input error that should be handled gracefully.
|
||||
@@ -253,16 +260,17 @@ func IsUserError(err error) bool {
|
||||
}
|
||||
|
||||
// Check for specific user error types
|
||||
var cliErr *CLIMissingSourceError
|
||||
var cliErr *MissingSourceError
|
||||
if errors.As(err, &cliErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for structured errors that are user-facing
|
||||
if structErr, ok := err.(*utils.StructuredError); ok {
|
||||
return structErr.Type == utils.ErrorTypeValidation ||
|
||||
structErr.Code == utils.CodeValidationFormat ||
|
||||
structErr.Code == utils.CodeValidationSize
|
||||
var structErr *gibidiutils.StructuredError
|
||||
if errors.As(err, &structErr) {
|
||||
return structErr.Type == gibidiutils.ErrorTypeValidation ||
|
||||
structErr.Code == gibidiutils.CodeValidationFormat ||
|
||||
structErr.Code == gibidiutils.CodeValidationSize
|
||||
}
|
||||
|
||||
// Check error message patterns
|
||||
|
||||
963
cli/errors_test.go
Normal file
963
cli/errors_test.go
Normal file
@@ -0,0 +1,963 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
func TestNewErrorFormatter(t *testing.T) {
|
||||
ui := &UIManager{
|
||||
output: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
ef := NewErrorFormatter(ui)
|
||||
|
||||
assert.NotNil(t, ef)
|
||||
assert.Equal(t, ui, ef.ui)
|
||||
}
|
||||
|
||||
func TestFormatError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedOutput []string
|
||||
notExpected []string
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expectedOutput: []string{},
|
||||
},
|
||||
{
|
||||
name: "structured error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSNotFound,
|
||||
testErrFileNotFound,
|
||||
"/test/file.txt",
|
||||
map[string]interface{}{"size": 1024},
|
||||
),
|
||||
expectedOutput: []string{
|
||||
gibidiutils.IconError + testErrorSuffix,
|
||||
"FileSystem",
|
||||
testErrFileNotFound,
|
||||
"/test/file.txt",
|
||||
"NOT_FOUND",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic error",
|
||||
err: errors.New("something went wrong"),
|
||||
expectedOutput: []string{gibidiutils.IconError + testErrorSuffix, "something went wrong"},
|
||||
},
|
||||
{
|
||||
name: "wrapped structured error",
|
||||
err: gibidiutils.WrapError(
|
||||
errors.New("inner error"),
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationRequired,
|
||||
"validation failed",
|
||||
),
|
||||
expectedOutput: []string{
|
||||
gibidiutils.IconError + testErrorSuffix,
|
||||
"validation failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = true
|
||||
t.Cleanup(func() { color.NoColor = prev })
|
||||
|
||||
ef := NewErrorFormatter(ui)
|
||||
ef.FormatError(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, expected := range tt.expectedOutput {
|
||||
assert.Contains(t, output, expected)
|
||||
}
|
||||
for _, notExpected := range tt.notExpected {
|
||||
assert.NotContains(t, output, notExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatStructuredError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *gibidiutils.StructuredError
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "filesystem error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSPermission,
|
||||
testErrPermissionDenied,
|
||||
"/etc/shadow",
|
||||
nil,
|
||||
),
|
||||
expectedOutput: []string{
|
||||
"FileSystem",
|
||||
testErrPermissionDenied,
|
||||
"/etc/shadow",
|
||||
"PERMISSION_DENIED",
|
||||
testSuggestionsHeader,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationFormat,
|
||||
testErrInvalidFormat,
|
||||
"",
|
||||
map[string]interface{}{"format": "xml"},
|
||||
),
|
||||
expectedOutput: []string{
|
||||
"Validation",
|
||||
testErrInvalidFormat,
|
||||
"FORMAT",
|
||||
testSuggestionsHeader,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "processing error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingFileRead,
|
||||
"failed to read file",
|
||||
"large.bin",
|
||||
nil,
|
||||
),
|
||||
expectedOutput: []string{
|
||||
"Processing",
|
||||
"failed to read file",
|
||||
"large.bin",
|
||||
"FILE_READ",
|
||||
testSuggestionsHeader,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IO error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeIO,
|
||||
gibidiutils.CodeIOFileWrite,
|
||||
"disk full",
|
||||
"/output/result.txt",
|
||||
nil,
|
||||
),
|
||||
expectedOutput: []string{
|
||||
"IO",
|
||||
"disk full",
|
||||
"/output/result.txt",
|
||||
"FILE_WRITE",
|
||||
testSuggestionsHeader,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = true
|
||||
t.Cleanup(func() { color.NoColor = prev })
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.formatStructuredError(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, expected := range tt.expectedOutput {
|
||||
assert.Contains(t, output, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGenericError(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = true
|
||||
t.Cleanup(func() { color.NoColor = prev })
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.formatGenericError(errors.New("generic error message"))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, gibidiutils.IconError+testErrorSuffix)
|
||||
assert.Contains(t, output, "generic error message")
|
||||
}
|
||||
|
||||
func TestProvideSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *gibidiutils.StructuredError
|
||||
expectedSugges []string
|
||||
}{
|
||||
{
|
||||
name: "filesystem permission error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSPermission,
|
||||
testErrPermissionDenied,
|
||||
"/root/file",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestVerifyPath,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filesystem not found error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSNotFound,
|
||||
testErrFileNotFound,
|
||||
"/missing/file",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Check if the file/directory exists: /missing/file",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation format error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationFormat,
|
||||
"unsupported format",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestFormat,
|
||||
testSuggestFormatEx,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation path error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationPath,
|
||||
"invalid path",
|
||||
"../../etc",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "processing file read error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingFileRead,
|
||||
"read error",
|
||||
"corrupted.dat",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Check file permissions",
|
||||
"Verify the file is not corrupted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IO file write error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeIO,
|
||||
gibidiutils.CodeIOFileWrite,
|
||||
"write failed",
|
||||
"/output.txt",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestDiskSpace,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown error type",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeUnknown,
|
||||
"UNKNOWN",
|
||||
"unknown error",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = true
|
||||
t.Cleanup(func() { color.NoColor = prev })
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.provideSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, suggestion := range tt.expectedSugges {
|
||||
assert.Contains(t, output, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideFileSystemSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *gibidiutils.StructuredError
|
||||
expectedSugges []string
|
||||
}{
|
||||
{
|
||||
name: testErrPermissionDenied,
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSPermission,
|
||||
testErrPermissionDenied,
|
||||
"/root/secret",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestVerifyPath,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path resolution error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSPathResolution,
|
||||
"path error",
|
||||
"../../../etc",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Use an absolute path instead of relative",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testErrFileNotFound,
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.CodeFSNotFound,
|
||||
"not found",
|
||||
"/missing.txt",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Check if the file/directory exists: /missing.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default filesystem error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
"OTHER_FS_ERROR",
|
||||
testErrOther,
|
||||
"/some/path",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestVerifyPath,
|
||||
"Path: /some/path",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.provideFileSystemSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, suggestion := range tt.expectedSugges {
|
||||
assert.Contains(t, output, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideValidationSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *gibidiutils.StructuredError
|
||||
expectedSugges []string
|
||||
}{
|
||||
{
|
||||
name: "format validation",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationFormat,
|
||||
testErrInvalidFormat,
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestFormat,
|
||||
testSuggestFormatEx,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path validation",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationPath,
|
||||
"invalid path",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "size validation",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationSize,
|
||||
"size error",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Increase file size limit in config.yaml",
|
||||
"Use smaller files or exclude large files",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "required validation",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationRequired,
|
||||
"required",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default validation",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
"OTHER_VALIDATION",
|
||||
"other",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.provideValidationSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, suggestion := range tt.expectedSugges {
|
||||
assert.Contains(t, output, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideProcessingSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *gibidiutils.StructuredError
|
||||
expectedSugges []string
|
||||
}{
|
||||
{
|
||||
name: "file read error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingFileRead,
|
||||
"read error",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Check file permissions",
|
||||
"Verify the file is not corrupted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "collection error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingCollection,
|
||||
"collection error",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Check if the source directory exists and is readable",
|
||||
"Verify directory permissions",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testErrEncoding,
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingEncode,
|
||||
testErrEncoding,
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Try reducing concurrency: -concurrency 1",
|
||||
"Check available system resources",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default processing",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
"OTHER",
|
||||
testErrOther,
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Try reducing concurrency: -concurrency 1",
|
||||
"Check available system resources",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.provideProcessingSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, suggestion := range tt.expectedSugges {
|
||||
assert.Contains(t, output, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideIOSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *gibidiutils.StructuredError
|
||||
expectedSugges []string
|
||||
}{
|
||||
{
|
||||
name: "file create error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeIO,
|
||||
gibidiutils.CodeIOFileCreate,
|
||||
"create error",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
"Check if the destination directory exists",
|
||||
"Verify write permissions for the output file",
|
||||
"Ensure sufficient disk space",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file write error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeIO,
|
||||
gibidiutils.CodeIOFileWrite,
|
||||
"write error",
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestDiskSpace,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testErrEncoding,
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeIO,
|
||||
gibidiutils.CodeIOEncoding,
|
||||
testErrEncoding,
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestDiskSpace,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default IO error",
|
||||
err: gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeIO,
|
||||
"OTHER",
|
||||
testErrOther,
|
||||
"",
|
||||
nil,
|
||||
),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
testSuggestDiskSpace,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.provideIOSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, suggestion := range tt.expectedSugges {
|
||||
assert.Contains(t, output, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideGenericSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedSugges []string
|
||||
}{
|
||||
{
|
||||
name: "permission error",
|
||||
err: errors.New("permission denied accessing file"),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckPerms,
|
||||
"Try running with appropriate privileges",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not found error",
|
||||
err: errors.New("no such file or directory"),
|
||||
expectedSugges: []string{
|
||||
"Verify the file/directory path is correct",
|
||||
"Check if the file exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "memory error",
|
||||
err: errors.New("out of memory"),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
testSuggestReduceConcur,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeout error",
|
||||
err: errors.New("operation timed out"),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
testSuggestReduceConcur,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "connection error",
|
||||
err: errors.New("connection refused"),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
testSuggestReduceConcur,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default error",
|
||||
err: errors.New("unknown error occurred"),
|
||||
expectedSugges: []string{
|
||||
testSuggestCheckArgs,
|
||||
testSuggestHelp,
|
||||
testSuggestReduceConcur,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
ef.provideGenericSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
for _, suggestion := range tt.expectedSugges {
|
||||
assert.Contains(t, output, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourceError(t *testing.T) {
|
||||
err := &MissingSourceError{}
|
||||
|
||||
assert.Equal(t, "source directory is required", err.Error())
|
||||
}
|
||||
|
||||
func TestNewMissingSourceErrorType(t *testing.T) {
|
||||
err := NewMissingSourceError()
|
||||
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "source directory is required", err.Error())
|
||||
|
||||
var msErr *MissingSourceError
|
||||
ok := errors.As(err, &msErr)
|
||||
assert.True(t, ok)
|
||||
assert.NotNil(t, msErr)
|
||||
}
|
||||
|
||||
// Test error formatting with colors enabled
|
||||
func TestFormatErrorWithColors(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: true,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = false
|
||||
t.Cleanup(func() { color.NoColor = prev })
|
||||
|
||||
ef := NewErrorFormatter(ui)
|
||||
err := gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeValidationFormat,
|
||||
testErrInvalidFormat,
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
|
||||
ef.FormatError(err)
|
||||
|
||||
output := buf.String()
|
||||
// When colors are enabled, some output may go directly to stdout
|
||||
// Check for suggestions that are captured in the buffer
|
||||
assert.Contains(t, output, testSuggestFormat)
|
||||
assert.Contains(t, output, testSuggestFormatEx)
|
||||
}
|
||||
|
||||
// Test wrapped error handling
|
||||
func TestFormatWrappedError(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ef := NewErrorFormatter(ui)
|
||||
|
||||
innerErr := errors.New("inner error")
|
||||
wrappedErr := gibidiutils.WrapError(
|
||||
innerErr,
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingFileRead,
|
||||
"wrapper message",
|
||||
)
|
||||
|
||||
ef.FormatError(wrappedErr)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "wrapper message")
|
||||
}
|
||||
|
||||
// Test all suggestion paths get called
|
||||
func TestSuggestionPathCoverage(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
|
||||
// Test all error types
|
||||
errorTypes := []gibidiutils.ErrorType{
|
||||
gibidiutils.ErrorTypeFileSystem,
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.ErrorTypeIO,
|
||||
gibidiutils.ErrorTypeConfiguration,
|
||||
gibidiutils.ErrorTypeUnknown,
|
||||
}
|
||||
|
||||
for _, errType := range errorTypes {
|
||||
t.Run(errType.String(), func(t *testing.T) {
|
||||
buf.Reset()
|
||||
err := gibidiutils.NewStructuredError(
|
||||
errType,
|
||||
"TEST_CODE",
|
||||
"test error",
|
||||
"/test/path",
|
||||
nil,
|
||||
)
|
||||
ef.provideSuggestions(err)
|
||||
|
||||
output := buf.String()
|
||||
// Should have some suggestion output
|
||||
assert.NotEmpty(t, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test suggestion helper functions with various inputs
|
||||
func TestSuggestHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
testFunc func(*ErrorFormatter)
|
||||
}{
|
||||
{
|
||||
name: "suggestFileAccess",
|
||||
testFunc: func(ef *ErrorFormatter) {
|
||||
ef.suggestFileAccess("/root/file")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "suggestPathResolution",
|
||||
testFunc: func(ef *ErrorFormatter) {
|
||||
ef.suggestPathResolution("../../../etc")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "suggestFileNotFound",
|
||||
testFunc: func(ef *ErrorFormatter) {
|
||||
ef.suggestFileNotFound("/missing")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "suggestFileSystemGeneral",
|
||||
testFunc: func(ef *ErrorFormatter) {
|
||||
ef.suggestFileSystemGeneral("/path")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provideDefaultSuggestions",
|
||||
testFunc: func(ef *ErrorFormatter) {
|
||||
ef.provideDefaultSuggestions()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
|
||||
tt.testFunc(ef)
|
||||
|
||||
output := buf.String()
|
||||
// Each should produce some output
|
||||
assert.NotEmpty(t, output)
|
||||
// Should contain bullet point
|
||||
assert.Contains(t, output, gibidiutils.IconBullet)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test edge cases in error message analysis
|
||||
func TestGenericSuggestionsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"empty message", errors.New("")},
|
||||
{"very long message", errors.New(strings.Repeat("error ", 100))},
|
||||
{"special characters", errors.New("error!@#$%^&*()")},
|
||||
{"newlines", errors.New("error\nwith\nnewlines")},
|
||||
{"unicode", errors.New("error with 中文 characters")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
ef := &ErrorFormatter{ui: ui}
|
||||
|
||||
// Should not panic
|
||||
ef.provideGenericSuggestions(tt.err)
|
||||
|
||||
output := buf.String()
|
||||
// Should have some output
|
||||
assert.NotEmpty(t, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
28
cli/flags.go
28
cli/flags.go
@@ -5,7 +5,7 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/utils"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
// Flags holds CLI flags values.
|
||||
@@ -39,8 +39,10 @@ func ParseFlags() (*Flags, error) {
|
||||
flag.StringVar(&flags.Prefix, "prefix", "", "Text to add at the beginning of the output file")
|
||||
flag.StringVar(&flags.Suffix, "suffix", "", "Text to add at the end of the output file")
|
||||
flag.StringVar(&flags.Format, "format", "markdown", "Output format (json, markdown, yaml)")
|
||||
flag.IntVar(&flags.Concurrency, "concurrency", runtime.NumCPU(),
|
||||
"Number of concurrent workers (default: number of CPU cores)")
|
||||
flag.IntVar(
|
||||
&flags.Concurrency, "concurrency", runtime.NumCPU(),
|
||||
"Number of concurrent workers (default: number of CPU cores)",
|
||||
)
|
||||
flag.BoolVar(&flags.NoColors, "no-colors", false, "Disable colored output")
|
||||
flag.BoolVar(&flags.NoProgress, "no-progress", false, "Disable progress bars")
|
||||
flag.BoolVar(&flags.Verbose, "verbose", false, "Enable verbose output")
|
||||
@@ -63,11 +65,11 @@ func ParseFlags() (*Flags, error) {
|
||||
// validate validates the CLI flags.
|
||||
func (f *Flags) validate() error {
|
||||
if f.SourceDir == "" {
|
||||
return NewCLIMissingSourceError()
|
||||
return NewMissingSourceError()
|
||||
}
|
||||
|
||||
// Validate source path for security
|
||||
if err := utils.ValidateSourcePath(f.SourceDir); err != nil {
|
||||
if err := gibidiutils.ValidateSourcePath(f.SourceDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,28 +79,20 @@ func (f *Flags) validate() error {
|
||||
}
|
||||
|
||||
// Validate concurrency
|
||||
if err := config.ValidateConcurrency(f.Concurrency); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return config.ValidateConcurrency(f.Concurrency)
|
||||
}
|
||||
|
||||
// setDefaultDestination sets the default destination if not provided.
|
||||
func (f *Flags) setDefaultDestination() error {
|
||||
if f.Destination == "" {
|
||||
absRoot, err := utils.GetAbsolutePath(f.SourceDir)
|
||||
absRoot, err := gibidiutils.GetAbsolutePath(f.SourceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseName := utils.GetBaseName(absRoot)
|
||||
baseName := gibidiutils.GetBaseName(absRoot)
|
||||
f.Destination = baseName + "." + f.Format
|
||||
}
|
||||
|
||||
// Validate destination path for security
|
||||
if err := utils.ValidateDestinationPath(f.Destination); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return gibidiutils.ValidateDestinationPath(f.Destination)
|
||||
}
|
||||
|
||||
366
cli/flags_test.go
Normal file
366
cli/flags_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseFlags(t *testing.T) {
|
||||
// Save original command line args and restore after test
|
||||
oldArgs := os.Args
|
||||
oldFlagsParsed := flagsParsed
|
||||
defer func() {
|
||||
os.Args = oldArgs
|
||||
flagsParsed = oldFlagsParsed
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedError string
|
||||
validate func(t *testing.T, f *Flags)
|
||||
setup func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "valid flags with all options",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
"-destination", "output.md",
|
||||
"-format", "json",
|
||||
testFlagConcurrency, "4",
|
||||
"-prefix", "prefix",
|
||||
"-suffix", "suffix",
|
||||
"-no-colors",
|
||||
"-no-progress",
|
||||
"-verbose",
|
||||
},
|
||||
validate: nil, // set in test body using closure
|
||||
},
|
||||
{
|
||||
name: "missing source directory",
|
||||
args: []string{"gibidify"},
|
||||
expectedError: testErrSourceRequired,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
"-format", "invalid",
|
||||
},
|
||||
expectedError: "unsupported output format: invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid concurrency (zero)",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
testFlagConcurrency, "0",
|
||||
},
|
||||
expectedError: "concurrency (0) must be at least 1",
|
||||
},
|
||||
{
|
||||
name: "invalid concurrency (too high)",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
testFlagConcurrency, "200",
|
||||
},
|
||||
// Set maxConcurrency so the upper bound is enforced
|
||||
expectedError: "concurrency (200) exceeds maximum (128)",
|
||||
setup: func(t *testing.T) {
|
||||
orig := viper.Get("maxConcurrency")
|
||||
viper.Set("maxConcurrency", 128)
|
||||
t.Cleanup(func() { viper.Set("maxConcurrency", orig) })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path traversal in source",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, testPathTraversalPath,
|
||||
},
|
||||
expectedError: testErrPathTraversal,
|
||||
},
|
||||
{
|
||||
name: "default values",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
},
|
||||
validate: nil, // set in test body using closure
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset flags for each test
|
||||
flagsParsed = false
|
||||
globalFlags = nil
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
|
||||
// Create a local copy of args to avoid corrupting shared test data
|
||||
args := append([]string{}, tt.args...)
|
||||
|
||||
// Use t.TempDir for source directory if needed
|
||||
tempDir := ""
|
||||
for i := range args {
|
||||
if i > 0 && args[i-1] == testFlagSource && args[i] == "" {
|
||||
tempDir = t.TempDir()
|
||||
args[i] = tempDir
|
||||
}
|
||||
}
|
||||
os.Args = args
|
||||
|
||||
// Set validate closure if needed (for tempDir)
|
||||
if tt.name == "valid flags with all options" {
|
||||
tt.validate = func(t *testing.T, f *Flags) {
|
||||
assert.Equal(t, tempDir, f.SourceDir)
|
||||
assert.Equal(t, "output.md", f.Destination)
|
||||
assert.Equal(t, "json", f.Format)
|
||||
assert.Equal(t, 4, f.Concurrency)
|
||||
assert.Equal(t, "prefix", f.Prefix)
|
||||
assert.Equal(t, "suffix", f.Suffix)
|
||||
assert.True(t, f.NoColors)
|
||||
assert.True(t, f.NoProgress)
|
||||
assert.True(t, f.Verbose)
|
||||
}
|
||||
}
|
||||
if tt.name == "default values" {
|
||||
tt.validate = func(t *testing.T, f *Flags) {
|
||||
assert.Equal(t, tempDir, f.SourceDir)
|
||||
assert.Equal(t, "markdown", f.Format)
|
||||
assert.Equal(t, runtime.NumCPU(), f.Concurrency)
|
||||
assert.Equal(t, "", f.Prefix)
|
||||
assert.Equal(t, "", f.Suffix)
|
||||
assert.False(t, f.NoColors)
|
||||
assert.False(t, f.NoProgress)
|
||||
assert.False(t, f.Verbose)
|
||||
// Destination should be set by setDefaultDestination
|
||||
assert.NotEmpty(t, f.Destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Call setup if present (e.g. for maxConcurrency)
|
||||
if tt.setup != nil {
|
||||
tt.setup(t)
|
||||
}
|
||||
|
||||
flags, err := ParseFlags()
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
}
|
||||
assert.Nil(t, flags)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, flags)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, flags)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *Flags
|
||||
setupFunc func(t *testing.T, f *Flags)
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "missing source directory",
|
||||
flags: &Flags{},
|
||||
expectedError: testErrSourceRequired,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
flags: &Flags{
|
||||
Format: "invalid",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedError: "unsupported output format: invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid concurrency",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Concurrency: 0,
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedError: "concurrency (0) must be at least 1",
|
||||
},
|
||||
{
|
||||
name: "path traversal attempt",
|
||||
flags: &Flags{
|
||||
SourceDir: testPathTraversalPath,
|
||||
Format: "markdown",
|
||||
},
|
||||
expectedError: testErrPathTraversal,
|
||||
},
|
||||
{
|
||||
name: "valid flags",
|
||||
flags: &Flags{
|
||||
Format: "json",
|
||||
Concurrency: 4,
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, tt.flags)
|
||||
}
|
||||
|
||||
err := tt.flags.validate()
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultDestination(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *Flags
|
||||
setupFunc func(t *testing.T, f *Flags)
|
||||
expectedDest string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "default destination for directory",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedDest: "", // will check suffix below
|
||||
},
|
||||
{
|
||||
name: "default destination for json format",
|
||||
flags: &Flags{
|
||||
Format: "json",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedDest: "", // will check suffix below
|
||||
},
|
||||
{
|
||||
name: "provided destination unchanged",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Destination: "custom-output.txt",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedDest: "custom-output.txt",
|
||||
},
|
||||
{
|
||||
name: "path traversal in destination",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Destination: testPathTraversalPath,
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedError: testErrPathTraversal,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, tt.flags)
|
||||
}
|
||||
|
||||
err := tt.flags.setDefaultDestination()
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
switch {
|
||||
case tt.expectedDest != "":
|
||||
assert.Equal(t, tt.expectedDest, tt.flags.Destination)
|
||||
case tt.flags.Format == "json":
|
||||
assert.True(
|
||||
t, strings.HasSuffix(tt.flags.Destination, ".json"),
|
||||
"expected %q to have suffix .json", tt.flags.Destination,
|
||||
)
|
||||
case tt.flags.Format == "markdown":
|
||||
assert.True(
|
||||
t, strings.HasSuffix(tt.flags.Destination, ".markdown"),
|
||||
"expected %q to have suffix .markdown", tt.flags.Destination,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsSingleton(t *testing.T) {
|
||||
// Save original state
|
||||
oldFlagsParsed := flagsParsed
|
||||
oldGlobalFlags := globalFlags
|
||||
defer func() {
|
||||
flagsParsed = oldFlagsParsed
|
||||
globalFlags = oldGlobalFlags
|
||||
}()
|
||||
|
||||
// Test singleton behavior
|
||||
flagsParsed = true
|
||||
expectedFlags := &Flags{
|
||||
SourceDir: "/test",
|
||||
Format: "json",
|
||||
Concurrency: 2,
|
||||
}
|
||||
globalFlags = expectedFlags
|
||||
|
||||
// Should return cached flags without parsing
|
||||
flags, err := ParseFlags()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedFlags, flags)
|
||||
assert.Same(t, globalFlags, flags)
|
||||
}
|
||||
|
||||
func TestNewMissingSourceError(t *testing.T) {
|
||||
err := NewMissingSourceError()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, testErrSourceRequired, err.Error())
|
||||
|
||||
// Check if it's the right type
|
||||
var missingSourceError *MissingSourceError
|
||||
ok := errors.As(err, &missingSourceError)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
@@ -8,14 +8,19 @@ import (
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/utils"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
// collectFiles collects all files to be processed.
|
||||
func (p *Processor) collectFiles() ([]string, error) {
|
||||
files, err := fileproc.CollectFiles(p.flags.SourceDir)
|
||||
if err != nil {
|
||||
return nil, utils.WrapError(err, utils.ErrorTypeProcessing, utils.CodeProcessingCollection, "error collecting files")
|
||||
return nil, gibidiutils.WrapError(
|
||||
err,
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingCollection,
|
||||
"error collecting files",
|
||||
)
|
||||
}
|
||||
logrus.Infof("Found %d files to process", len(files))
|
||||
return files, nil
|
||||
@@ -30,9 +35,9 @@ func (p *Processor) validateFileCollection(files []string) error {
|
||||
// Check file count limit
|
||||
maxFiles := config.GetMaxFiles()
|
||||
if len(files) > maxFiles {
|
||||
return utils.NewStructuredError(
|
||||
utils.ErrorTypeValidation,
|
||||
utils.CodeResourceLimitFiles,
|
||||
return gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeResourceLimitFiles,
|
||||
fmt.Sprintf("file count (%d) exceeds maximum limit (%d)", len(files), maxFiles),
|
||||
"",
|
||||
map[string]interface{}{
|
||||
@@ -51,10 +56,14 @@ func (p *Processor) validateFileCollection(files []string) error {
|
||||
if fileInfo, err := os.Stat(filePath); err == nil {
|
||||
totalSize += fileInfo.Size()
|
||||
if totalSize > maxTotalSize {
|
||||
return utils.NewStructuredError(
|
||||
utils.ErrorTypeValidation,
|
||||
utils.CodeResourceLimitTotalSize,
|
||||
fmt.Sprintf("total file size (%d bytes) would exceed maximum limit (%d bytes)", totalSize, maxTotalSize),
|
||||
return gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeResourceLimitTotalSize,
|
||||
fmt.Sprintf(
|
||||
"total file size (%d bytes) would exceed maximum limit (%d bytes)",
|
||||
totalSize,
|
||||
maxTotalSize,
|
||||
),
|
||||
"",
|
||||
map[string]interface{}{
|
||||
"total_size": totalSize,
|
||||
@@ -74,4 +83,4 @@ func (p *Processor) validateFileCollection(files []string) error {
|
||||
|
||||
logrus.Infof("Pre-validation passed: %d files, %d MB total", len(files), totalSize/1024/1024)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/utils"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
// Process executes the main file processing workflow.
|
||||
@@ -16,7 +16,9 @@ func (p *Processor) Process(ctx context.Context) error {
|
||||
defer overallCancel()
|
||||
|
||||
// Configure file type registry
|
||||
p.configureFileTypes()
|
||||
if err := p.configureFileTypes(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print startup info with colors
|
||||
p.ui.PrintHeader("🚀 Starting gibidify")
|
||||
@@ -55,7 +57,7 @@ func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
utils.LogError("Error closing output file", outFile.Close())
|
||||
gibidiutils.LogError("Error closing output file", outFile.Close())
|
||||
}()
|
||||
|
||||
// Initialize back-pressure and channels
|
||||
@@ -65,7 +67,11 @@ func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
writerDone := make(chan struct{})
|
||||
|
||||
// Start writer
|
||||
go fileproc.StartWriter(outFile, writeCh, writerDone, p.flags.Format, p.flags.Prefix, p.flags.Suffix)
|
||||
go fileproc.StartWriter(outFile, writeCh, writerDone, fileproc.WriterConfig{
|
||||
Format: p.flags.Format,
|
||||
Prefix: p.flags.Prefix,
|
||||
Suffix: p.flags.Suffix,
|
||||
})
|
||||
|
||||
// Start workers
|
||||
var wg sync.WaitGroup
|
||||
@@ -92,9 +98,13 @@ func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
// createOutputFile creates the output file.
|
||||
func (p *Processor) createOutputFile() (*os.File, error) {
|
||||
// Destination path has been validated in CLI flags validation for path traversal attempts
|
||||
outFile, err := os.Create(p.flags.Destination) // #nosec G304 - destination is validated in flags.validate()
|
||||
// #nosec G304 - destination is validated in flags.validate()
|
||||
outFile, err := os.Create(p.flags.Destination)
|
||||
if err != nil {
|
||||
return nil, utils.WrapError(err, utils.ErrorTypeIO, utils.CodeIOFileCreate, "failed to create output file").WithFilePath(p.flags.Destination)
|
||||
return nil, gibidiutils.WrapError(
|
||||
err, gibidiutils.ErrorTypeIO, gibidiutils.CodeIOFileCreate,
|
||||
"failed to create output file",
|
||||
).WithFilePath(p.flags.Destination)
|
||||
}
|
||||
return outFile, nil
|
||||
}
|
||||
}
|
||||
|
||||
265
cli/processor_simple_test.go
Normal file
265
cli/processor_simple_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
)
|
||||
|
||||
func TestProcessorSimple(t *testing.T) {
|
||||
t.Run("NewProcessor", func(t *testing.T) {
|
||||
flags := &Flags{
|
||||
SourceDir: "/tmp/test",
|
||||
Destination: "output.md",
|
||||
Format: "markdown",
|
||||
Concurrency: 2,
|
||||
NoColors: true,
|
||||
NoProgress: true,
|
||||
Verbose: false,
|
||||
}
|
||||
|
||||
p := NewProcessor(flags)
|
||||
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, flags, p.flags)
|
||||
assert.NotNil(t, p.ui)
|
||||
assert.NotNil(t, p.backpressure)
|
||||
assert.NotNil(t, p.resourceMonitor)
|
||||
assert.False(t, p.ui.enableColors)
|
||||
assert.False(t, p.ui.enableProgress)
|
||||
})
|
||||
|
||||
t.Run("ConfigureFileTypes", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
flags: &Flags{},
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
// Should not panic or error
|
||||
err := p.configureFileTypes()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
})
|
||||
|
||||
t.Run("CreateOutputFile", func(t *testing.T) {
|
||||
// Create temp file path
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "output.txt")
|
||||
|
||||
p := &Processor{
|
||||
flags: &Flags{
|
||||
Destination: outputPath,
|
||||
},
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
file, err := p.createOutputFile()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, file)
|
||||
|
||||
// Clean up
|
||||
err = file.Close()
|
||||
require.NoError(t, err)
|
||||
err = os.Remove(outputPath)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("ValidateFileCollection", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
// Empty collection should be valid (just checks limits)
|
||||
err := p.validateFileCollection([]string{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Small collection should be valid
|
||||
err = p.validateFileCollection([]string{
|
||||
testFilePath1,
|
||||
testFilePath2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("CollectFiles_EmptyDir", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
p := &Processor{
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
},
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
files, err := p.collectFiles()
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
})
|
||||
|
||||
t.Run("CollectFiles_WithFiles", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "test1.go"), []byte("package main"), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "test2.go"), []byte("package test"), 0o600))
|
||||
|
||||
// Set config so no files are ignored, and restore after test
|
||||
origIgnoreDirs := viper.Get("ignoreDirectories")
|
||||
origFileSizeLimit := viper.Get("fileSizeLimit")
|
||||
viper.Set("ignoreDirectories", []string{})
|
||||
viper.Set("fileSizeLimit", 1024*1024*10) // 10MB
|
||||
t.Cleanup(func() {
|
||||
viper.Set("ignoreDirectories", origIgnoreDirs)
|
||||
viper.Set("fileSizeLimit", origFileSizeLimit)
|
||||
})
|
||||
|
||||
p := &Processor{
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
},
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
files, err := p.collectFiles()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("SendFiles", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
backpressure: fileproc.NewBackpressureManager(),
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
fileCh := make(chan string, 3)
|
||||
files := []string{
|
||||
testFilePath1,
|
||||
testFilePath2,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
// Send files in a goroutine since it might block
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := p.sendFiles(ctx, files, fileCh)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Read all files from channel
|
||||
var received []string
|
||||
for i := 0; i < len(files); i++ {
|
||||
file := <-fileCh
|
||||
received = append(received, file)
|
||||
}
|
||||
|
||||
assert.Equal(t, len(files), len(received))
|
||||
|
||||
// Wait for sendFiles goroutine to finish (and close fileCh)
|
||||
wg.Wait()
|
||||
|
||||
// Now channel should be closed
|
||||
_, ok := <-fileCh
|
||||
assert.False(t, ok, "channel should be closed")
|
||||
})
|
||||
|
||||
t.Run("WaitForCompletion", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest)
|
||||
writerDone := make(chan struct{})
|
||||
|
||||
// Simulate writer finishing
|
||||
go func() {
|
||||
<-writeCh // Wait for close
|
||||
close(writerDone)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// Start and finish immediately
|
||||
wg.Add(1)
|
||||
wg.Done()
|
||||
|
||||
// Should complete without hanging
|
||||
p.waitForCompletion(&wg, writeCh, writerDone)
|
||||
assert.NotNil(t, p)
|
||||
})
|
||||
|
||||
t.Run("LogFinalStats", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
flags: &Flags{
|
||||
Verbose: true,
|
||||
},
|
||||
ui: NewUIManager(),
|
||||
resourceMonitor: fileproc.NewResourceMonitor(),
|
||||
backpressure: fileproc.NewBackpressureManager(),
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
p.logFinalStats()
|
||||
assert.NotNil(t, p)
|
||||
})
|
||||
}
|
||||
|
||||
// Test error handling scenarios
|
||||
func TestProcessorErrors(t *testing.T) {
|
||||
t.Run("CreateOutputFile_InvalidPath", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
flags: &Flags{
|
||||
Destination: "/root/cannot-write-here.txt",
|
||||
},
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
file, err := p.createOutputFile()
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, file)
|
||||
})
|
||||
|
||||
t.Run("CollectFiles_NonExistentDir", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
flags: &Flags{
|
||||
SourceDir: "/non/existent/path",
|
||||
},
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
files, err := p.collectFiles()
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, files)
|
||||
})
|
||||
|
||||
t.Run("SendFiles_WithCancellation", func(t *testing.T) {
|
||||
p := &Processor{
|
||||
backpressure: fileproc.NewBackpressureManager(),
|
||||
ui: NewUIManager(),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
fileCh := make(chan string) // Unbuffered to force blocking
|
||||
|
||||
files := []string{
|
||||
testFilePath1,
|
||||
testFilePath2,
|
||||
"/test/file3.go",
|
||||
}
|
||||
|
||||
// Cancel immediately
|
||||
cancel()
|
||||
|
||||
err := p.sendFiles(ctx, files, fileCh)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
})
|
||||
}
|
||||
@@ -11,8 +11,12 @@ func (p *Processor) logFinalStats() {
|
||||
// Log back-pressure stats
|
||||
backpressureStats := p.backpressure.GetStats()
|
||||
if backpressureStats.Enabled {
|
||||
logrus.Infof("Back-pressure stats: processed=%d files, memory=%dMB/%dMB",
|
||||
backpressureStats.FilesProcessed, backpressureStats.CurrentMemoryUsage/1024/1024, backpressureStats.MaxMemoryUsage/1024/1024)
|
||||
logrus.Infof(
|
||||
"Back-pressure stats: processed=%d files, memory=%dMB/%dMB",
|
||||
backpressureStats.FilesProcessed,
|
||||
backpressureStats.CurrentMemoryUsage/1024/1024,
|
||||
backpressureStats.MaxMemoryUsage/1024/1024,
|
||||
)
|
||||
}
|
||||
|
||||
// Log resource monitoring stats
|
||||
@@ -37,4 +41,4 @@ func (p *Processor) logFinalStats() {
|
||||
|
||||
// Clean up resource monitor
|
||||
p.resourceMonitor.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,18 @@ func NewProcessor(flags *Flags) *Processor {
|
||||
}
|
||||
|
||||
// configureFileTypes configures the file type registry.
|
||||
func (p *Processor) configureFileTypes() {
|
||||
func (p *Processor) configureFileTypes() error {
|
||||
if config.GetFileTypesEnabled() {
|
||||
fileproc.ConfigureFromSettings(
|
||||
config.GetCustomImageExtensions(),
|
||||
config.GetCustomBinaryExtensions(),
|
||||
config.GetCustomLanguages(),
|
||||
config.GetDisabledImageExtensions(),
|
||||
config.GetDisabledBinaryExtensions(),
|
||||
config.GetDisabledLanguageExtensions(),
|
||||
)
|
||||
if err := fileproc.ConfigureFromSettings(fileproc.RegistryConfig{
|
||||
CustomImages: config.GetCustomImageExtensions(),
|
||||
CustomBinary: config.GetCustomBinaryExtensions(),
|
||||
CustomLanguages: config.GetCustomLanguages(),
|
||||
DisabledImages: config.GetDisabledImageExtensions(),
|
||||
DisabledBinary: config.GetDisabledBinaryExtensions(),
|
||||
DisabledLanguages: config.GetDisabledLanguageExtensions(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,11 +7,16 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/utils"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
// startWorkers starts the worker goroutines.
|
||||
func (p *Processor) startWorkers(ctx context.Context, wg *sync.WaitGroup, fileCh chan string, writeCh chan fileproc.WriteRequest) {
|
||||
func (p *Processor) startWorkers(
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
fileCh chan string,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
) {
|
||||
for range p.flags.Concurrency {
|
||||
wg.Add(1)
|
||||
go p.worker(ctx, wg, fileCh, writeCh)
|
||||
@@ -19,7 +24,12 @@ func (p *Processor) startWorkers(ctx context.Context, wg *sync.WaitGroup, fileCh
|
||||
}
|
||||
|
||||
// worker is the worker goroutine function.
|
||||
func (p *Processor) worker(ctx context.Context, wg *sync.WaitGroup, fileCh chan string, writeCh chan fileproc.WriteRequest) {
|
||||
func (p *Processor) worker(
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
fileCh chan string,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
@@ -42,9 +52,9 @@ func (p *Processor) processFile(ctx context.Context, filePath string, writeCh ch
|
||||
return
|
||||
}
|
||||
|
||||
absRoot, err := utils.GetAbsolutePath(p.flags.SourceDir)
|
||||
absRoot, err := gibidiutils.GetAbsolutePath(p.flags.SourceDir)
|
||||
if err != nil {
|
||||
utils.LogError("Failed to get absolute path", err)
|
||||
gibidiutils.LogError("Failed to get absolute path", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,8 +88,12 @@ func (p *Processor) sendFiles(ctx context.Context, files []string, fileCh chan s
|
||||
}
|
||||
|
||||
// waitForCompletion waits for all workers to complete.
|
||||
func (p *Processor) waitForCompletion(wg *sync.WaitGroup, writeCh chan fileproc.WriteRequest, writerDone chan struct{}) {
|
||||
func (p *Processor) waitForCompletion(
|
||||
wg *sync.WaitGroup,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
writerDone chan struct{},
|
||||
) {
|
||||
wg.Wait()
|
||||
close(writeCh)
|
||||
<-writerDone
|
||||
}
|
||||
}
|
||||
|
||||
68
cli/terminal_test_helpers.go
Normal file
68
cli/terminal_test_helpers.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
// terminalEnvSetup defines environment variables for terminal detection tests.
|
||||
type terminalEnvSetup struct {
|
||||
Term string
|
||||
CI string
|
||||
GitHubActions string
|
||||
NoColor string
|
||||
ForceColor string
|
||||
}
|
||||
|
||||
// apply sets up the environment variables using t.Setenv.
|
||||
func (e terminalEnvSetup) apply(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Always set all environment variables to ensure isolation
|
||||
// Empty string explicitly unsets the variable in the test environment
|
||||
t.Setenv("TERM", e.Term)
|
||||
t.Setenv("CI", e.CI)
|
||||
t.Setenv("GITHUB_ACTIONS", e.GitHubActions)
|
||||
t.Setenv("NO_COLOR", e.NoColor)
|
||||
t.Setenv("FORCE_COLOR", e.ForceColor)
|
||||
}
|
||||
|
||||
// Common terminal environment setups for reuse across tests.
|
||||
var (
|
||||
envDefaultTerminal = terminalEnvSetup{
|
||||
Term: "xterm-256color",
|
||||
CI: "",
|
||||
NoColor: "",
|
||||
ForceColor: "",
|
||||
}
|
||||
|
||||
envDumbTerminal = terminalEnvSetup{
|
||||
Term: "dumb",
|
||||
}
|
||||
|
||||
envCIWithoutGitHub = terminalEnvSetup{
|
||||
Term: "xterm",
|
||||
CI: "true",
|
||||
GitHubActions: "",
|
||||
}
|
||||
|
||||
envGitHubActions = terminalEnvSetup{
|
||||
Term: "xterm",
|
||||
CI: "true",
|
||||
GitHubActions: "true",
|
||||
NoColor: "",
|
||||
}
|
||||
|
||||
envNoColor = terminalEnvSetup{
|
||||
Term: "xterm-256color",
|
||||
CI: "",
|
||||
NoColor: "1",
|
||||
ForceColor: "",
|
||||
}
|
||||
|
||||
envForceColor = terminalEnvSetup{
|
||||
Term: "dumb",
|
||||
ForceColor: "1",
|
||||
}
|
||||
|
||||
envEmptyTerm = terminalEnvSetup{
|
||||
Term: "",
|
||||
}
|
||||
)
|
||||
42
cli/test_constants.go
Normal file
42
cli/test_constants.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cli
|
||||
|
||||
// Test constants to avoid duplication in test files.
|
||||
// These constants are used across multiple test files in the cli package.
|
||||
const (
|
||||
// Error messages
|
||||
testErrFileNotFound = "file not found"
|
||||
testErrPermissionDenied = "permission denied"
|
||||
testErrInvalidFormat = "invalid format"
|
||||
testErrOther = "other error"
|
||||
testErrEncoding = "encoding error"
|
||||
testErrSourceRequired = "source directory is required"
|
||||
testErrPathTraversal = "path traversal attempt detected"
|
||||
testPathTraversalPath = "../../../etc/passwd"
|
||||
|
||||
// Suggestion messages
|
||||
testSuggestionsHeader = "Suggestions:"
|
||||
testSuggestCheckPerms = "Check file/directory permissions"
|
||||
testSuggestVerifyPath = "Verify the path is correct"
|
||||
testSuggestFormat = "Use a supported format: markdown, json, yaml"
|
||||
testSuggestFormatEx = "Example: -format markdown"
|
||||
testSuggestCheckArgs = "Check your command line arguments"
|
||||
testSuggestHelp = "Run with --help for usage information"
|
||||
testSuggestDiskSpace = "Verify available disk space"
|
||||
testSuggestReduceConcur = "Try with -concurrency 1 to reduce resource usage"
|
||||
|
||||
// UI test strings
|
||||
testWithColors = "with colors"
|
||||
testWithoutColors = "without colors"
|
||||
testProcessingMsg = "Processing files"
|
||||
|
||||
// Flag names
|
||||
testFlagSource = "-source"
|
||||
testFlagConcurrency = "-concurrency"
|
||||
|
||||
// Test file paths
|
||||
testFilePath1 = "/test/file1.go"
|
||||
testFilePath2 = "/test/file2.go"
|
||||
|
||||
// Output markers
|
||||
testErrorSuffix = " Error"
|
||||
)
|
||||
100
cli/ui.go
100
cli/ui.go
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
// UIManager handles CLI user interface elements.
|
||||
@@ -44,23 +46,40 @@ func (ui *UIManager) StartProgress(total int, description string) {
|
||||
return
|
||||
}
|
||||
|
||||
ui.progressBar = progressbar.NewOptions(total,
|
||||
progressbar.OptionSetWriter(ui.output),
|
||||
progressbar.OptionSetDescription(description),
|
||||
progressbar.OptionSetTheme(progressbar.Theme{
|
||||
// Set progress bar theme based on color support
|
||||
var theme progressbar.Theme
|
||||
if ui.enableColors {
|
||||
theme = progressbar.Theme{
|
||||
Saucer: color.GreenString("█"),
|
||||
SaucerHead: color.GreenString("█"),
|
||||
SaucerPadding: " ",
|
||||
BarStart: "[",
|
||||
BarEnd: "]",
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
theme = progressbar.Theme{
|
||||
Saucer: "█",
|
||||
SaucerHead: "█",
|
||||
SaucerPadding: " ",
|
||||
BarStart: "[",
|
||||
BarEnd: "]",
|
||||
}
|
||||
}
|
||||
|
||||
ui.progressBar = progressbar.NewOptions(
|
||||
total,
|
||||
progressbar.OptionSetWriter(ui.output),
|
||||
progressbar.OptionSetDescription(description),
|
||||
progressbar.OptionSetTheme(theme),
|
||||
progressbar.OptionShowCount(),
|
||||
progressbar.OptionShowIts(),
|
||||
progressbar.OptionSetWidth(40),
|
||||
progressbar.OptionThrottle(100*time.Millisecond),
|
||||
progressbar.OptionOnCompletion(func() {
|
||||
_, _ = fmt.Fprint(ui.output, "\n")
|
||||
}),
|
||||
progressbar.OptionOnCompletion(
|
||||
func() {
|
||||
_, _ = fmt.Fprint(ui.output, "\n")
|
||||
},
|
||||
),
|
||||
progressbar.OptionSetRenderBlankState(true),
|
||||
)
|
||||
}
|
||||
@@ -80,40 +99,44 @@ func (ui *UIManager) FinishProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
// PrintSuccess prints a success message in green.
|
||||
// writeMessage writes a formatted message with optional colorization.
|
||||
// It handles color enablement, formatting, writing to output, and error logging.
|
||||
func (ui *UIManager) writeMessage(
|
||||
icon, methodName, format string,
|
||||
colorFunc func(string, ...interface{}) string,
|
||||
args ...interface{},
|
||||
) {
|
||||
msg := icon + " " + format
|
||||
var output string
|
||||
if ui.enableColors && colorFunc != nil {
|
||||
output = colorFunc(msg, args...)
|
||||
} else {
|
||||
output = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(ui.output, "%s\n", output); err != nil {
|
||||
gibidiutils.LogError(fmt.Sprintf("UIManager.%s: failed to write to output", methodName), err)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintSuccess prints a success message in green (to ui.output if set).
|
||||
func (ui *UIManager) PrintSuccess(format string, args ...interface{}) {
|
||||
if ui.enableColors {
|
||||
color.Green("✓ "+format, args...)
|
||||
} else {
|
||||
ui.printf("✓ "+format+"\n", args...)
|
||||
}
|
||||
ui.writeMessage(gibidiutils.IconSuccess, "PrintSuccess", format, color.GreenString, args...)
|
||||
}
|
||||
|
||||
// PrintError prints an error message in red.
|
||||
// PrintError prints an error message in red (to ui.output if set).
|
||||
func (ui *UIManager) PrintError(format string, args ...interface{}) {
|
||||
if ui.enableColors {
|
||||
color.Red("✗ "+format, args...)
|
||||
} else {
|
||||
ui.printf("✗ "+format+"\n", args...)
|
||||
}
|
||||
ui.writeMessage(gibidiutils.IconError, "PrintError", format, color.RedString, args...)
|
||||
}
|
||||
|
||||
// PrintWarning prints a warning message in yellow.
|
||||
// PrintWarning prints a warning message in yellow (to ui.output if set).
|
||||
func (ui *UIManager) PrintWarning(format string, args ...interface{}) {
|
||||
if ui.enableColors {
|
||||
color.Yellow("⚠ "+format, args...)
|
||||
} else {
|
||||
ui.printf("⚠ "+format+"\n", args...)
|
||||
}
|
||||
ui.writeMessage(gibidiutils.IconWarning, "PrintWarning", format, color.YellowString, args...)
|
||||
}
|
||||
|
||||
// PrintInfo prints an info message in blue.
|
||||
// PrintInfo prints an info message in blue (to ui.output if set).
|
||||
func (ui *UIManager) PrintInfo(format string, args ...interface{}) {
|
||||
if ui.enableColors {
|
||||
color.Blue("ℹ "+format, args...)
|
||||
} else {
|
||||
ui.printf("ℹ "+format+"\n", args...)
|
||||
}
|
||||
ui.writeMessage(gibidiutils.IconInfo, "PrintInfo", format, color.BlueString, args...)
|
||||
}
|
||||
|
||||
// PrintHeader prints a header message in bold.
|
||||
@@ -127,6 +150,11 @@ func (ui *UIManager) PrintHeader(format string, args ...interface{}) {
|
||||
|
||||
// isColorTerminal checks if the terminal supports colors.
|
||||
func isColorTerminal() bool {
|
||||
// Check if FORCE_COLOR is set
|
||||
if os.Getenv("FORCE_COLOR") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check common environment variables
|
||||
term := os.Getenv("TERM")
|
||||
if term == "" || term == "dumb" {
|
||||
@@ -148,13 +176,7 @@ func isColorTerminal() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if FORCE_COLOR is set
|
||||
if os.Getenv("FORCE_COLOR") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to true for interactive terminals
|
||||
return isInteractiveTerminal()
|
||||
return true
|
||||
}
|
||||
|
||||
// isInteractiveTerminal checks if we're running in an interactive terminal.
|
||||
|
||||
109
cli/ui_manager_test.go
Normal file
109
cli/ui_manager_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewUIManager(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env terminalEnvSetup
|
||||
expectedColors bool
|
||||
expectedProgress bool
|
||||
}{
|
||||
{
|
||||
name: "default terminal",
|
||||
env: envDefaultTerminal,
|
||||
expectedColors: true,
|
||||
expectedProgress: false, // Not a tty in test environment
|
||||
},
|
||||
{
|
||||
name: "dumb terminal",
|
||||
env: envDumbTerminal,
|
||||
expectedColors: false,
|
||||
expectedProgress: false,
|
||||
},
|
||||
{
|
||||
name: "CI environment without GitHub Actions",
|
||||
env: envCIWithoutGitHub,
|
||||
expectedColors: false,
|
||||
expectedProgress: false,
|
||||
},
|
||||
{
|
||||
name: "GitHub Actions CI",
|
||||
env: envGitHubActions,
|
||||
expectedColors: true,
|
||||
expectedProgress: false,
|
||||
},
|
||||
{
|
||||
name: "NO_COLOR set",
|
||||
env: envNoColor,
|
||||
expectedColors: false,
|
||||
expectedProgress: false,
|
||||
},
|
||||
{
|
||||
name: "FORCE_COLOR set",
|
||||
env: envForceColor,
|
||||
expectedColors: true,
|
||||
expectedProgress: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.env.apply(t)
|
||||
|
||||
ui := NewUIManager()
|
||||
assert.NotNil(t, ui)
|
||||
assert.NotNil(t, ui.output)
|
||||
assert.Equal(t, tt.expectedColors, ui.enableColors, "color state mismatch")
|
||||
assert.Equal(t, tt.expectedProgress, ui.enableProgress, "progress state mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetColorOutput(t *testing.T) {
|
||||
// Capture original color.NoColor state and restore after test
|
||||
orig := color.NoColor
|
||||
defer func() { color.NoColor = orig }()
|
||||
|
||||
ui := &UIManager{output: os.Stderr}
|
||||
|
||||
// Test enabling colors
|
||||
ui.SetColorOutput(true)
|
||||
assert.False(t, color.NoColor)
|
||||
assert.True(t, ui.enableColors)
|
||||
|
||||
// Test disabling colors
|
||||
ui.SetColorOutput(false)
|
||||
assert.True(t, color.NoColor)
|
||||
assert.False(t, ui.enableColors)
|
||||
}
|
||||
|
||||
func TestSetProgressOutput(t *testing.T) {
|
||||
ui := &UIManager{output: os.Stderr}
|
||||
|
||||
// Test enabling progress
|
||||
ui.SetProgressOutput(true)
|
||||
assert.True(t, ui.enableProgress)
|
||||
|
||||
// Test disabling progress
|
||||
ui.SetProgressOutput(false)
|
||||
assert.False(t, ui.enableProgress)
|
||||
}
|
||||
|
||||
func TestPrintf(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ui.printf("Test %s %d", "output", 123)
|
||||
|
||||
assert.Equal(t, "Test output 123", buf.String())
|
||||
}
|
||||
245
cli/ui_print_test.go
Normal file
245
cli/ui_print_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
)
|
||||
|
||||
func TestPrintSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []interface{}
|
||||
expectSymbol string
|
||||
}{
|
||||
{
|
||||
name: testWithColors,
|
||||
enableColors: true,
|
||||
format: "Operation %s",
|
||||
args: []interface{}{"completed"},
|
||||
expectSymbol: gibidiutils.IconSuccess,
|
||||
},
|
||||
{
|
||||
name: testWithoutColors,
|
||||
enableColors: false,
|
||||
format: "Operation %s",
|
||||
args: []interface{}{"completed"},
|
||||
expectSymbol: gibidiutils.IconSuccess,
|
||||
},
|
||||
{
|
||||
name: "no arguments",
|
||||
enableColors: true,
|
||||
format: "Success",
|
||||
args: nil,
|
||||
expectSymbol: gibidiutils.IconSuccess,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: tt.enableColors,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = !tt.enableColors
|
||||
defer func() { color.NoColor = prev }()
|
||||
|
||||
ui.PrintSuccess(tt.format, tt.args...)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, tt.expectSymbol)
|
||||
if len(tt.args) > 0 {
|
||||
assert.Contains(t, output, "completed")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []interface{}
|
||||
expectSymbol string
|
||||
}{
|
||||
{
|
||||
name: testWithColors,
|
||||
enableColors: true,
|
||||
format: "Failed to %s",
|
||||
args: []interface{}{"process"},
|
||||
expectSymbol: gibidiutils.IconError,
|
||||
},
|
||||
{
|
||||
name: testWithoutColors,
|
||||
enableColors: false,
|
||||
format: "Failed to %s",
|
||||
args: []interface{}{"process"},
|
||||
expectSymbol: gibidiutils.IconError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: tt.enableColors,
|
||||
output: buf,
|
||||
}
|
||||
prev := color.NoColor
|
||||
color.NoColor = !tt.enableColors
|
||||
defer func() { color.NoColor = prev }()
|
||||
|
||||
ui.PrintError(tt.format, tt.args...)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, tt.expectSymbol)
|
||||
if len(tt.args) > 0 {
|
||||
assert.Contains(t, output, "process")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWarning(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: true,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
ui.PrintWarning("This is a %s", "warning")
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, gibidiutils.IconWarning)
|
||||
}
|
||||
|
||||
func TestPrintInfo(t *testing.T) {
|
||||
// Capture original color.NoColor state and restore after test
|
||||
orig := color.NoColor
|
||||
defer func() { color.NoColor = orig }()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: true,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
color.NoColor = false
|
||||
|
||||
ui.PrintInfo("Information: %d items", 42)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, gibidiutils.IconInfo)
|
||||
assert.Contains(t, output, "42")
|
||||
}
|
||||
|
||||
func TestPrintHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []interface{}
|
||||
}{
|
||||
{
|
||||
name: testWithColors,
|
||||
enableColors: true,
|
||||
format: "Header %s",
|
||||
args: []interface{}{"Title"},
|
||||
},
|
||||
{
|
||||
name: testWithoutColors,
|
||||
enableColors: false,
|
||||
format: "Header %s",
|
||||
args: []interface{}{"Title"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
// Capture original color.NoColor state and restore after test
|
||||
orig := color.NoColor
|
||||
defer func() { color.NoColor = orig }()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: tt.enableColors,
|
||||
output: buf,
|
||||
}
|
||||
color.NoColor = !tt.enableColors
|
||||
|
||||
ui.PrintHeader(tt.format, tt.args...)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Title")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that all print methods handle newlines correctly
|
||||
func TestPrintMethodsNewlines(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method func(*UIManager, string, ...interface{})
|
||||
symbol string
|
||||
}{
|
||||
{
|
||||
name: "PrintSuccess",
|
||||
method: (*UIManager).PrintSuccess,
|
||||
symbol: gibidiutils.IconSuccess,
|
||||
},
|
||||
{
|
||||
name: "PrintError",
|
||||
method: (*UIManager).PrintError,
|
||||
symbol: gibidiutils.IconError,
|
||||
},
|
||||
{
|
||||
name: "PrintWarning",
|
||||
method: (*UIManager).PrintWarning,
|
||||
symbol: gibidiutils.IconWarning,
|
||||
},
|
||||
{
|
||||
name: "PrintInfo",
|
||||
method: (*UIManager).PrintInfo,
|
||||
symbol: gibidiutils.IconInfo,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
// Disable colors for consistent testing
|
||||
oldNoColor := color.NoColor
|
||||
color.NoColor = true
|
||||
defer func() { color.NoColor = oldNoColor }()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false,
|
||||
output: buf,
|
||||
}
|
||||
|
||||
tt.method(ui, "Test message")
|
||||
|
||||
output := buf.String()
|
||||
assert.True(t, strings.HasSuffix(output, "\n"))
|
||||
assert.Contains(t, output, tt.symbol)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
147
cli/ui_progress_test.go
Normal file
147
cli/ui_progress_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStartProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
total int
|
||||
description string
|
||||
enabled bool
|
||||
expectBar bool
|
||||
}{
|
||||
{
|
||||
name: "progress enabled with valid total",
|
||||
total: 100,
|
||||
description: testProcessingMsg,
|
||||
enabled: true,
|
||||
expectBar: true,
|
||||
},
|
||||
{
|
||||
name: "progress disabled",
|
||||
total: 100,
|
||||
description: testProcessingMsg,
|
||||
enabled: false,
|
||||
expectBar: false,
|
||||
},
|
||||
{
|
||||
name: "zero total",
|
||||
total: 0,
|
||||
description: testProcessingMsg,
|
||||
enabled: true,
|
||||
expectBar: false,
|
||||
},
|
||||
{
|
||||
name: "negative total",
|
||||
total: -5,
|
||||
description: testProcessingMsg,
|
||||
enabled: true,
|
||||
expectBar: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui := &UIManager{
|
||||
enableProgress: tt.enabled,
|
||||
output: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
ui.StartProgress(tt.total, tt.description)
|
||||
|
||||
if tt.expectBar {
|
||||
assert.NotNil(t, ui.progressBar)
|
||||
} else {
|
||||
assert.Nil(t, ui.progressBar)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupBar bool
|
||||
enabledProg bool
|
||||
expectUpdate bool
|
||||
}{
|
||||
{
|
||||
name: "with progress bar",
|
||||
setupBar: true,
|
||||
enabledProg: true,
|
||||
expectUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "without progress bar",
|
||||
setupBar: false,
|
||||
enabledProg: false,
|
||||
expectUpdate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(_ *testing.T) {
|
||||
ui := &UIManager{
|
||||
enableProgress: tt.enabledProg,
|
||||
output: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
if tt.setupBar {
|
||||
ui.StartProgress(10, "Test")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
ui.UpdateProgress(1)
|
||||
|
||||
// Multiple updates should not panic
|
||||
ui.UpdateProgress(2)
|
||||
ui.UpdateProgress(3)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupBar bool
|
||||
}{
|
||||
{
|
||||
name: "with progress bar",
|
||||
setupBar: true,
|
||||
},
|
||||
{
|
||||
name: "without progress bar",
|
||||
setupBar: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui := &UIManager{
|
||||
enableProgress: true,
|
||||
output: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
if tt.setupBar {
|
||||
ui.StartProgress(10, "Test")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
ui.FinishProgress()
|
||||
|
||||
// Bar should be cleared
|
||||
assert.Nil(t, ui.progressBar)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
62
cli/ui_terminal_test.go
Normal file
62
cli/ui_terminal_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsColorTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env terminalEnvSetup
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "dumb terminal",
|
||||
env: envDumbTerminal,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty TERM",
|
||||
env: envEmptyTerm,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "CI without GitHub Actions",
|
||||
env: envCIWithoutGitHub,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GitHub Actions",
|
||||
env: envGitHubActions,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "NO_COLOR set",
|
||||
env: envNoColor,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "FORCE_COLOR set",
|
||||
env: envForceColor,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.env.apply(t)
|
||||
|
||||
result := isColorTerminal()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInteractiveTerminal(t *testing.T) {
|
||||
// This function checks if stderr is a terminal
|
||||
// In test environment, it will typically return false
|
||||
result := isInteractiveTerminal()
|
||||
assert.False(t, result)
|
||||
}
|
||||
Reference in New Issue
Block a user