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:
2025-10-10 12:14:42 +03:00
committed by GitHub
parent 958f5952a0
commit 3f65b813bd
100 changed files with 6997 additions and 1225 deletions

View File

@@ -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
View 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)
})
}
}

View File

@@ -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
View 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)
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View 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)
})
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View 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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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)
}