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

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

* refactor: migrate from golangci-lint to revive

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

* refactor: rename utils to shared and deduplicate code

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

* fix: resolve SonarQube issues and add safety guards

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

* test: improve test infrastructure and coverage

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

* docs: update documentation and configuration examples

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

* fix: resolve shellcheck warnings in scripts

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

* chore(deps): upgrade go dependencies

* chore(lint): megalinter fixes

* fix: improve test coverage and fix file descriptor leaks

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

* fix: align test comments with function names

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

* fix: various test improvements and bug fixes

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

* chore(yaml): add missing document start markers

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

* fix: guard nil resourceMonitor and fix test deadlock

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

665 lines
16 KiB
Go

package cli
import (
"flag"
"os"
"runtime"
"strings"
"testing"
"github.com/ivuorinen/gibidify/shared"
"github.com/ivuorinen/gibidify/testutil"
)
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
want *Flags
wantErr bool
errContains string
}{
{
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{
shared.TestCLIFlagSource, "testdir",
shared.TestCLIFlagDestination, shared.TestOutputMD,
"-prefix", "# Header",
"-suffix", "# Footer",
shared.TestCLIFlagFormat, "json",
shared.TestCLIFlagConcurrency, "4",
"-verbose",
"-no-colors",
"-no-progress",
},
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{shared.TestCLIFlagFormat, "markdown"},
wantErr: true,
errContains: "source directory is required",
},
{
name: "invalid format",
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagFormat, "invalid"},
wantErr: true,
errContains: "validating output format",
},
{
name: "invalid concurrency zero",
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagConcurrency, "0"},
wantErr: true,
errContains: shared.TestOpValidatingConcurrency,
},
{
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) {
runParseFlagsTest(t, tt.args, tt.want, tt.wantErr, tt.errContains)
},
)
}
}
// 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
wantErr bool
errContains string
}{
{
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",
},
wantErr: true,
errContains: "invalid log level",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
err := tt.flags.validate()
validateFlagsValidationResult(t, err, tt.wantErr, tt.errContains)
},
)
}
}
// 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
wantDestination string
wantErr bool
errContains string
}{
{
name: "set default destination markdown",
flags: &Flags{
SourceDir: tempDir,
Format: "markdown",
LogLevel: "warn",
},
wantDestination: baseName + ".markdown",
wantErr: false,
},
{
name: "set default destination json",
flags: &Flags{
SourceDir: tempDir,
Format: "json",
LogLevel: "warn",
},
wantDestination: baseName + ".json",
wantErr: false,
},
{
name: "set default destination yaml",
flags: &Flags{
SourceDir: tempDir,
Format: "yaml",
LogLevel: "warn",
},
wantDestination: baseName + ".yaml",
wantErr: false,
},
{
name: "preserve existing destination",
flags: &Flags{
SourceDir: tempDir,
Format: "yaml",
Destination: "custom-output.yaml",
LogLevel: "warn",
},
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",
},
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) {
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"}
if _, err := ParseFlags(); err != nil {
t.Fatalf("Setup failed: %v", err)
}
},
}
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()")
}
})
}
}
// 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.
// 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()
}
}
// BenchmarkSetDefaultDestinationAllFormats measures setDefaultDestination across all formats.
func BenchmarkSetDefaultDestinationAllFormats(b *testing.B) {
tempDir := b.TempDir()
formats := []string{"markdown", "json", "yaml"}
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()
}
}
}