mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-02-05 02:45:25 +00:00
chore: modernize workflows, security scanning, and linting configuration (#50)
* build: update Go 1.25, CI workflows, and build tooling - Upgrade to Go 1.25 - Add benchmark targets to Makefile - Implement parallel gosec execution - Lock tool versions for reproducibility - Add shellcheck directives to scripts - Update CI workflows with improved caching * refactor: migrate from golangci-lint to revive - Replace golangci-lint with revive for linting - Configure comprehensive revive rules - Fix all EditorConfig violations - Add yamllint and yamlfmt support - Remove deprecated .golangci.yml * refactor: rename utils to shared and deduplicate code - Rename utils package to shared - Add shared constants package - Deduplicate constants across packages - Address CodeRabbit review feedback * fix: resolve SonarQube issues and add safety guards - Fix all 73 SonarQube OPEN issues - Add nil guards for resourceMonitor, backpressure, metricsCollector - Implement io.Closer for headerFileReader - Propagate errors from processing helpers - Add metrics and templates packages - Improve error handling across codebase * test: improve test infrastructure and coverage - Add benchmarks for cli, fileproc, metrics - Improve test coverage for cli, fileproc, config - Refactor tests with helper functions - Add shared test constants - Fix test function naming conventions - Reduce cognitive complexity in benchmark tests * docs: update documentation and configuration examples - Update CLAUDE.md with current project state - Refresh README with new features - Add usage and configuration examples - Add SonarQube project configuration - Consolidate config.example.yaml * fix: resolve shellcheck warnings in scripts - Use ./*.go instead of *.go to prevent dash-prefixed filenames from being interpreted as options (SC2035) - Remove unreachable return statement after exit (SC2317) - Remove obsolete gibidiutils/ directory reference * chore(deps): upgrade go dependencies * chore(lint): megalinter fixes * fix: improve test coverage and fix file descriptor leaks - Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests - Refactor TestProcessorConfigureFileTypes with helper functions and assertions - Refactor TestProcessorLogFinalStats with output capture and keyword verification - Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.) - Reduce cognitive complexity by extracting helper functions * fix: align test comments with function names Remove underscores from test comments to match actual function names: - benchmark/benchmark_test.go (2 fixes) - fileproc/filetypes_config_test.go (4 fixes) - fileproc/filetypes_registry_test.go (6 fixes) - fileproc/processor_test.go (6 fixes) - fileproc/resource_monitor_types_test.go (4 fixes) - fileproc/writer_test.go (3 fixes) * fix: various test improvements and bug fixes - Remove duplicate maxCacheSize check in filetypes_registry_test.go - Shorten long comment in processor_test.go to stay under 120 chars - Remove flaky time.Sleep in collector_test.go, use >= 0 assertion - Close pipe reader in benchmark_test.go to fix file descriptor leak - Use ContinueOnError in flags_test.go to match ResetFlags behavior - Add nil check for p.ui in processor_workers.go before UpdateProgress - Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly * chore(yaml): add missing document start markers Add --- document start to YAML files to satisfy yamllint: - .github/workflows/codeql.yml - .github/workflows/build-test-publish.yml - .github/workflows/security.yml - .github/actions/setup/action.yml * fix: guard nil resourceMonitor and fix test deadlock - Guard resourceMonitor before CreateFileProcessingContext call - Add ui.UpdateProgress on emergency stop and path error returns - Fix potential deadlock in TestProcessFile using wg.Go with defer close
This commit is contained in:
190
cli/errors.go
190
cli/errors.go
@@ -1,4 +1,4 @@
|
||||
// Package cli provides command-line interface utilities for gibidify.
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// ErrorFormatter handles CLI-friendly error formatting with suggestions.
|
||||
// This is not an error type itself; it formats existing errors for display.
|
||||
type ErrorFormatter struct {
|
||||
ui *UIManager
|
||||
}
|
||||
@@ -20,11 +21,6 @@ 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 {
|
||||
@@ -32,9 +28,10 @@ func (ef *ErrorFormatter) FormatError(err error) {
|
||||
}
|
||||
|
||||
// Handle structured errors
|
||||
var structErr *gibidiutils.StructuredError
|
||||
structErr := &shared.StructuredError{}
|
||||
if errors.As(err, &structErr) {
|
||||
ef.formatStructuredError(structErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,12 +40,12 @@ func (ef *ErrorFormatter) FormatError(err error) {
|
||||
}
|
||||
|
||||
// formatStructuredError formats a structured error with context and suggestions.
|
||||
func (ef *ErrorFormatter) formatStructuredError(err *gibidiutils.StructuredError) {
|
||||
func (ef *ErrorFormatter) formatStructuredError(err *shared.StructuredError) {
|
||||
// Print main error
|
||||
ef.ui.PrintError("Error: %s", err.Message)
|
||||
ef.ui.PrintError(shared.CLIMsgErrorFormat, err.Message)
|
||||
|
||||
// Print error type and code
|
||||
if err.Type != gibidiutils.ErrorTypeUnknown || err.Code != "" {
|
||||
if err.Type != shared.ErrorTypeUnknown || err.Code != "" {
|
||||
ef.ui.PrintInfo("Type: %s, Code: %s", err.Type.String(), err.Code)
|
||||
}
|
||||
|
||||
@@ -71,20 +68,20 @@ func (ef *ErrorFormatter) formatStructuredError(err *gibidiutils.StructuredError
|
||||
|
||||
// formatGenericError formats a generic error.
|
||||
func (ef *ErrorFormatter) formatGenericError(err error) {
|
||||
ef.ui.PrintError("Error: %s", err.Error())
|
||||
ef.ui.PrintError(shared.CLIMsgErrorFormat, err.Error())
|
||||
ef.provideGenericSuggestions(err)
|
||||
}
|
||||
|
||||
// provideSuggestions provides helpful suggestions based on the error.
|
||||
func (ef *ErrorFormatter) provideSuggestions(err *gibidiutils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideSuggestions(err *shared.StructuredError) {
|
||||
switch err.Type {
|
||||
case gibidiutils.ErrorTypeFileSystem:
|
||||
case shared.ErrorTypeFileSystem:
|
||||
ef.provideFileSystemSuggestions(err)
|
||||
case gibidiutils.ErrorTypeValidation:
|
||||
case shared.ErrorTypeValidation:
|
||||
ef.provideValidationSuggestions(err)
|
||||
case gibidiutils.ErrorTypeProcessing:
|
||||
case shared.ErrorTypeProcessing:
|
||||
ef.provideProcessingSuggestions(err)
|
||||
case gibidiutils.ErrorTypeIO:
|
||||
case shared.ErrorTypeIO:
|
||||
ef.provideIOSuggestions(err)
|
||||
default:
|
||||
ef.provideDefaultSuggestions()
|
||||
@@ -92,17 +89,17 @@ func (ef *ErrorFormatter) provideSuggestions(err *gibidiutils.StructuredError) {
|
||||
}
|
||||
|
||||
// provideFileSystemSuggestions provides suggestions for file system errors.
|
||||
func (ef *ErrorFormatter) provideFileSystemSuggestions(err *gibidiutils.StructuredError) {
|
||||
func (ef *ErrorFormatter) provideFileSystemSuggestions(err *shared.StructuredError) {
|
||||
filePath := err.FilePath
|
||||
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
case gibidiutils.CodeFSAccess:
|
||||
case shared.CodeFSAccess:
|
||||
ef.suggestFileAccess(filePath)
|
||||
case gibidiutils.CodeFSPathResolution:
|
||||
case shared.CodeFSPathResolution:
|
||||
ef.suggestPathResolution(filePath)
|
||||
case gibidiutils.CodeFSNotFound:
|
||||
case shared.CodeFSNotFound:
|
||||
ef.suggestFileNotFound(filePath)
|
||||
default:
|
||||
ef.suggestFileSystemGeneral(filePath)
|
||||
@@ -110,130 +107,135 @@ func (ef *ErrorFormatter) provideFileSystemSuggestions(err *gibidiutils.Structur
|
||||
}
|
||||
|
||||
// provideValidationSuggestions provides suggestions for validation errors.
|
||||
func (ef *ErrorFormatter) provideValidationSuggestions(err *gibidiutils.StructuredError) {
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
func (ef *ErrorFormatter) provideValidationSuggestions(err *shared.StructuredError) {
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
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)
|
||||
case shared.CodeValidationFormat:
|
||||
ef.ui.printf(" • Use a supported format: markdown, json, yaml\n")
|
||||
ef.ui.printf(" • Example: -format markdown\n")
|
||||
case shared.CodeValidationSize:
|
||||
ef.ui.printf(" • Increase file size limit in config.yaml\n")
|
||||
ef.ui.printf(" • Use smaller files or exclude large files\n")
|
||||
default:
|
||||
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(shared.CLIMsgCheckCommandLineArgs)
|
||||
ef.ui.printf(shared.CLIMsgRunWithHelp)
|
||||
}
|
||||
}
|
||||
|
||||
// provideProcessingSuggestions provides suggestions for processing errors.
|
||||
func (ef *ErrorFormatter) provideProcessingSuggestions(err *gibidiutils.StructuredError) {
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
func (ef *ErrorFormatter) provideProcessingSuggestions(err *shared.StructuredError) {
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
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)
|
||||
case shared.CodeProcessingCollection:
|
||||
ef.ui.printf(" • Check if the source directory exists and is readable\n")
|
||||
ef.ui.printf(" • Verify directory permissions\n")
|
||||
case shared.CodeProcessingFileRead:
|
||||
ef.ui.printf(" • Check file permissions\n")
|
||||
ef.ui.printf(" • Verify the file is not corrupted\n")
|
||||
default:
|
||||
ef.ui.printf(" %s Try reducing concurrency: -concurrency 1\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Check available system resources\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" • Try reducing concurrency: -concurrency 1\n")
|
||||
ef.ui.printf(" • Check available system resources\n")
|
||||
}
|
||||
}
|
||||
|
||||
// provideIOSuggestions provides suggestions for I/O errors.
|
||||
func (ef *ErrorFormatter) provideIOSuggestions(err *gibidiutils.StructuredError) {
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
func (ef *ErrorFormatter) provideIOSuggestions(err *shared.StructuredError) {
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
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)
|
||||
case shared.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 shared.CodeIOWrite:
|
||||
ef.ui.printf(" • Check available disk space\n")
|
||||
ef.ui.printf(" • Verify write permissions\n")
|
||||
default:
|
||||
ef.ui.printf(suggestionCheckPermissions, gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify available disk space\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(shared.CLIMsgCheckFilePermissions)
|
||||
ef.ui.printf(" • Verify available disk space\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for specific suggestions
|
||||
// Helper methods for specific suggestions.
|
||||
func (ef *ErrorFormatter) suggestFileAccess(filePath string) {
|
||||
ef.ui.printf(" %s Check if the path exists: %s\n", gibidiutils.IconBullet, filePath)
|
||||
ef.ui.printf(" %s Verify read permissions\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" • Check if the path exists: %s\n", filePath)
|
||||
ef.ui.printf(" • Verify read permissions\n")
|
||||
if filePath != "" {
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
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())
|
||||
ef.ui.printf(" • Path exists but may not be accessible\n")
|
||||
ef.ui.printf(" • Mode: %s\n", stat.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestPathResolution(filePath string) {
|
||||
ef.ui.printf(" %s Use an absolute path instead of relative\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(" • Use an absolute path instead of relative\n")
|
||||
if filePath != "" {
|
||||
if abs, err := filepath.Abs(filePath); err == nil {
|
||||
ef.ui.printf(" %s Try: %s\n", gibidiutils.IconBullet, abs)
|
||||
ef.ui.printf(" • Try: %s\n", abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestFileNotFound(filePath string) {
|
||||
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(" %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 %s\n", gibidiutils.IconBullet, entry.Name())
|
||||
count++
|
||||
}
|
||||
}
|
||||
ef.ui.printf(" • Check if the file/directory exists: %s\n", filePath)
|
||||
if filePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ef.ui.printf(" • Similar files in %s:\n", 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())
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestFileSystemGeneral(filePath string) {
|
||||
ef.ui.printf(suggestionCheckPermissions, gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Verify the path is correct\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(shared.CLIMsgCheckFilePermissions)
|
||||
ef.ui.printf(" • Verify the path is correct\n")
|
||||
if filePath != "" {
|
||||
ef.ui.printf(" %s Path: %s\n", gibidiutils.IconBullet, filePath)
|
||||
ef.ui.printf(" • Path: %s\n", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// provideDefaultSuggestions provides general suggestions.
|
||||
func (ef *ErrorFormatter) provideDefaultSuggestions() {
|
||||
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)
|
||||
ef.ui.printf(shared.CLIMsgCheckCommandLineArgs)
|
||||
ef.ui.printf(shared.CLIMsgRunWithHelp)
|
||||
ef.ui.printf(" • Try with -concurrency 1 to reduce resource usage\n")
|
||||
}
|
||||
|
||||
// provideGenericSuggestions provides suggestions for generic errors.
|
||||
func (ef *ErrorFormatter) provideGenericSuggestions(err error) {
|
||||
errorMsg := err.Error()
|
||||
|
||||
ef.ui.PrintWarning("Suggestions:")
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
// Pattern matching for common errors
|
||||
switch {
|
||||
case strings.Contains(errorMsg, "permission denied"):
|
||||
ef.ui.printf(suggestionCheckPermissions, gibidiutils.IconBullet)
|
||||
ef.ui.printf(" %s Try running with appropriate privileges\n", gibidiutils.IconBullet)
|
||||
ef.ui.printf(shared.CLIMsgCheckFilePermissions)
|
||||
ef.ui.printf(" • Try running with appropriate privileges\n")
|
||||
case strings.Contains(errorMsg, "no such file or directory"):
|
||||
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)
|
||||
ef.ui.printf(" • Verify the file/directory path is correct\n")
|
||||
ef.ui.printf(" • Check if the file exists\n")
|
||||
case strings.Contains(errorMsg, "flag") && strings.Contains(errorMsg, "redefined"):
|
||||
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)
|
||||
ef.ui.printf(" • This is likely a test environment issue\n")
|
||||
ef.ui.printf(" • Try running the command directly instead of in tests\n")
|
||||
default:
|
||||
ef.provideDefaultSuggestions()
|
||||
}
|
||||
@@ -248,8 +250,8 @@ func (e MissingSourceError) Error() string {
|
||||
return "source directory is required"
|
||||
}
|
||||
|
||||
// NewMissingSourceError creates a new CLI missing source error with suggestions.
|
||||
func NewMissingSourceError() error {
|
||||
// NewCLIMissingSourceError creates a new CLI missing source error with suggestions.
|
||||
func NewCLIMissingSourceError() error {
|
||||
return &MissingSourceError{}
|
||||
}
|
||||
|
||||
@@ -266,11 +268,11 @@ func IsUserError(err error) bool {
|
||||
}
|
||||
|
||||
// Check for structured errors that are user-facing
|
||||
var structErr *gibidiutils.StructuredError
|
||||
structErr := &shared.StructuredError{}
|
||||
if errors.As(err, &structErr) {
|
||||
return structErr.Type == gibidiutils.ErrorTypeValidation ||
|
||||
structErr.Code == gibidiutils.CodeValidationFormat ||
|
||||
structErr.Code == gibidiutils.CodeValidationSize
|
||||
return structErr.Type == shared.ErrorTypeValidation ||
|
||||
structErr.Code == shared.CodeValidationFormat ||
|
||||
structErr.Code == shared.CodeValidationSize
|
||||
}
|
||||
|
||||
// Check error message patterns
|
||||
|
||||
1441
cli/errors_test.go
1441
cli/errors_test.go
File diff suppressed because it is too large
Load Diff
62
cli/flags.go
62
cli/flags.go
@@ -1,11 +1,14 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Flags holds CLI flags values.
|
||||
@@ -18,7 +21,9 @@ type Flags struct {
|
||||
Format string
|
||||
NoColors bool
|
||||
NoProgress bool
|
||||
NoUI bool
|
||||
Verbose bool
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -26,6 +31,15 @@ var (
|
||||
globalFlags *Flags
|
||||
)
|
||||
|
||||
// ResetFlags resets the global flag parsing state for testing.
|
||||
// This function should only be used in tests to ensure proper isolation.
|
||||
func ResetFlags() {
|
||||
flagsParsed = false
|
||||
globalFlags = nil
|
||||
// Reset default FlagSet to avoid duplicate flag registration across tests
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
}
|
||||
|
||||
// ParseFlags parses and validates CLI flags.
|
||||
func ParseFlags() (*Flags, error) {
|
||||
if flagsParsed {
|
||||
@@ -34,18 +48,20 @@ func ParseFlags() (*Flags, error) {
|
||||
|
||||
flags := &Flags{}
|
||||
|
||||
flag.StringVar(&flags.SourceDir, "source", "", "Source directory to scan recursively")
|
||||
flag.StringVar(&flags.SourceDir, shared.CLIArgSource, "", "Source directory to scan recursively")
|
||||
flag.StringVar(&flags.Destination, "destination", "", "Output file to write aggregated code")
|
||||
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.StringVar(&flags.Format, shared.CLIArgFormat, shared.FormatJSON, "Output format (json, markdown, yaml)")
|
||||
flag.IntVar(&flags.Concurrency, shared.CLIArgConcurrency, 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.NoUI, "no-ui", false, "Disable all UI output (implies no-colors and no-progress)")
|
||||
flag.BoolVar(&flags.Verbose, "verbose", false, "Enable verbose output")
|
||||
flag.StringVar(
|
||||
&flags.LogLevel, "log-level", string(shared.LogLevelWarn), "Set log level (debug, info, warn, error)",
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
@@ -59,40 +75,54 @@ func ParseFlags() (*Flags, error) {
|
||||
|
||||
flagsParsed = true
|
||||
globalFlags = flags
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
// validate validates the CLI flags.
|
||||
func (f *Flags) validate() error {
|
||||
if f.SourceDir == "" {
|
||||
return NewMissingSourceError()
|
||||
return NewCLIMissingSourceError()
|
||||
}
|
||||
|
||||
// Validate source path for security
|
||||
if err := gibidiutils.ValidateSourcePath(f.SourceDir); err != nil {
|
||||
return err
|
||||
if err := shared.ValidateSourcePath(f.SourceDir); err != nil {
|
||||
return fmt.Errorf("validating source path: %w", err)
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
if err := config.ValidateOutputFormat(f.Format); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("validating output format: %w", err)
|
||||
}
|
||||
|
||||
// Validate concurrency
|
||||
return config.ValidateConcurrency(f.Concurrency)
|
||||
if err := config.ValidateConcurrency(f.Concurrency); err != nil {
|
||||
return fmt.Errorf("validating concurrency: %w", err)
|
||||
}
|
||||
|
||||
// Validate log level
|
||||
if !shared.ValidateLogLevel(f.LogLevel) {
|
||||
return fmt.Errorf("invalid log level: %s (must be: debug, info, warn, error)", f.LogLevel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDefaultDestination sets the default destination if not provided.
|
||||
func (f *Flags) setDefaultDestination() error {
|
||||
if f.Destination == "" {
|
||||
absRoot, err := gibidiutils.GetAbsolutePath(f.SourceDir)
|
||||
absRoot, err := shared.AbsolutePath(f.SourceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("getting absolute path: %w", err)
|
||||
}
|
||||
baseName := gibidiutils.GetBaseName(absRoot)
|
||||
baseName := shared.BaseName(absRoot)
|
||||
f.Destination = baseName + "." + f.Format
|
||||
}
|
||||
|
||||
// Validate destination path for security
|
||||
return gibidiutils.ValidateDestinationPath(f.Destination)
|
||||
if err := shared.ValidateDestinationPath(f.Destination); err != nil {
|
||||
return fmt.Errorf("validating destination path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,366 +1,664 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
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)
|
||||
}()
|
||||
const testDirPlaceholder = "testdir"
|
||||
|
||||
// setupTestArgs prepares test arguments by replacing testdir with actual temp directory.
|
||||
func setupTestArgs(t *testing.T, args []string, want *Flags) ([]string, *Flags) {
|
||||
t.Helper()
|
||||
|
||||
if !containsFlag(args, shared.TestCLIFlagSource) {
|
||||
return args, want
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
modifiedArgs := replaceTestDirInArgs(args, tempDir)
|
||||
|
||||
// Handle nil want parameter (used for error test cases)
|
||||
if want == nil {
|
||||
return modifiedArgs, nil
|
||||
}
|
||||
|
||||
modifiedWant := updateWantFlags(*want, tempDir)
|
||||
|
||||
return modifiedArgs, &modifiedWant
|
||||
}
|
||||
|
||||
// replaceTestDirInArgs replaces testdir placeholder with actual temp directory in args.
|
||||
func replaceTestDirInArgs(args []string, tempDir string) []string {
|
||||
modifiedArgs := make([]string, len(args))
|
||||
copy(modifiedArgs, args)
|
||||
|
||||
for i, arg := range modifiedArgs {
|
||||
if arg == testDirPlaceholder {
|
||||
modifiedArgs[i] = tempDir
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedArgs
|
||||
}
|
||||
|
||||
// updateWantFlags updates the want flags with temp directory replacements.
|
||||
func updateWantFlags(want Flags, tempDir string) Flags {
|
||||
modifiedWant := want
|
||||
|
||||
if want.SourceDir == testDirPlaceholder {
|
||||
modifiedWant.SourceDir = tempDir
|
||||
if strings.HasPrefix(want.Destination, testDirPlaceholder+".") {
|
||||
baseName := testutil.BaseName(tempDir)
|
||||
modifiedWant.Destination = baseName + "." + want.Format
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedWant
|
||||
}
|
||||
|
||||
// runParseFlagsTest runs a single parse flags test.
|
||||
func runParseFlagsTest(t *testing.T, args []string, want *Flags, wantErr bool, errContains string) {
|
||||
t.Helper()
|
||||
|
||||
// Capture and restore original os.Args
|
||||
origArgs := os.Args
|
||||
defer func() { os.Args = origArgs }()
|
||||
|
||||
resetFlagsState()
|
||||
modifiedArgs, modifiedWant := setupTestArgs(t, args, want)
|
||||
setupCommandLineArgs(modifiedArgs)
|
||||
|
||||
got, err := ParseFlags()
|
||||
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Error("ParseFlags() expected error, got nil")
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("ParseFlags() error = %v, want error containing %v", err, errContains)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseFlags() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
verifyFlags(t, got, modifiedWant)
|
||||
}
|
||||
|
||||
func TestParseFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedError string
|
||||
validate func(t *testing.T, f *Flags)
|
||||
setup func(t *testing.T)
|
||||
name string
|
||||
args []string
|
||||
want *Flags
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid flags with all options",
|
||||
name: "valid basic flags",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagFormat, "markdown"},
|
||||
want: &Flags{
|
||||
SourceDir: "testdir",
|
||||
Format: "markdown",
|
||||
Concurrency: runtime.NumCPU(),
|
||||
Destination: "testdir.markdown",
|
||||
LogLevel: string(shared.LogLevelWarn),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with all flags",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
"-destination", "output.md",
|
||||
"-format", "json",
|
||||
testFlagConcurrency, "4",
|
||||
"-prefix", "prefix",
|
||||
"-suffix", "suffix",
|
||||
shared.TestCLIFlagSource, "testdir",
|
||||
shared.TestCLIFlagDestination, shared.TestOutputMD,
|
||||
"-prefix", "# Header",
|
||||
"-suffix", "# Footer",
|
||||
shared.TestCLIFlagFormat, "json",
|
||||
shared.TestCLIFlagConcurrency, "4",
|
||||
"-verbose",
|
||||
"-no-colors",
|
||||
"-no-progress",
|
||||
"-verbose",
|
||||
},
|
||||
validate: nil, // set in test body using closure
|
||||
want: &Flags{
|
||||
SourceDir: "testdir",
|
||||
Destination: shared.TestOutputMD,
|
||||
Prefix: "# Header",
|
||||
Suffix: "# Footer",
|
||||
Format: "json",
|
||||
Concurrency: 4,
|
||||
Verbose: true,
|
||||
NoColors: true,
|
||||
NoProgress: true,
|
||||
LogLevel: string(shared.LogLevelWarn),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing source directory",
|
||||
args: []string{"gibidify"},
|
||||
expectedError: testErrSourceRequired,
|
||||
name: "missing source directory",
|
||||
args: []string{shared.TestCLIFlagFormat, "markdown"},
|
||||
wantErr: true,
|
||||
errContains: "source directory is required",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{
|
||||
"gibidify",
|
||||
testFlagSource, "", // will set to tempDir in test body
|
||||
"-format", "invalid",
|
||||
},
|
||||
expectedError: "unsupported output format: invalid",
|
||||
name: "invalid format",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagFormat, "invalid"},
|
||||
wantErr: true,
|
||||
errContains: "validating output format",
|
||||
},
|
||||
{
|
||||
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 zero",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagConcurrency, "0"},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
{
|
||||
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
|
||||
name: "negative concurrency",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagConcurrency, "-1"},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
runParseFlagsTest(t, tt.args, tt.want, tt.wantErr, tt.errContains)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsValidate(t *testing.T) {
|
||||
// validateFlagsValidationResult validates flag validation test results.
|
||||
func validateFlagsValidationResult(t *testing.T, err error, wantErr bool, errContains string) {
|
||||
t.Helper()
|
||||
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Error("Flags.validate() expected error, got nil")
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("Flags.validate() error = %v, want error containing %v", err, errContains)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Flags.validate() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsvalidate(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *Flags
|
||||
setupFunc func(t *testing.T, f *Flags)
|
||||
expectedError string
|
||||
name string
|
||||
flags *Flags
|
||||
wantErr bool
|
||||
errContains 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{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
Concurrency: 4,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty source directory",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Concurrency: 4,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "source directory is required",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "invalid",
|
||||
Concurrency: 4,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "validating output format",
|
||||
},
|
||||
{
|
||||
name: "zero concurrency",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
Concurrency: 0,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
{
|
||||
name: "negative concurrency",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "json",
|
||||
Concurrency: -1,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
{
|
||||
name: "invalid log level",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "json",
|
||||
Concurrency: 4,
|
||||
LogLevel: "invalid",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "invalid log level",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
err := tt.flags.validate()
|
||||
validateFlagsValidationResult(t, err, tt.wantErr, tt.errContains)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultDestination(t *testing.T) {
|
||||
// validateDefaultDestinationResult validates default destination test results.
|
||||
func validateDefaultDestinationResult(
|
||||
t *testing.T,
|
||||
flags *Flags,
|
||||
err error,
|
||||
wantDestination string,
|
||||
wantErr bool,
|
||||
errContains string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Error("Flags.setDefaultDestination() expected error, got nil")
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("Flags.setDefaultDestination() error = %v, want error containing %v", err, errContains)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Flags.setDefaultDestination() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if flags.Destination != wantDestination {
|
||||
t.Errorf("Flags.Destination = %v, want %v", flags.Destination, wantDestination)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagssetDefaultDestination(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
baseName := testutil.BaseName(tempDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *Flags
|
||||
setupFunc func(t *testing.T, f *Flags)
|
||||
expectedDest string
|
||||
expectedError string
|
||||
name string
|
||||
flags *Flags
|
||||
wantDestination string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "default destination for directory",
|
||||
name: "set default destination markdown",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedDest: "", // will check suffix below
|
||||
wantDestination: baseName + ".markdown",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "default destination for json format",
|
||||
name: "set default destination json",
|
||||
flags: &Flags{
|
||||
Format: "json",
|
||||
SourceDir: tempDir,
|
||||
Format: "json",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedDest: "", // will check suffix below
|
||||
wantDestination: baseName + ".json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "provided destination unchanged",
|
||||
name: "set default destination yaml",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Destination: "custom-output.txt",
|
||||
SourceDir: tempDir,
|
||||
Format: "yaml",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
},
|
||||
expectedDest: "custom-output.txt",
|
||||
wantDestination: baseName + ".yaml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path traversal in destination",
|
||||
name: "preserve existing destination",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Destination: testPathTraversalPath,
|
||||
SourceDir: tempDir,
|
||||
Format: "yaml",
|
||||
Destination: "custom-output.yaml",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
setupFunc: func(t *testing.T, f *Flags) {
|
||||
f.SourceDir = t.TempDir()
|
||||
wantDestination: "custom-output.yaml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nonexistent source path still generates destination",
|
||||
flags: &Flags{
|
||||
SourceDir: "/nonexistent/path/that/should/not/exist",
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
expectedError: testErrPathTraversal,
|
||||
wantDestination: "exist.markdown", // Based on filepath.Base of the path
|
||||
wantErr: false, // AbsolutePath doesn't validate existence, only converts to absolute
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, tt.flags)
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
err := tt.flags.setDefaultDestination()
|
||||
validateDefaultDestinationResult(t, tt.flags, err, tt.wantDestination, tt.wantErr, tt.errContains)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlagsSingleton(t *testing.T) {
|
||||
// Capture and restore original os.Args
|
||||
origArgs := os.Args
|
||||
defer func() { os.Args = origArgs }()
|
||||
|
||||
resetFlagsState()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// First call
|
||||
setupCommandLineArgs([]string{shared.TestCLIFlagSource, tempDir, shared.TestCLIFlagFormat, "markdown"})
|
||||
flags1, err := ParseFlags()
|
||||
if err != nil {
|
||||
t.Fatalf("First ParseFlags() failed: %v", err)
|
||||
}
|
||||
|
||||
// Second call should return the same instance
|
||||
flags2, err := ParseFlags()
|
||||
if err != nil {
|
||||
t.Fatalf("Second ParseFlags() failed: %v", err)
|
||||
}
|
||||
|
||||
if flags1 != flags2 {
|
||||
t.Error("ParseFlags() should return singleton instance, got different pointers")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// resetFlagsState resets the global flags state for testing.
|
||||
func resetFlagsState() {
|
||||
flagsParsed = false
|
||||
globalFlags = nil
|
||||
// Reset the flag.CommandLine for clean testing (use ContinueOnError to match ResetFlags)
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
}
|
||||
|
||||
// setupCommandLineArgs sets up command line arguments for testing.
|
||||
func setupCommandLineArgs(args []string) {
|
||||
os.Args = append([]string{"gibidify"}, args...)
|
||||
}
|
||||
|
||||
// containsFlag checks if a flag is present in the arguments.
|
||||
func containsFlag(args []string, flagName string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// verifyFlags compares two Flags structs for testing.
|
||||
func verifyFlags(t *testing.T, got, want *Flags) {
|
||||
t.Helper()
|
||||
|
||||
if got.SourceDir != want.SourceDir {
|
||||
t.Errorf("SourceDir = %v, want %v", got.SourceDir, want.SourceDir)
|
||||
}
|
||||
if got.Destination != want.Destination {
|
||||
t.Errorf("Destination = %v, want %v", got.Destination, want.Destination)
|
||||
}
|
||||
if got.Prefix != want.Prefix {
|
||||
t.Errorf("Prefix = %v, want %v", got.Prefix, want.Prefix)
|
||||
}
|
||||
if got.Suffix != want.Suffix {
|
||||
t.Errorf("Suffix = %v, want %v", got.Suffix, want.Suffix)
|
||||
}
|
||||
if got.Format != want.Format {
|
||||
t.Errorf("Format = %v, want %v", got.Format, want.Format)
|
||||
}
|
||||
if got.Concurrency != want.Concurrency {
|
||||
t.Errorf("Concurrency = %v, want %v", got.Concurrency, want.Concurrency)
|
||||
}
|
||||
if got.NoColors != want.NoColors {
|
||||
t.Errorf("NoColors = %v, want %v", got.NoColors, want.NoColors)
|
||||
}
|
||||
if got.NoProgress != want.NoProgress {
|
||||
t.Errorf("NoProgress = %v, want %v", got.NoProgress, want.NoProgress)
|
||||
}
|
||||
if got.Verbose != want.Verbose {
|
||||
t.Errorf("Verbose = %v, want %v", got.Verbose, want.Verbose)
|
||||
}
|
||||
if got.LogLevel != want.LogLevel {
|
||||
t.Errorf("LogLevel = %v, want %v", got.LogLevel, want.LogLevel)
|
||||
}
|
||||
if got.NoUI != want.NoUI {
|
||||
t.Errorf("NoUI = %v, want %v", got.NoUI, want.NoUI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFlags tests the ResetFlags function.
|
||||
func TestResetFlags(t *testing.T) {
|
||||
// Save original state
|
||||
originalArgs := os.Args
|
||||
originalFlagsParsed := flagsParsed
|
||||
originalGlobalFlags := globalFlags
|
||||
originalCommandLine := flag.CommandLine
|
||||
|
||||
defer func() {
|
||||
// Restore original state
|
||||
os.Args = originalArgs
|
||||
flagsParsed = originalFlagsParsed
|
||||
globalFlags = originalGlobalFlags
|
||||
flag.CommandLine = originalCommandLine
|
||||
}()
|
||||
|
||||
// Simplified test cases to reduce complexity
|
||||
testCases := map[string]func(t *testing.T){
|
||||
"reset after flags have been parsed": func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
testutil.CreateTestFile(t, srcDir, "test.txt", []byte("test"))
|
||||
os.Args = []string{"test", "-source", srcDir, "-destination", "out.json"}
|
||||
|
||||
// Parse flags first
|
||||
if _, err := ParseFlags(); err != nil {
|
||||
t.Fatalf("Setup failed: %v", err)
|
||||
}
|
||||
},
|
||||
"reset with clean state": func(t *testing.T) {
|
||||
if flagsParsed {
|
||||
t.Log("Note: flagsParsed was already true at start")
|
||||
}
|
||||
},
|
||||
"multiple resets": func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
testutil.CreateTestFile(t, srcDir, "test.txt", []byte("test"))
|
||||
os.Args = []string{"test", "-source", srcDir, "-destination", "out.json"}
|
||||
|
||||
err := tt.flags.setDefaultDestination()
|
||||
if _, err := ParseFlags(); err != nil {
|
||||
t.Fatalf("Setup failed: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
for name, setup := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Setup test scenario
|
||||
setup(t)
|
||||
|
||||
// Call ResetFlags
|
||||
ResetFlags()
|
||||
|
||||
// Basic verification that reset worked
|
||||
if flagsParsed {
|
||||
t.Error("flagsParsed should be false after ResetFlags()")
|
||||
}
|
||||
if globalFlags != nil {
|
||||
t.Error("globalFlags should be nil after ResetFlags()")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsSingleton(t *testing.T) {
|
||||
// Save original state
|
||||
oldFlagsParsed := flagsParsed
|
||||
oldGlobalFlags := globalFlags
|
||||
defer func() {
|
||||
flagsParsed = oldFlagsParsed
|
||||
globalFlags = oldGlobalFlags
|
||||
}()
|
||||
// TestResetFlags_Integration tests ResetFlags in integration scenarios.
|
||||
func TestResetFlagsIntegration(t *testing.T) {
|
||||
// This test verifies that ResetFlags properly resets the internal state
|
||||
// to allow multiple calls to ParseFlags in test scenarios.
|
||||
|
||||
// Test singleton behavior
|
||||
flagsParsed = true
|
||||
expectedFlags := &Flags{
|
||||
SourceDir: "/test",
|
||||
Format: "json",
|
||||
Concurrency: 2,
|
||||
// Note: This test documents the expected behavior of ResetFlags
|
||||
// The actual integration with ParseFlags is already tested in main tests
|
||||
// where ResetFlags is used to enable proper test isolation.
|
||||
|
||||
t.Run("state_reset_behavior", func(t *testing.T) {
|
||||
// Test behavior is already covered in TestResetFlags
|
||||
// This is mainly for documentation of the integration pattern
|
||||
|
||||
t.Log("ResetFlags integration behavior:")
|
||||
t.Log("1. Resets flagsParsed to false")
|
||||
t.Log("2. Sets globalFlags to nil")
|
||||
t.Log("3. Creates new flag.CommandLine FlagSet")
|
||||
t.Log("4. Allows subsequent ParseFlags calls")
|
||||
|
||||
// The actual mechanics are tested in TestResetFlags
|
||||
// This test serves to document the integration contract
|
||||
|
||||
// Reset state (this should not panic)
|
||||
ResetFlags()
|
||||
|
||||
// Verify basic state expectations
|
||||
if flagsParsed {
|
||||
t.Error("flagsParsed should be false after ResetFlags")
|
||||
}
|
||||
if globalFlags != nil {
|
||||
t.Error("globalFlags should be nil after ResetFlags")
|
||||
}
|
||||
if flag.CommandLine == nil {
|
||||
t.Error("flag.CommandLine should not be nil after ResetFlags")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmarks for flag-related operations.
|
||||
// While flag parsing is a one-time startup operation, these benchmarks
|
||||
// document baseline performance and catch regressions if parsing logic becomes more complex.
|
||||
//
|
||||
// Note: ParseFlags benchmarks are omitted because resetFlagsState() interferes with
|
||||
// Go's testing framework flags. The core operations (setDefaultDestination, validate)
|
||||
// are benchmarked instead.
|
||||
|
||||
// BenchmarkSetDefaultDestination measures the setDefaultDestination operation.
|
||||
func BenchmarkSetDefaultDestination(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
|
||||
for b.Loop() {
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
}
|
||||
_ = flags.setDefaultDestination()
|
||||
}
|
||||
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()
|
||||
// BenchmarkSetDefaultDestinationAllFormats measures setDefaultDestination across all formats.
|
||||
func BenchmarkSetDefaultDestinationAllFormats(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
formats := []string{"markdown", "json", "yaml"}
|
||||
|
||||
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)
|
||||
for b.Loop() {
|
||||
for _, format := range formats {
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: format,
|
||||
LogLevel: "warn",
|
||||
}
|
||||
_ = flags.setDefaultDestination()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFlagsValidate measures the validate operation.
|
||||
func BenchmarkFlagsValidate(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Destination: "output.md",
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
_ = flags.validate()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFlagsValidateAllFormats measures validate across all formats.
|
||||
func BenchmarkFlagsValidateAllFormats(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
formats := []string{"markdown", "json", "yaml"}
|
||||
|
||||
for b.Loop() {
|
||||
for _, format := range formats {
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Destination: "output." + format,
|
||||
Format: format,
|
||||
LogLevel: "warn",
|
||||
}
|
||||
_ = flags.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// 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, gibidiutils.WrapError(
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
gibidiutils.ErrorTypeProcessing,
|
||||
gibidiutils.CodeProcessingCollection,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
"error collecting files",
|
||||
)
|
||||
}
|
||||
logrus.Infof("Found %d files to process", len(files))
|
||||
|
||||
logger := shared.GetLogger()
|
||||
logger.Infof(shared.CLIMsgFoundFilesToProcess, len(files))
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// validateFileCollection validates the collected files against resource limits.
|
||||
func (p *Processor) validateFileCollection(files []string) error {
|
||||
if !config.GetResourceLimitsEnabled() {
|
||||
if !config.ResourceLimitsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file count limit
|
||||
maxFiles := config.GetMaxFiles()
|
||||
maxFiles := config.MaxFiles()
|
||||
if len(files) > maxFiles {
|
||||
return gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeResourceLimitFiles,
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitFiles,
|
||||
fmt.Sprintf("file count (%d) exceeds maximum limit (%d)", len(files), maxFiles),
|
||||
"",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"file_count": len(files),
|
||||
"max_files": maxFiles,
|
||||
},
|
||||
@@ -48,7 +50,7 @@ func (p *Processor) validateFileCollection(files []string) error {
|
||||
}
|
||||
|
||||
// Check total size limit (estimate)
|
||||
maxTotalSize := config.GetMaxTotalSize()
|
||||
maxTotalSize := config.MaxTotalSize()
|
||||
totalSize := int64(0)
|
||||
oversizedFiles := 0
|
||||
|
||||
@@ -56,16 +58,14 @@ func (p *Processor) validateFileCollection(files []string) error {
|
||||
if fileInfo, err := os.Stat(filePath); err == nil {
|
||||
totalSize += fileInfo.Size()
|
||||
if totalSize > maxTotalSize {
|
||||
return gibidiutils.NewStructuredError(
|
||||
gibidiutils.ErrorTypeValidation,
|
||||
gibidiutils.CodeResourceLimitTotalSize,
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTotalSize,
|
||||
fmt.Sprintf(
|
||||
"total file size (%d bytes) would exceed maximum limit (%d bytes)",
|
||||
totalSize,
|
||||
maxTotalSize,
|
||||
"total file size (%d bytes) would exceed maximum limit (%d bytes)", totalSize, maxTotalSize,
|
||||
),
|
||||
"",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"total_size": totalSize,
|
||||
"max_total_size": maxTotalSize,
|
||||
"files_checked": len(files),
|
||||
@@ -77,10 +77,12 @@ func (p *Processor) validateFileCollection(files []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
if oversizedFiles > 0 {
|
||||
logrus.Warnf("Could not stat %d files during pre-validation", oversizedFiles)
|
||||
logger.Warnf("Could not stat %d files during pre-validation", oversizedFiles)
|
||||
}
|
||||
|
||||
logrus.Infof("Pre-validation passed: %d files, %d MB total", len(files), totalSize/1024/1024)
|
||||
logger.Infof("Pre-validation passed: %d files, %d MB total", len(files), totalSize/int64(shared.BytesPerMB))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Process executes the main file processing workflow.
|
||||
@@ -16,9 +18,7 @@ func (p *Processor) Process(ctx context.Context) error {
|
||||
defer overallCancel()
|
||||
|
||||
// Configure file type registry
|
||||
if err := p.configureFileTypes(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.configureFileTypes()
|
||||
|
||||
// Print startup info with colors
|
||||
p.ui.PrintHeader("🚀 Starting gibidify")
|
||||
@@ -31,23 +31,32 @@ func (p *Processor) Process(ctx context.Context) error {
|
||||
p.resourceMonitor.LogResourceInfo()
|
||||
p.backpressure.LogBackpressureInfo()
|
||||
|
||||
// Collect files with progress indication
|
||||
// Collect files with progress indication and timing
|
||||
p.ui.PrintInfo("📁 Collecting files...")
|
||||
collectionStart := time.Now()
|
||||
files, err := p.collectFiles()
|
||||
collectionTime := time.Since(collectionStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseCollection, collectionTime)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Show collection results
|
||||
p.ui.PrintSuccess("Found %d files to process", len(files))
|
||||
p.ui.PrintSuccess(shared.CLIMsgFoundFilesToProcess, len(files))
|
||||
|
||||
// Pre-validate file collection against resource limits
|
||||
if err := p.validateFileCollection(files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process files with overall timeout
|
||||
return p.processFiles(overallCtx, files)
|
||||
// Process files with overall timeout and timing
|
||||
processingStart := time.Now()
|
||||
err = p.processFiles(overallCtx, files)
|
||||
processingTime := time.Since(processingStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseProcessing, processingTime)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// processFiles processes the collected files.
|
||||
@@ -57,7 +66,7 @@ func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
gibidiutils.LogError("Error closing output file", outFile.Close())
|
||||
shared.LogError("Error closing output file", outFile.Close())
|
||||
}()
|
||||
|
||||
// Initialize back-pressure and channels
|
||||
@@ -67,11 +76,7 @@ func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
writerDone := make(chan struct{})
|
||||
|
||||
// Start writer
|
||||
go fileproc.StartWriter(outFile, writeCh, writerDone, fileproc.WriterConfig{
|
||||
Format: p.flags.Format,
|
||||
Prefix: p.flags.Prefix,
|
||||
Suffix: p.flags.Suffix,
|
||||
})
|
||||
go fileproc.StartWriter(outFile, writeCh, writerDone, p.flags.Format, p.flags.Prefix, p.flags.Suffix)
|
||||
|
||||
// Start workers
|
||||
var wg sync.WaitGroup
|
||||
@@ -83,28 +88,41 @@ func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
// Send files to workers
|
||||
if err := p.sendFiles(ctx, files, fileCh); err != nil {
|
||||
p.ui.FinishProgress()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for completion
|
||||
// Wait for completion with timing
|
||||
writingStart := time.Now()
|
||||
p.waitForCompletion(&wg, writeCh, writerDone)
|
||||
writingTime := time.Since(writingStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseWriting, writingTime)
|
||||
|
||||
p.ui.FinishProgress()
|
||||
|
||||
// Final cleanup with timing
|
||||
finalizeStart := time.Now()
|
||||
p.logFinalStats()
|
||||
finalizeTime := time.Since(finalizeStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseFinalize, finalizeTime)
|
||||
|
||||
p.ui.PrintSuccess("Processing completed. Output saved to %s", p.flags.Destination)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// #nosec G304 - destination is validated in flags.validate()
|
||||
outFile, err := os.Create(p.flags.Destination)
|
||||
outFile, err := os.Create(p.flags.Destination) // #nosec G304 - destination is validated in flags.validate()
|
||||
if err != nil {
|
||||
return nil, gibidiutils.WrapError(
|
||||
err, gibidiutils.ErrorTypeIO, gibidiutils.CodeIOFileCreate,
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOFileCreate,
|
||||
"failed to create output file",
|
||||
).WithFilePath(p.flags.Destination)
|
||||
}
|
||||
|
||||
return outFile, nil
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,44 +1,108 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// logFinalStats logs the final back-pressure and resource monitoring statistics.
|
||||
// logFinalStats logs back-pressure, resource usage, and processing statistics.
|
||||
func (p *Processor) logFinalStats() {
|
||||
// Log back-pressure stats
|
||||
backpressureStats := p.backpressure.GetStats()
|
||||
p.logBackpressureStats()
|
||||
p.logResourceStats()
|
||||
p.finalizeAndReportMetrics()
|
||||
p.logVerboseStats()
|
||||
if p.resourceMonitor != nil {
|
||||
p.resourceMonitor.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// logBackpressureStats logs back-pressure statistics.
|
||||
func (p *Processor) logBackpressureStats() {
|
||||
// Check backpressure is non-nil before dereferencing
|
||||
if p.backpressure == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
backpressureStats := p.backpressure.Stats()
|
||||
if backpressureStats.Enabled {
|
||||
logrus.Infof(
|
||||
logger.Infof(
|
||||
"Back-pressure stats: processed=%d files, memory=%dMB/%dMB",
|
||||
backpressureStats.FilesProcessed,
|
||||
backpressureStats.CurrentMemoryUsage/1024/1024,
|
||||
backpressureStats.MaxMemoryUsage/1024/1024,
|
||||
backpressureStats.CurrentMemoryUsage/int64(shared.BytesPerMB),
|
||||
backpressureStats.MaxMemoryUsage/int64(shared.BytesPerMB),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Log resource monitoring stats
|
||||
resourceStats := p.resourceMonitor.GetMetrics()
|
||||
if config.GetResourceLimitsEnabled() {
|
||||
logrus.Infof("Resource stats: processed=%d files, totalSize=%dMB, avgFileSize=%.2fKB, rate=%.2f files/sec",
|
||||
resourceStats.FilesProcessed, resourceStats.TotalSizeProcessed/1024/1024,
|
||||
resourceStats.AverageFileSize/1024, resourceStats.ProcessingRate)
|
||||
|
||||
if len(resourceStats.ViolationsDetected) > 0 {
|
||||
logrus.Warnf("Resource violations detected: %v", resourceStats.ViolationsDetected)
|
||||
}
|
||||
|
||||
if resourceStats.DegradationActive {
|
||||
logrus.Warnf("Processing completed with degradation mode active")
|
||||
}
|
||||
|
||||
if resourceStats.EmergencyStopActive {
|
||||
logrus.Errorf("Processing completed with emergency stop active")
|
||||
}
|
||||
// logResourceStats logs resource monitoring statistics.
|
||||
func (p *Processor) logResourceStats() {
|
||||
// Check resource monitoring is enabled and monitor is non-nil before dereferencing
|
||||
if !config.ResourceLimitsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up resource monitor
|
||||
p.resourceMonitor.Close()
|
||||
if p.resourceMonitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
resourceStats := p.resourceMonitor.Metrics()
|
||||
|
||||
logger.Infof(
|
||||
"Resource stats: processed=%d files, totalSize=%dMB, avgFileSize=%.2fKB, rate=%.2f files/sec",
|
||||
resourceStats.FilesProcessed, resourceStats.TotalSizeProcessed/int64(shared.BytesPerMB),
|
||||
resourceStats.AverageFileSize/float64(shared.BytesPerKB), resourceStats.ProcessingRate,
|
||||
)
|
||||
|
||||
if len(resourceStats.ViolationsDetected) > 0 {
|
||||
logger.Warnf("Resource violations detected: %v", resourceStats.ViolationsDetected)
|
||||
}
|
||||
|
||||
if resourceStats.DegradationActive {
|
||||
logger.Warnf("Processing completed with degradation mode active")
|
||||
}
|
||||
|
||||
if resourceStats.EmergencyStopActive {
|
||||
logger.Errorf("Processing completed with emergency stop active")
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeAndReportMetrics finalizes metrics collection and displays the final report.
|
||||
func (p *Processor) finalizeAndReportMetrics() {
|
||||
if p.metricsCollector != nil {
|
||||
p.metricsCollector.Finish()
|
||||
}
|
||||
|
||||
if p.metricsReporter != nil {
|
||||
finalReport := p.metricsReporter.ReportFinal()
|
||||
if finalReport != "" && p.ui != nil {
|
||||
// Use UI manager to respect NoUI flag - remove trailing newline if present
|
||||
p.ui.PrintInfo("%s", strings.TrimSuffix(finalReport, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logVerboseStats logs detailed structured statistics when verbose mode is enabled.
|
||||
func (p *Processor) logVerboseStats() {
|
||||
if !p.flags.Verbose || p.metricsCollector == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
report := p.metricsCollector.GenerateReport()
|
||||
fields := map[string]any{
|
||||
"total_files": report.Summary.TotalFiles,
|
||||
"processed_files": report.Summary.ProcessedFiles,
|
||||
"skipped_files": report.Summary.SkippedFiles,
|
||||
"error_files": report.Summary.ErrorFiles,
|
||||
"processing_time": report.Summary.ProcessingTime,
|
||||
"files_per_second": report.Summary.FilesPerSecond,
|
||||
"bytes_per_second": report.Summary.BytesPerSecond,
|
||||
"memory_usage_mb": report.Summary.CurrentMemoryMB,
|
||||
}
|
||||
logger.WithFields(fields).Info("Processing completed with comprehensive metrics")
|
||||
}
|
||||
|
||||
1025
cli/processor_test.go
Normal file
1025
cli/processor_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,20 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/metrics"
|
||||
)
|
||||
|
||||
// Processor handles the main file processing logic.
|
||||
type Processor struct {
|
||||
flags *Flags
|
||||
backpressure *fileproc.BackpressureManager
|
||||
resourceMonitor *fileproc.ResourceMonitor
|
||||
ui *UIManager
|
||||
flags *Flags
|
||||
backpressure *fileproc.BackpressureManager
|
||||
resourceMonitor *fileproc.ResourceMonitor
|
||||
ui *UIManager
|
||||
metricsCollector *metrics.Collector
|
||||
metricsReporter *metrics.Reporter
|
||||
}
|
||||
|
||||
// NewProcessor creates a new processor with the given flags.
|
||||
@@ -18,30 +22,38 @@ func NewProcessor(flags *Flags) *Processor {
|
||||
ui := NewUIManager()
|
||||
|
||||
// Configure UI based on flags
|
||||
ui.SetColorOutput(!flags.NoColors)
|
||||
ui.SetProgressOutput(!flags.NoProgress)
|
||||
ui.SetColorOutput(!flags.NoColors && !flags.NoUI)
|
||||
ui.SetProgressOutput(!flags.NoProgress && !flags.NoUI)
|
||||
ui.SetSilentMode(flags.NoUI)
|
||||
|
||||
// Initialize metrics system
|
||||
metricsCollector := metrics.NewCollector()
|
||||
metricsReporter := metrics.NewReporter(
|
||||
metricsCollector,
|
||||
flags.Verbose && !flags.NoUI,
|
||||
!flags.NoColors && !flags.NoUI,
|
||||
)
|
||||
|
||||
return &Processor{
|
||||
flags: flags,
|
||||
backpressure: fileproc.NewBackpressureManager(),
|
||||
resourceMonitor: fileproc.NewResourceMonitor(),
|
||||
ui: ui,
|
||||
flags: flags,
|
||||
backpressure: fileproc.NewBackpressureManager(),
|
||||
resourceMonitor: fileproc.NewResourceMonitor(),
|
||||
ui: ui,
|
||||
metricsCollector: metricsCollector,
|
||||
metricsReporter: metricsReporter,
|
||||
}
|
||||
}
|
||||
|
||||
// configureFileTypes configures the file type registry.
|
||||
func (p *Processor) configureFileTypes() error {
|
||||
if config.GetFileTypesEnabled() {
|
||||
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
|
||||
}
|
||||
func (p *Processor) configureFileTypes() {
|
||||
if config.FileTypesEnabled() {
|
||||
fileproc.ConfigureFromSettings(
|
||||
config.CustomImageExtensions(),
|
||||
config.CustomBinaryExtensions(),
|
||||
config.CustomLanguages(),
|
||||
config.DisabledImageExtensions(),
|
||||
config.DisabledBinaryExtensions(),
|
||||
config.DisabledLanguageExtensions(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
"github.com/ivuorinen/gibidify/metrics"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// startWorkers starts the worker goroutines.
|
||||
@@ -44,25 +48,69 @@ func (p *Processor) worker(
|
||||
}
|
||||
}
|
||||
|
||||
// processFile processes a single file with resource monitoring.
|
||||
// processFile processes a single file with resource monitoring and metrics collection.
|
||||
func (p *Processor) processFile(ctx context.Context, filePath string, writeCh chan fileproc.WriteRequest) {
|
||||
// Create file processing context with timeout (resourceMonitor may be nil)
|
||||
fileCtx, fileCancel := ctx, func() {}
|
||||
if p.resourceMonitor != nil {
|
||||
fileCtx, fileCancel = p.resourceMonitor.CreateFileProcessingContext(ctx)
|
||||
}
|
||||
defer fileCancel()
|
||||
|
||||
// Track concurrency
|
||||
if p.metricsCollector != nil {
|
||||
p.metricsCollector.IncrementConcurrency()
|
||||
defer p.metricsCollector.DecrementConcurrency()
|
||||
}
|
||||
|
||||
// Check for emergency stop
|
||||
if p.resourceMonitor.IsEmergencyStopActive() {
|
||||
logrus.Warnf("Emergency stop active, skipping file: %s", filePath)
|
||||
if p.resourceMonitor != nil && p.resourceMonitor.IsEmergencyStopActive() {
|
||||
logger := shared.GetLogger()
|
||||
logger.Warnf("Emergency stop active, skipping file: %s", filePath)
|
||||
|
||||
// Record skipped file
|
||||
p.recordFileResult(filePath, 0, "", false, true, "emergency stop active", nil)
|
||||
|
||||
if p.ui != nil {
|
||||
p.ui.UpdateProgress(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
absRoot, err := gibidiutils.GetAbsolutePath(p.flags.SourceDir)
|
||||
absRoot, err := shared.AbsolutePath(p.flags.SourceDir)
|
||||
if err != nil {
|
||||
gibidiutils.LogError("Failed to get absolute path", err)
|
||||
shared.LogError("Failed to get absolute path", err)
|
||||
|
||||
// Record error
|
||||
p.recordFileResult(filePath, 0, "", false, false, "", err)
|
||||
|
||||
if p.ui != nil {
|
||||
p.ui.UpdateProgress(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Use the resource monitor-aware processing
|
||||
fileproc.ProcessFileWithMonitor(ctx, filePath, writeCh, absRoot, p.resourceMonitor)
|
||||
// Use the resource monitor-aware processing with metrics tracking
|
||||
fileSize, format, success, processErr := p.processFileWithMetrics(fileCtx, filePath, writeCh, absRoot)
|
||||
|
||||
// Update progress bar
|
||||
p.ui.UpdateProgress(1)
|
||||
// Record the processing result (skipped=false, skipReason="" since processFileWithMetrics never skips)
|
||||
p.recordFileResult(filePath, fileSize, format, success, false, "", processErr)
|
||||
|
||||
// Update progress bar with metrics
|
||||
if p.ui != nil {
|
||||
p.ui.UpdateProgress(1)
|
||||
}
|
||||
|
||||
// Show real-time stats in verbose mode
|
||||
if p.flags.Verbose && p.metricsCollector != nil {
|
||||
currentMetrics := p.metricsCollector.CurrentMetrics()
|
||||
if currentMetrics.ProcessedFiles%10 == 0 && p.metricsReporter != nil {
|
||||
logger := shared.GetLogger()
|
||||
logger.Info(p.metricsReporter.ReportProgress())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendFiles sends files to the worker channels with back-pressure handling.
|
||||
@@ -78,15 +126,88 @@ func (p *Processor) sendFiles(ctx context.Context, files []string, fileCh chan s
|
||||
// Wait for channel space if needed
|
||||
p.backpressure.WaitForChannelSpace(ctx, fileCh, nil)
|
||||
|
||||
if err := shared.CheckContextCancellation(ctx, shared.CLIMsgFileProcessingWorker); err != nil {
|
||||
return fmt.Errorf("context check failed: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case fileCh <- fp:
|
||||
case <-ctx.Done():
|
||||
if err := shared.CheckContextCancellation(ctx, shared.CLIMsgFileProcessingWorker); err != nil {
|
||||
return fmt.Errorf("context cancellation during channel send: %w", err)
|
||||
}
|
||||
|
||||
return errors.New("context canceled during channel send")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processFileWithMetrics wraps the file processing with detailed metrics collection.
|
||||
func (p *Processor) processFileWithMetrics(
|
||||
ctx context.Context,
|
||||
filePath string,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
absRoot string,
|
||||
) (fileSize int64, format string, success bool, err error) {
|
||||
// Get file info
|
||||
fileInfo, statErr := os.Stat(filePath)
|
||||
if statErr != nil {
|
||||
return 0, "", false, fmt.Errorf("getting file info for %s: %w", filePath, statErr)
|
||||
}
|
||||
|
||||
fileSize = fileInfo.Size()
|
||||
|
||||
// Detect format from file extension
|
||||
format = filepath.Ext(filePath)
|
||||
if format != "" && format[0] == '.' {
|
||||
format = format[1:] // Remove the dot
|
||||
}
|
||||
|
||||
// Use the existing resource monitor-aware processing
|
||||
err = fileproc.ProcessFileWithMonitor(ctx, filePath, writeCh, absRoot, p.resourceMonitor)
|
||||
|
||||
// Check if processing was successful
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fileSize, format, false, fmt.Errorf("file processing worker canceled: %w", ctx.Err())
|
||||
default:
|
||||
if err != nil {
|
||||
return fileSize, format, false, fmt.Errorf("processing file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return fileSize, format, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// recordFileResult records the result of file processing in metrics.
|
||||
func (p *Processor) recordFileResult(
|
||||
filePath string,
|
||||
fileSize int64,
|
||||
format string,
|
||||
success bool,
|
||||
skipped bool,
|
||||
skipReason string,
|
||||
err error,
|
||||
) {
|
||||
if p.metricsCollector == nil {
|
||||
return // No metrics collector, skip recording
|
||||
}
|
||||
|
||||
result := metrics.FileProcessingResult{
|
||||
FilePath: filePath,
|
||||
FileSize: fileSize,
|
||||
Format: format,
|
||||
Success: success,
|
||||
Error: err,
|
||||
Skipped: skipped,
|
||||
SkipReason: skipReason,
|
||||
}
|
||||
|
||||
p.metricsCollector.RecordFileProcessed(result)
|
||||
}
|
||||
|
||||
// waitForCompletion waits for all workers to complete.
|
||||
func (p *Processor) waitForCompletion(
|
||||
wg *sync.WaitGroup,
|
||||
|
||||
138
cli/ui.go
138
cli/ui.go
@@ -1,3 +1,4 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -9,13 +10,14 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gibidify/gibidiutils"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// UIManager handles CLI user interface elements.
|
||||
type UIManager struct {
|
||||
enableColors bool
|
||||
enableProgress bool
|
||||
silentMode bool
|
||||
progressBar *progressbar.ProgressBar
|
||||
output io.Writer
|
||||
}
|
||||
@@ -40,43 +42,42 @@ func (ui *UIManager) SetProgressOutput(enabled bool) {
|
||||
ui.enableProgress = enabled
|
||||
}
|
||||
|
||||
// SetSilentMode enables or disables all UI output.
|
||||
func (ui *UIManager) SetSilentMode(silent bool) {
|
||||
ui.silentMode = silent
|
||||
if silent {
|
||||
ui.output = io.Discard
|
||||
} else {
|
||||
ui.output = os.Stderr
|
||||
}
|
||||
}
|
||||
|
||||
// StartProgress initializes a progress bar for file processing.
|
||||
func (ui *UIManager) StartProgress(total int, description string) {
|
||||
if !ui.enableProgress || total <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 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.OptionSetTheme(
|
||||
progressbar.Theme{
|
||||
Saucer: color.GreenString(shared.UIProgressBarChar),
|
||||
SaucerHead: color.GreenString(shared.UIProgressBarChar),
|
||||
SaucerPadding: " ",
|
||||
BarStart: "[",
|
||||
BarEnd: "]",
|
||||
},
|
||||
),
|
||||
progressbar.OptionShowCount(),
|
||||
progressbar.OptionShowIts(),
|
||||
progressbar.OptionSetWidth(40),
|
||||
progressbar.OptionThrottle(100*time.Millisecond),
|
||||
progressbar.OptionOnCompletion(
|
||||
func() {
|
||||
//nolint:errcheck // UI output, errors don't affect processing
|
||||
_, _ = fmt.Fprint(ui.output, "\n")
|
||||
},
|
||||
),
|
||||
@@ -99,49 +100,62 @@ func (ui *UIManager) FinishProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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...)
|
||||
// PrintSuccess prints a success message in green.
|
||||
func (ui *UIManager) PrintSuccess(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
color.Green("✓ "+format, 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)
|
||||
ui.printf("✓ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintSuccess prints a success message in green (to ui.output if set).
|
||||
func (ui *UIManager) PrintSuccess(format string, args ...interface{}) {
|
||||
ui.writeMessage(gibidiutils.IconSuccess, "PrintSuccess", format, color.GreenString, args...)
|
||||
// PrintError prints an error message in red.
|
||||
func (ui *UIManager) PrintError(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
color.Red("✗ "+format, args...)
|
||||
} else {
|
||||
ui.printf("✗ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintError prints an error message in red (to ui.output if set).
|
||||
func (ui *UIManager) PrintError(format string, args ...interface{}) {
|
||||
ui.writeMessage(gibidiutils.IconError, "PrintError", format, color.RedString, args...)
|
||||
// PrintWarning prints a warning message in yellow.
|
||||
func (ui *UIManager) PrintWarning(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
color.Yellow("⚠ "+format, args...)
|
||||
} else {
|
||||
ui.printf("⚠ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintWarning prints a warning message in yellow (to ui.output if set).
|
||||
func (ui *UIManager) PrintWarning(format string, args ...interface{}) {
|
||||
ui.writeMessage(gibidiutils.IconWarning, "PrintWarning", format, color.YellowString, args...)
|
||||
}
|
||||
|
||||
// PrintInfo prints an info message in blue (to ui.output if set).
|
||||
func (ui *UIManager) PrintInfo(format string, args ...interface{}) {
|
||||
ui.writeMessage(gibidiutils.IconInfo, "PrintInfo", format, color.BlueString, args...)
|
||||
// PrintInfo prints an info message in blue.
|
||||
func (ui *UIManager) PrintInfo(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
//nolint:errcheck // UI output, errors don't affect processing
|
||||
color.Blue("ℹ "+format, args...)
|
||||
} else {
|
||||
ui.printf("ℹ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintHeader prints a header message in bold.
|
||||
func (ui *UIManager) PrintHeader(format string, args ...interface{}) {
|
||||
func (ui *UIManager) PrintHeader(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
//nolint:errcheck // UI output, errors don't affect processing
|
||||
_, _ = color.New(color.Bold).Fprintf(ui.output, format+"\n", args...)
|
||||
} else {
|
||||
ui.printf(format+"\n", args...)
|
||||
@@ -150,11 +164,6 @@ 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" {
|
||||
@@ -164,7 +173,7 @@ func isColorTerminal() bool {
|
||||
// Check for CI environments that typically don't support colors
|
||||
if os.Getenv("CI") != "" {
|
||||
// GitHub Actions supports colors
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
if os.Getenv("GITHUB_ACTIONS") == shared.LiteralTrue {
|
||||
return true
|
||||
}
|
||||
// Most other CI systems don't
|
||||
@@ -176,7 +185,13 @@ func isColorTerminal() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
// Check if FORCE_COLOR is set
|
||||
if os.Getenv("FORCE_COLOR") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to true for interactive terminals
|
||||
return isInteractiveTerminal()
|
||||
}
|
||||
|
||||
// isInteractiveTerminal checks if we're running in an interactive terminal.
|
||||
@@ -186,10 +201,11 @@ func isInteractiveTerminal() bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// printf is a helper that ignores printf errors (for UI output).
|
||||
func (ui *UIManager) printf(format string, args ...interface{}) {
|
||||
func (ui *UIManager) printf(format string, args ...any) {
|
||||
_, _ = fmt.Fprintf(ui.output, format, args...)
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
}
|
||||
531
cli/ui_test.go
Normal file
531
cli/ui_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
func TestNewUIManager(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
|
||||
if ui == nil {
|
||||
t.Error("NewUIManager() returned nil")
|
||||
|
||||
return
|
||||
}
|
||||
if ui.output == nil {
|
||||
t.Error("NewUIManager() did not set output")
|
||||
|
||||
return
|
||||
}
|
||||
if ui.output != os.Stderr {
|
||||
t.Error("NewUIManager() should default output to os.Stderr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerSetColorOutput(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
|
||||
// Test enabling colors
|
||||
ui.SetColorOutput(true)
|
||||
if !ui.enableColors {
|
||||
t.Error("SetColorOutput(true) did not enable colors")
|
||||
}
|
||||
|
||||
// Test disabling colors
|
||||
ui.SetColorOutput(false)
|
||||
if ui.enableColors {
|
||||
t.Error("SetColorOutput(false) did not disable colors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerSetProgressOutput(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
|
||||
// Test enabling progress
|
||||
ui.SetProgressOutput(true)
|
||||
if !ui.enableProgress {
|
||||
t.Error("SetProgressOutput(true) did not enable progress")
|
||||
}
|
||||
|
||||
// Test disabling progress
|
||||
ui.SetProgressOutput(false)
|
||||
if ui.enableProgress {
|
||||
t.Error("SetProgressOutput(false) did not disable progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerStartProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
total int
|
||||
description string
|
||||
enabled bool
|
||||
expectBar bool
|
||||
}{
|
||||
{
|
||||
name: "valid progress with enabled progress",
|
||||
total: 10,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: true,
|
||||
expectBar: true,
|
||||
},
|
||||
{
|
||||
name: "disabled progress should not create bar",
|
||||
total: 10,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: false,
|
||||
expectBar: false,
|
||||
},
|
||||
{
|
||||
name: "zero total should not create bar",
|
||||
total: 0,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: true,
|
||||
expectBar: false,
|
||||
},
|
||||
{
|
||||
name: "negative total should not create bar",
|
||||
total: -1,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: true,
|
||||
expectBar: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetProgressOutput(tt.enabled)
|
||||
|
||||
ui.StartProgress(tt.total, tt.description)
|
||||
|
||||
if tt.expectBar && ui.progressBar == nil {
|
||||
t.Error("StartProgress() should have created progress bar but didn't")
|
||||
}
|
||||
if !tt.expectBar && ui.progressBar != nil {
|
||||
t.Error("StartProgress() should not have created progress bar but did")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerUpdateProgress(t *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetProgressOutput(true)
|
||||
|
||||
// Test with no progress bar (should not panic)
|
||||
ui.UpdateProgress(1)
|
||||
|
||||
// Test with progress bar
|
||||
ui.StartProgress(10, "Test progress")
|
||||
if ui.progressBar == nil {
|
||||
t.Fatal("StartProgress() did not create progress bar")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
ui.UpdateProgress(1)
|
||||
ui.UpdateProgress(5)
|
||||
}
|
||||
|
||||
func TestUIManagerFinishProgress(t *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetProgressOutput(true)
|
||||
|
||||
// Test with no progress bar (should not panic)
|
||||
ui.FinishProgress()
|
||||
|
||||
// Test with progress bar
|
||||
ui.StartProgress(10, "Test progress")
|
||||
if ui.progressBar == nil {
|
||||
t.Fatal("StartProgress() did not create progress bar")
|
||||
}
|
||||
|
||||
ui.FinishProgress()
|
||||
if ui.progressBar != nil {
|
||||
t.Error("FinishProgress() should have cleared progress bar")
|
||||
}
|
||||
}
|
||||
|
||||
// testPrintMethod is a helper function to test UI print methods without duplication.
|
||||
type printMethodTest struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []any
|
||||
expectedText string
|
||||
}
|
||||
|
||||
func testPrintMethod(
|
||||
t *testing.T,
|
||||
methodName string,
|
||||
printFunc func(*UIManager, string, ...any),
|
||||
tests []printMethodTest,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
ui.SetColorOutput(tt.enableColors)
|
||||
|
||||
printFunc(ui, tt.format, tt.args...)
|
||||
|
||||
if !tt.enableColors {
|
||||
outputStr := output.String()
|
||||
if !strings.Contains(outputStr, tt.expectedText) {
|
||||
t.Errorf("%s() output %q should contain %q", methodName, outputStr, tt.expectedText)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Test color method separately (doesn't capture output but shouldn't panic)
|
||||
t.Run(
|
||||
methodName+" with colors should not panic", func(_ *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetColorOutput(true)
|
||||
// Should not panic
|
||||
printFunc(ui, "Test message")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintSuccess(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "success without colors",
|
||||
enableColors: false,
|
||||
format: "Operation completed successfully",
|
||||
args: []any{},
|
||||
expectedText: "✓ Operation completed successfully",
|
||||
},
|
||||
{
|
||||
name: "success with args without colors",
|
||||
enableColors: false,
|
||||
format: "Processed %d files in %s",
|
||||
args: []any{5, "project"},
|
||||
expectedText: "✓ Processed 5 files in project",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintSuccess", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintSuccess(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintError(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "error without colors",
|
||||
enableColors: false,
|
||||
format: "Operation failed",
|
||||
args: []any{},
|
||||
expectedText: "✗ Operation failed",
|
||||
},
|
||||
{
|
||||
name: "error with args without colors",
|
||||
enableColors: false,
|
||||
format: "Failed to process %d files",
|
||||
args: []any{3},
|
||||
expectedText: "✗ Failed to process 3 files",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintError", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintError(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintWarning(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "warning without colors",
|
||||
enableColors: false,
|
||||
format: "This is a warning",
|
||||
args: []any{},
|
||||
expectedText: "⚠ This is a warning",
|
||||
},
|
||||
{
|
||||
name: "warning with args without colors",
|
||||
enableColors: false,
|
||||
format: "Found %d potential issues",
|
||||
args: []any{2},
|
||||
expectedText: "⚠ Found 2 potential issues",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintWarning", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintWarning(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintInfo(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "info without colors",
|
||||
enableColors: false,
|
||||
format: "Information message",
|
||||
args: []any{},
|
||||
expectedText: "ℹ Information message",
|
||||
},
|
||||
{
|
||||
name: "info with args without colors",
|
||||
enableColors: false,
|
||||
format: "Processing file %s",
|
||||
args: []any{"example.go"},
|
||||
expectedText: "ℹ Processing file example.go",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintInfo", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintInfo(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []any
|
||||
expectedText string
|
||||
}{
|
||||
{
|
||||
name: "header without colors",
|
||||
enableColors: false,
|
||||
format: "Main Header",
|
||||
args: []any{},
|
||||
expectedText: "Main Header",
|
||||
},
|
||||
{
|
||||
name: "header with args without colors",
|
||||
enableColors: false,
|
||||
format: "Processing %s Module",
|
||||
args: []any{"CLI"},
|
||||
expectedText: "Processing CLI Module",
|
||||
},
|
||||
{
|
||||
name: "header with colors",
|
||||
enableColors: true,
|
||||
format: "Build Results",
|
||||
args: []any{},
|
||||
expectedText: "Build Results",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
ui.SetColorOutput(tt.enableColors)
|
||||
|
||||
ui.PrintHeader(tt.format, tt.args...)
|
||||
|
||||
outputStr := output.String()
|
||||
if !strings.Contains(outputStr, tt.expectedText) {
|
||||
t.Errorf("PrintHeader() output %q should contain %q", outputStr, tt.expectedText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// colorTerminalTestCase represents a test case for color terminal detection.
|
||||
type colorTerminalTestCase struct {
|
||||
name string
|
||||
term string
|
||||
ci string
|
||||
githubActions string
|
||||
noColor string
|
||||
forceColor string
|
||||
expected bool
|
||||
}
|
||||
|
||||
// clearColorTerminalEnvVars clears all environment variables used for terminal color detection.
|
||||
func clearColorTerminalEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
envVars := []string{"TERM", "CI", "GITHUB_ACTIONS", "NO_COLOR", "FORCE_COLOR"}
|
||||
for _, envVar := range envVars {
|
||||
if err := os.Unsetenv(envVar); err != nil {
|
||||
t.Logf("Failed to unset %s: %v", envVar, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setColorTerminalTestEnv sets up environment variables for a test case.
|
||||
func setColorTerminalTestEnv(t *testing.T, testCase colorTerminalTestCase) {
|
||||
t.Helper()
|
||||
|
||||
envSettings := map[string]string{
|
||||
"TERM": testCase.term,
|
||||
"CI": testCase.ci,
|
||||
"GITHUB_ACTIONS": testCase.githubActions,
|
||||
"NO_COLOR": testCase.noColor,
|
||||
"FORCE_COLOR": testCase.forceColor,
|
||||
}
|
||||
|
||||
for key, value := range envSettings {
|
||||
if value != "" {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsColorTerminal(t *testing.T) {
|
||||
// Save original environment
|
||||
originalEnv := map[string]string{
|
||||
"TERM": os.Getenv("TERM"),
|
||||
"CI": os.Getenv("CI"),
|
||||
"GITHUB_ACTIONS": os.Getenv("GITHUB_ACTIONS"),
|
||||
"NO_COLOR": os.Getenv("NO_COLOR"),
|
||||
"FORCE_COLOR": os.Getenv("FORCE_COLOR"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Restore original environment
|
||||
for key, value := range originalEnv {
|
||||
setEnvOrUnset(key, value)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []colorTerminalTestCase{
|
||||
{
|
||||
name: "dumb terminal",
|
||||
term: "dumb",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty term",
|
||||
term: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "github actions with CI",
|
||||
term: shared.TestTerminalXterm256,
|
||||
ci: "true",
|
||||
githubActions: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "CI without github actions",
|
||||
term: shared.TestTerminalXterm256,
|
||||
ci: "true",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "NO_COLOR set",
|
||||
term: shared.TestTerminalXterm256,
|
||||
noColor: "1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "FORCE_COLOR set",
|
||||
term: shared.TestTerminalXterm256,
|
||||
forceColor: "1",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
clearColorTerminalEnvVars(t)
|
||||
setColorTerminalTestEnv(t, tt)
|
||||
|
||||
result := isColorTerminal()
|
||||
if result != tt.expected {
|
||||
t.Errorf("isColorTerminal() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInteractiveTerminal(_ *testing.T) {
|
||||
// This test is limited because we can't easily mock os.Stderr.Stat()
|
||||
// but we can at least verify it doesn't panic and returns a boolean
|
||||
result := isInteractiveTerminal()
|
||||
|
||||
// Result should be a boolean (true or false, both are valid)
|
||||
// result is already a boolean, so this check is always satisfied
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestUIManagerprintf(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
|
||||
ui.printf("Hello %s", "world")
|
||||
|
||||
expected := "Hello world"
|
||||
if output.String() != expected {
|
||||
t.Errorf("printf() = %q, want %q", output.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set environment variable or unset if empty.
|
||||
func setEnvOrUnset(key, value string) {
|
||||
if value == "" {
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
// In tests, environment variable errors are not critical,
|
||||
// but we should still handle them to avoid linting issues
|
||||
_ = err // explicitly ignore error
|
||||
}
|
||||
} else {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
// In tests, environment variable errors are not critical,
|
||||
// but we should still handle them to avoid linting issues
|
||||
_ = err // explicitly ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for UI workflow.
|
||||
func TestUIManagerIntegration(t *testing.T) {
|
||||
ui, output := createTestUI() //nolint:errcheck // Test helper, output buffer is used
|
||||
ui.SetColorOutput(false) // Disable colors for consistent output
|
||||
ui.SetProgressOutput(false) // Disable progress for testing
|
||||
|
||||
// Simulate a complete UI workflow
|
||||
ui.PrintHeader("Starting Processing")
|
||||
ui.PrintInfo("Initializing system")
|
||||
ui.StartProgress(3, shared.TestProgressMessage)
|
||||
ui.UpdateProgress(1)
|
||||
ui.PrintInfo("Processing file 1")
|
||||
ui.UpdateProgress(1)
|
||||
ui.PrintWarning("Skipping invalid file")
|
||||
ui.UpdateProgress(1)
|
||||
ui.FinishProgress()
|
||||
ui.PrintSuccess("Processing completed successfully")
|
||||
|
||||
outputStr := output.String()
|
||||
|
||||
expectedStrings := []string{
|
||||
"Starting Processing",
|
||||
"ℹ Initializing system",
|
||||
"ℹ Processing file 1",
|
||||
"⚠ Skipping invalid file",
|
||||
"✓ Processing completed successfully",
|
||||
}
|
||||
|
||||
for _, expected := range expectedStrings {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf("Integration test output missing expected string: %q\nFull output:\n%s", expected, outputStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user