Add batch processing support for multiple configuration files

This commit is contained in:
eyedeekay
2025-10-18 21:25:11 -04:00
parent e41f92e943
commit f6488c292a
4 changed files with 544 additions and 46 deletions

View File

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

View File

@@ -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.
//
// 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
//
// 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 <input-file> [output-file]", c.App.Name)
// 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
}
inputFile := c.Args().Get(0)
outputFile := c.Args().Get(1)
// 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:
// - pattern: Glob pattern to match input files
// - c: CLI context containing flags and options
//
// Returns:
// - []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)
}
// Get flags with proper defaults
if len(files) == 0 {
return nil, fmt.Errorf("no files match pattern '%s'", pattern)
}
// 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 <input-file> [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
}

View File

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

View File

@@ -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: "<input-file> [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,
}