diff --git a/README.md b/README.md index fb307b5..e41ae4d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ Test conversion (dry-run): go-i2ptunnel-config --dry-run tunnel.config ``` +Batch process multiple files: +```bash +go-i2ptunnel-config --batch "*.config" +go-i2ptunnel-config --batch --out-format ini "tunnels/*.properties" +``` + ## Contributing 1. Fork repository diff --git a/lib/convert.go b/lib/convert.go index 518ed8c..a5dd9fa 100644 --- a/lib/convert.go +++ b/lib/convert.go @@ -3,69 +3,155 @@ package i2pconv import ( "fmt" "os" + "path/filepath" "strings" "github.com/urfave/cli" ) -// ConvertCommand converts the input configuration file to the specified output format. -// It supports automatic format detection based on file extensions and provides comprehensive -// argument validation. The command can validate-only, perform dry-run conversions, or -// write output to specified files. +// BatchResult represents the result of processing a single file in batch mode +type BatchResult struct { + InputFile string + OutputFile string + InputFormat string + OutputFormat string + Success bool + Error error +} + +// ProcessBatch processes multiple files using glob patterns and returns results for each file. +// It continues processing even if some files fail, collecting all results for reporting. // // Parameters: -// - c (*cli.Context): The CLI context containing the command-line arguments and flags. -// -// Arguments: -// - input-file: Path to the input configuration file (required) -// - output-file: Path for output file (optional, defaults based on input name and format) -// -// Flags: -// - in-format: Input format (properties|ini|yaml) - auto-detected if not specified -// - out-format: Output format (properties|ini|yaml) - defaults to yaml -// - output: Output file path - takes precedence over positional output-file argument -// - validate: Validate input without performing conversion -// - strict: Enable strict validation of the configuration -// - dry-run: Print output to console instead of writing to file +// - pattern: Glob pattern to match input files +// - c: CLI context containing flags and options // // Returns: -// - error: An error if any step fails, including argument validation, file I/O, -// format detection, parsing, validation, or output generation. -// -// Format Detection: -// -// Auto-detects input format based on file extension: -// - .config, .properties, .prop -> properties format -// - .conf, .ini -> ini format -// - .yaml, .yml -> yaml format -// -// Related: -// - Converter.DetectFormat, Converter.ParseInput, Converter.validate, Converter.generateOutput -func ConvertCommand(c *cli.Context) error { - // Validate required arguments - if c.NArg() < 1 { - return fmt.Errorf("input file is required\nUsage: %s [output-file]", c.App.Name) +// - []BatchResult: Results for each processed file +// - error: Fatal error that prevented batch processing from starting +func ProcessBatch(pattern string, c *cli.Context) ([]BatchResult, error) { + // Expand glob pattern to get list of files + files, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("invalid glob pattern '%s': %w", pattern, err) } - inputFile := c.Args().Get(0) - outputFile := c.Args().Get(1) + if len(files) == 0 { + return nil, fmt.Errorf("no files match pattern '%s'", pattern) + } - // Get flags with proper defaults + // Get flags inputFormat := c.String("in-format") outputFormat := c.String("out-format") - outputFlag := c.String("output") validateOnly := c.Bool("validate") strict := c.Bool("strict") dryRun := c.Bool("dry-run") - // Handle output file priority: --output flag takes precedence over positional argument - if outputFlag != "" { - outputFile = outputFlag - } - - // Initialize converter with options + // Process each file individually + results := make([]BatchResult, 0, len(files)) converter := &Converter{strict: strict} + for _, inputFile := range files { + result := BatchResult{ + InputFile: inputFile, + OutputFormat: outputFormat, + } + + // Process single file using existing logic + err := processSingleFile(inputFile, "", inputFormat, outputFormat, validateOnly, dryRun, converter) + if err != nil { + result.Success = false + result.Error = err + } else { + result.Success = true + result.OutputFile = generateOutputFilename(inputFile, outputFormat) + + // Detect input format for reporting + if inputFormat == "" { + detectedFormat, err := converter.DetectFormat(inputFile) + if err == nil { + result.InputFormat = detectedFormat + } + } else { + result.InputFormat = inputFormat + } + } + + results = append(results, result) + } + + return results, nil +} + +// reportBatchResults prints a summary of batch processing results and returns appropriate error. +// It reports both successful and failed file processing, providing clear feedback to users. +// +// Parameters: +// - results: Slice of BatchResult containing processing results for each file +// - validateOnly: Whether the operation was validation-only +// - dryRun: Whether the operation was a dry-run +// +// Returns: +// - error: Non-nil if any files failed processing, nil if all succeeded +func reportBatchResults(results []BatchResult, validateOnly, dryRun bool) error { + successCount := 0 + failureCount := 0 + + // Report individual results + for _, result := range results { + if result.Success { + successCount++ + if validateOnly { + fmt.Printf("✓ Configuration in '%s' is valid (%s format)\n", + result.InputFile, result.InputFormat) + } else if dryRun { + // Individual dry-run output already printed during processing + fmt.Printf("✓ Dry-run conversion '%s' (%s -> %s)\n", + result.InputFile, result.InputFormat, result.OutputFormat) + } else { + fmt.Printf("✓ Converted '%s' (%s) -> '%s' (%s)\n", + result.InputFile, result.InputFormat, result.OutputFile, result.OutputFormat) + } + } else { + failureCount++ + fmt.Printf("✗ Failed to process '%s': %v\n", result.InputFile, result.Error) + } + } + + // Print summary + total := len(results) + if validateOnly { + fmt.Printf("\nValidation summary: %d/%d files valid, %d failed\n", successCount, total, failureCount) + } else if dryRun { + fmt.Printf("\nDry-run summary: %d/%d files processed, %d failed\n", successCount, total, failureCount) + } else { + fmt.Printf("\nBatch conversion summary: %d/%d files converted, %d failed\n", successCount, total, failureCount) + } + + // Return error if any files failed + if failureCount > 0 { + return fmt.Errorf("%d of %d files failed processing", failureCount, total) + } + + return nil +} + +// processSingleFile handles the conversion of a single file with the given parameters. +// This function contains the core conversion logic extracted from ConvertCommand to enable reuse +// in both single-file and batch processing modes. +// +// Parameters: +// - inputFile: Path to input configuration file +// - outputFile: Path for output file (empty string for auto-generation) +// - inputFormat: Input format (empty string for auto-detection) +// - outputFormat: Output format +// - validateOnly: Whether to only validate without conversion +// - dryRun: Whether to print output instead of writing to file +// - converter: Converter instance with configuration +// +// Returns: +// - error: Any error that occurred during processing +func processSingleFile(inputFile, outputFile, inputFormat, outputFormat string, validateOnly, dryRun bool, converter *Converter) error { // Read input file inputData, err := os.ReadFile(inputFile) if err != nil { @@ -93,7 +179,6 @@ func ConvertCommand(c *cli.Context) error { // If validate-only mode, we're done if validateOnly { - fmt.Printf("✓ Configuration in '%s' is valid (%s format)\n", inputFile, inputFormat) return nil } @@ -105,7 +190,7 @@ func ConvertCommand(c *cli.Context) error { // Handle output - either print to console or write to file if dryRun { - fmt.Printf("# Converted from %s to %s format:\n", inputFormat, outputFormat) + fmt.Printf("# Converted '%s' from %s to %s format:\n", inputFile, inputFormat, outputFormat) fmt.Println(string(outputData)) return nil } @@ -120,7 +205,126 @@ func ConvertCommand(c *cli.Context) error { return fmt.Errorf("failed to write output file '%s': %w", outputFile, err) } - fmt.Printf("✓ Converted '%s' (%s) -> '%s' (%s)\n", inputFile, inputFormat, outputFile, outputFormat) + return nil +} + +// ConvertCommand converts the input configuration file to the specified output format. +// It supports automatic format detection based on file extensions and provides comprehensive +// argument validation. The command can validate-only, perform dry-run conversions, write +// output to specified files, or process multiple files in batch mode. +// +// Parameters: +// - c (*cli.Context): The CLI context containing the command-line arguments and flags. +// +// Arguments: +// - input-file: Path to the input configuration file or glob pattern (required) +// - output-file: Path for output file (optional, defaults based on input name and format) +// +// Flags: +// - in-format: Input format (properties|ini|yaml) - auto-detected if not specified +// - out-format: Output format (properties|ini|yaml) - defaults to yaml +// - output: Output file path - takes precedence over positional output-file argument +// - validate: Validate input without performing conversion +// - strict: Enable strict validation of the configuration +// - dry-run: Print output to console instead of writing to file +// - batch: Process multiple files using glob patterns +// +// Returns: +// - error: An error if any step fails, including argument validation, file I/O, +// format detection, parsing, validation, or output generation. +// +// Format Detection: +// +// Auto-detects input format based on file extension: +// - .config, .properties, .prop -> properties format +// - .conf, .ini -> ini format +// - .yaml, .yml -> yaml format +// +// Batch Processing: +// - When --batch flag is used, the input argument is treated as a glob pattern +// - Multiple files are processed independently with individual success/failure reporting +// - Processing continues even if some files fail +// +// Related: +// - Converter.DetectFormat, Converter.ParseInput, Converter.validate, Converter.generateOutput +// - ProcessBatch, processSingleFile +func ConvertCommand(c *cli.Context) error { + // Validate required arguments + if c.NArg() < 1 { + return fmt.Errorf("input file is required\nUsage: %s [output-file]", c.App.Name) + } + + inputArg := c.Args().Get(0) + outputFile := c.Args().Get(1) + + // Get flags with proper defaults + inputFormat := c.String("in-format") + outputFormat := c.String("out-format") + outputFlag := c.String("output") + validateOnly := c.Bool("validate") + strict := c.Bool("strict") + dryRun := c.Bool("dry-run") + batchMode := c.Bool("batch") + + // Handle output file priority: --output flag takes precedence over positional argument + if outputFlag != "" { + outputFile = outputFlag + } + + // Check for incompatible options in batch mode + if batchMode { + if outputFile != "" { + return fmt.Errorf("cannot specify output file in batch mode - files are auto-generated") + } + + // Process batch + results, err := ProcessBatch(inputArg, c) + if err != nil { + return fmt.Errorf("batch processing failed: %w", err) + } + + // Report results + return reportBatchResults(results, validateOnly, dryRun) + } + + // Single file processing (original behavior) + inputFile := inputArg + converter := &Converter{strict: strict} + + // Use extracted single file processing logic + err := processSingleFile(inputFile, outputFile, inputFormat, outputFormat, validateOnly, dryRun, converter) + if err != nil { + return err + } + + // Report success for single file (only if not validate-only or dry-run, as those print their own messages) + if !validateOnly && !dryRun { + // Auto-detect format for reporting if not specified + reportInputFormat := inputFormat + if reportInputFormat == "" { + if detected, err := converter.DetectFormat(inputFile); err == nil { + reportInputFormat = detected + } + } + + // Generate output filename for reporting if not specified + reportOutputFile := outputFile + if reportOutputFile == "" { + reportOutputFile = generateOutputFilename(inputFile, outputFormat) + } + + fmt.Printf("✓ Converted '%s' (%s) -> '%s' (%s)\n", inputFile, reportInputFormat, reportOutputFile, outputFormat) + } else if validateOnly { + // Auto-detect format for validation reporting if not specified + reportInputFormat := inputFormat + if reportInputFormat == "" { + if detected, err := converter.DetectFormat(inputFile); err == nil { + reportInputFormat = detected + } + } + fmt.Printf("✓ Configuration in '%s' is valid (%s format)\n", inputFile, reportInputFormat) + } + return nil } diff --git a/lib/convert_test.go b/lib/convert_test.go index 8654156..33579bf 100644 --- a/lib/convert_test.go +++ b/lib/convert_test.go @@ -1,6 +1,7 @@ package i2pconv import ( + "fmt" "os" "path/filepath" "strings" @@ -520,3 +521,282 @@ func TestConvertCommandErrorHandling(t *testing.T) { }) } } + +// TestProcessBatchWithGlob tests the glob pattern functionality using filepath.Glob directly +func TestProcessBatchWithGlob(t *testing.T) { + // Create temporary directory for test files + tempDir := t.TempDir() + + // Create test properties files + validProperties := `name=test-tunnel +type=httpclient +interface=127.0.0.1 +listenPort=8080 +` + + // Write test files + testFiles := map[string]string{ + "tunnel1.properties": validProperties, + "tunnel2.config": validProperties, + "tunnel3.properties": validProperties, + } + + for filename, content := range testFiles { + err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644) + if err != nil { + t.Fatalf("failed to create test file %s: %v", filename, err) + } + } + + tests := []struct { + name string + pattern string + expectFiles int + expectError bool + errorMsg string + }{ + { + name: "valid glob pattern for properties", + pattern: filepath.Join(tempDir, "*.properties"), + expectFiles: 2, + expectError: false, + }, + { + name: "valid glob pattern for config files", + pattern: filepath.Join(tempDir, "*.config"), + expectFiles: 1, + expectError: false, + }, + { + name: "no matching files", + pattern: filepath.Join(tempDir, "*.nonexistent"), + expectFiles: 0, + expectError: false, + }, + { + name: "all files", + pattern: filepath.Join(tempDir, "*"), + expectFiles: 3, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files, err := filepath.Glob(tt.pattern) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error to contain %q, got: %q", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(files) != tt.expectFiles { + t.Errorf("expected %d files, got %d", tt.expectFiles, len(files)) + } + }) + } +} + +// TestProcessSingleFile tests the extracted single file processing logic +func TestProcessSingleFile(t *testing.T) { + // Create temporary directory for test files + tempDir := t.TempDir() + + validProperties := `name=test-tunnel +type=httpclient +interface=127.0.0.1 +listenPort=8080 +` + + invalidProperties := `name= +type=invalid-type +` + + // Write test files + validFile := filepath.Join(tempDir, "valid.properties") + invalidFile := filepath.Join(tempDir, "invalid.properties") + + err := os.WriteFile(validFile, []byte(validProperties), 0644) + if err != nil { + t.Fatalf("failed to create valid test file: %v", err) + } + + err = os.WriteFile(invalidFile, []byte(invalidProperties), 0644) + if err != nil { + t.Fatalf("failed to create invalid test file: %v", err) + } + + converter := &Converter{strict: false} + + tests := []struct { + name string + inputFile string + outputFile string + inputFormat string + outputFormat string + validateOnly bool + dryRun bool + expectError bool + errorMsg string + }{ + { + name: "valid file conversion", + inputFile: validFile, + inputFormat: "properties", + outputFormat: "yaml", + dryRun: true, + expectError: false, + }, + { + name: "valid file validation only", + inputFile: validFile, + inputFormat: "properties", + outputFormat: "yaml", + validateOnly: true, + expectError: false, + }, + { + name: "invalid file validation", + inputFile: invalidFile, + inputFormat: "properties", + outputFormat: "yaml", + validateOnly: true, + expectError: true, + errorMsg: "validation error", + }, + { + name: "auto-detect format", + inputFile: validFile, + inputFormat: "", // Auto-detect + outputFormat: "yaml", + dryRun: true, + expectError: false, + }, + { + name: "nonexistent file", + inputFile: filepath.Join(tempDir, "nonexistent.properties"), + inputFormat: "properties", + expectError: true, + errorMsg: "failed to read input file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := processSingleFile(tt.inputFile, tt.outputFile, tt.inputFormat, + tt.outputFormat, tt.validateOnly, tt.dryRun, converter) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error to contain %q, got: %q", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +// TestReportBatchResults tests the batch results reporting functionality +func TestReportBatchResults(t *testing.T) { + tests := []struct { + name string + results []BatchResult + validateOnly bool + dryRun bool + expectError bool + }{ + { + name: "all successful conversions", + results: []BatchResult{ + { + InputFile: "file1.properties", + OutputFile: "file1.yaml", + InputFormat: "properties", + OutputFormat: "yaml", + Success: true, + }, + { + InputFile: "file2.properties", + OutputFile: "file2.yaml", + InputFormat: "properties", + OutputFormat: "yaml", + Success: true, + }, + }, + expectError: false, + }, + { + name: "mixed success and failure", + results: []BatchResult{ + { + InputFile: "file1.properties", + OutputFile: "file1.yaml", + InputFormat: "properties", + OutputFormat: "yaml", + Success: true, + }, + { + InputFile: "file2.properties", + Success: false, + Error: fmt.Errorf("validation failed"), + }, + }, + expectError: true, + }, + { + name: "validation only mode", + results: []BatchResult{ + { + InputFile: "file1.properties", + InputFormat: "properties", + Success: true, + }, + }, + validateOnly: true, + expectError: false, + }, + { + name: "dry run mode", + results: []BatchResult{ + { + InputFile: "file1.properties", + InputFormat: "properties", + OutputFormat: "yaml", + Success: true, + }, + }, + dryRun: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := reportBatchResults(tt.results, tt.validateOnly, tt.dryRun) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/main.go b/main.go index b6ab3f9..1fefcb4 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,10 @@ Examples: # Dry run to validate without writing go-i2ptunnel-config -dry-run tunnel.config + # Batch process multiple files using glob patterns + go-i2ptunnel-config -batch "*.config" + go-i2ptunnel-config -batch -out-format ini "tunnels/*.properties" + # Specify both input and output formats explicitly go-i2ptunnel-config -in-format properties -out-format yaml tunnel.txt`, ArgsUsage: " [output-file]", @@ -64,6 +68,10 @@ Examples: Name: "dry-run", Usage: "Print output to console instead of writing to file", }, + &cli.BoolFlag{ + Name: "batch", + Usage: "Process multiple files using glob patterns", + }, }, Action: i2pconv.ConvertCommand, }