Initial release: Go Jdenticon library v0.1.0
- Core library with SVG and PNG generation - CLI tool with generate and batch commands - Cross-platform path handling for Windows compatibility - Comprehensive test suite with integration tests
This commit is contained in:
332
cmd/jdenticon/batch.go
Normal file
332
cmd/jdenticon/batch.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
const (
|
||||
// Maximum filename length to avoid filesystem issues
|
||||
maxFilenameLength = 200
|
||||
)
|
||||
|
||||
var (
|
||||
// Keep a-z, A-Z, 0-9, underscore, hyphen, and period. Replace everything else.
|
||||
sanitizeRegex = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
|
||||
)
|
||||
|
||||
// batchJob represents a single identicon generation job
|
||||
type batchJob struct {
|
||||
value string
|
||||
outputPath string
|
||||
size int
|
||||
}
|
||||
|
||||
// batchStats tracks processing statistics atomically
|
||||
type batchStats struct {
|
||||
processed int64
|
||||
failed int64
|
||||
}
|
||||
|
||||
// batchCmd represents the batch command
|
||||
var batchCmd = &cobra.Command{
|
||||
Use: "batch <input-file>",
|
||||
Short: "Generate multiple identicons from a list using concurrent processing",
|
||||
Long: `Generate multiple identicons from a list of values in a text file.
|
||||
|
||||
The input file should contain one value per line. Each value will be used to generate
|
||||
an identicon saved to the output directory. The filename will be based on the input
|
||||
value with special characters replaced.
|
||||
|
||||
Uses concurrent processing with a worker pool for optimal performance. The number
|
||||
of concurrent workers can be controlled with the --concurrency flag.
|
||||
|
||||
Examples:
|
||||
jdenticon batch users.txt --output-dir ./avatars
|
||||
jdenticon batch emails.txt --output-dir ./avatars --format png --size 64
|
||||
jdenticon batch large-list.txt --output-dir ./avatars --concurrency 8`,
|
||||
Args: cobra.ExactArgs(1), // Validate argument count
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConcurrentBatch(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
|
||||
// Define local flags specific to 'batch'
|
||||
batchCmd.Flags().StringP("output-dir", "d", "", "Output directory for generated identicons (required)")
|
||||
_ = batchCmd.MarkFlagRequired("output-dir")
|
||||
|
||||
// Concurrency control
|
||||
batchCmd.Flags().IntP("concurrency", "c", runtime.NumCPU(),
|
||||
fmt.Sprintf("Number of concurrent workers (default: %d)", runtime.NumCPU()))
|
||||
}
|
||||
|
||||
// runConcurrentBatch executes the batch processing with concurrent workers
|
||||
func runConcurrentBatch(cmd *cobra.Command, args []string) error {
|
||||
inputFile := args[0]
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
concurrency, _ := cmd.Flags().GetInt("concurrency")
|
||||
|
||||
// Validate concurrency value
|
||||
if concurrency <= 0 {
|
||||
return fmt.Errorf("concurrency must be positive, got %d", concurrency)
|
||||
}
|
||||
|
||||
// Get format from viper
|
||||
format, err := getFormatFromViper()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Populate library config from root persistent flags
|
||||
config, size, err := populateConfigFromFlags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create generator with custom config and larger cache for concurrent workload
|
||||
cacheSize := generatorCacheSize * concurrency // Scale cache with worker count
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, cacheSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create generator: %w", err)
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
// #nosec G301 -- 0755 is standard for directories; CLI tool needs world-readable output
|
||||
if err = os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Set up graceful shutdown context
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Process the file and collect jobs
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, format, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No valid entries found in input file\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize progress reporting
|
||||
showProgress := isTTY(os.Stderr)
|
||||
var bar *progressbar.ProgressBar
|
||||
if showProgress {
|
||||
bar = createProgressBar(total)
|
||||
}
|
||||
|
||||
// Execute concurrent processing
|
||||
stats, err := processConcurrentJobs(ctx, jobs, generator, format, concurrency, bar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Final status message
|
||||
processed := atomic.LoadInt64(&stats.processed)
|
||||
failed := atomic.LoadInt64(&stats.failed)
|
||||
|
||||
if showProgress {
|
||||
fmt.Fprintf(os.Stderr, "\nBatch processing complete: %d succeeded, %d failed\n", processed, failed)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Batch processing complete: %d succeeded, %d failed\n", processed, failed)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("some identicons failed to generate")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareJobs reads the input file and creates a slice of jobs
|
||||
func prepareJobs(inputFile, outputDir string, format FormatFlag, size int) ([]batchJob, int, error) {
|
||||
// #nosec G304 -- inputFile is provided via CLI flag, validated by cobra
|
||||
file, err := os.Open(inputFile)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var jobs []batchJob
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
value := strings.TrimSpace(scanner.Text())
|
||||
if value == "" {
|
||||
continue // Skip empty lines
|
||||
}
|
||||
|
||||
// Generate filename from value (sanitize for filesystem)
|
||||
filename := sanitizeFilename(value)
|
||||
extension := ".svg"
|
||||
if format == FormatPNG {
|
||||
extension = ".png"
|
||||
}
|
||||
outputPath := filepath.Join(outputDir, filename+extension)
|
||||
|
||||
jobs = append(jobs, batchJob{
|
||||
value: value,
|
||||
outputPath: outputPath,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("error reading input file: %w", err)
|
||||
}
|
||||
|
||||
return jobs, len(jobs), nil
|
||||
}
|
||||
|
||||
// createProgressBar creates a progress bar for the batch processing
|
||||
func createProgressBar(total int) *progressbar.ProgressBar {
|
||||
return progressbar.NewOptions(total,
|
||||
progressbar.OptionSetWriter(os.Stderr),
|
||||
progressbar.OptionSetDescription("Processing identicons..."),
|
||||
progressbar.OptionEnableColorCodes(true),
|
||||
progressbar.OptionShowCount(),
|
||||
progressbar.OptionSetWidth(15),
|
||||
progressbar.OptionSetTheme(progressbar.Theme{
|
||||
Saucer: "[green]=[reset]",
|
||||
SaucerHead: "[green]>[reset]",
|
||||
SaucerPadding: " ",
|
||||
BarStart: "[",
|
||||
BarEnd: "]",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// processConcurrentJobs executes jobs using a worker pool pattern
|
||||
func processConcurrentJobs(ctx context.Context, jobs []batchJob, generator *jdenticon.Generator,
|
||||
format FormatFlag, concurrency int, bar *progressbar.ProgressBar) (*batchStats, error) {
|
||||
stats := &batchStats{}
|
||||
jobChan := make(chan batchJob, len(jobs))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start worker goroutines
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
batchWorker(ctx, workerID, jobChan, generator, format, stats, bar)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Send jobs to workers
|
||||
go func() {
|
||||
defer close(jobChan)
|
||||
for _, job := range jobs {
|
||||
select {
|
||||
case jobChan <- job:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for all workers to complete
|
||||
wg.Wait()
|
||||
|
||||
// Check if processing was interrupted
|
||||
if ctx.Err() != nil {
|
||||
return stats, fmt.Errorf("processing interrupted: %w", ctx.Err())
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// batchWorker processes jobs from the job channel
|
||||
func batchWorker(ctx context.Context, workerID int, jobs <-chan batchJob, generator *jdenticon.Generator,
|
||||
format FormatFlag, stats *batchStats, bar *progressbar.ProgressBar) {
|
||||
for {
|
||||
select {
|
||||
case job, ok := <-jobs:
|
||||
if !ok {
|
||||
// Channel closed, no more jobs
|
||||
return
|
||||
}
|
||||
|
||||
// Process the job with context for cancellation support
|
||||
if err := processJob(ctx, job, generator, format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Worker %d failed to process %q: %v\n", workerID, job.value, err)
|
||||
atomic.AddInt64(&stats.failed, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&stats.processed, 1)
|
||||
}
|
||||
|
||||
// Update progress bar if available
|
||||
if bar != nil {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
// Shutdown signal received
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processJob handles a single identicon generation job
|
||||
func processJob(ctx context.Context, job batchJob, generator *jdenticon.Generator, format FormatFlag) error {
|
||||
// Generate identicon with context for cancellation support
|
||||
icon, err := generator.Generate(ctx, job.value, job.size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate identicon: %w", err)
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
result, err := renderIcon(icon, format)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render %s: %w", format, err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
// #nosec G306 -- 0644 is appropriate for generated image files (world-readable)
|
||||
if err := os.WriteFile(job.outputPath, result, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeFilename converts a value to a safe filename using a whitelist approach
|
||||
func sanitizeFilename(value string) string {
|
||||
// Replace @ separately for readability if desired
|
||||
filename := strings.ReplaceAll(value, "@", "_at_")
|
||||
|
||||
// Replace all other invalid characters with a single underscore
|
||||
filename = sanitizeRegex.ReplaceAllString(filename, "_")
|
||||
|
||||
// Limit length to avoid filesystem issues
|
||||
if len(filename) > maxFilenameLength {
|
||||
filename = filename[:maxFilenameLength]
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
// isTTY checks if the given file descriptor is a terminal
|
||||
func isTTY(f *os.File) bool {
|
||||
return isatty.IsTerminal(f.Fd())
|
||||
}
|
||||
240
cmd/jdenticon/batch_bench_test.go
Normal file
240
cmd/jdenticon/batch_bench_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// benchmarkSizes defines different test scenarios for batch processing
|
||||
var benchmarkSizes = []struct {
|
||||
name string
|
||||
count int
|
||||
}{
|
||||
{"Small", 50},
|
||||
{"Medium", 200},
|
||||
{"Large", 1000},
|
||||
}
|
||||
|
||||
// BenchmarkBatchProcessing_Sequential benchmarks sequential processing (concurrency=1)
|
||||
func BenchmarkBatchProcessing_Sequential(b *testing.B) {
|
||||
for _, size := range benchmarkSizes {
|
||||
b.Run(size.name, func(b *testing.B) {
|
||||
benchmarkBatchWithConcurrency(b, size.count, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBatchProcessing_Concurrent benchmarks concurrent processing with different worker counts
|
||||
func BenchmarkBatchProcessing_Concurrent(b *testing.B) {
|
||||
concurrencyLevels := []int{2, 4, runtime.NumCPU(), runtime.NumCPU() * 2}
|
||||
|
||||
for _, size := range benchmarkSizes {
|
||||
for _, concurrency := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("%s_Workers%d", size.name, concurrency), func(b *testing.B) {
|
||||
benchmarkBatchWithConcurrency(b, size.count, concurrency)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// benchmarkBatchWithConcurrency runs a benchmark with specific parameters
|
||||
func benchmarkBatchWithConcurrency(b *testing.B, iconCount, concurrency int) {
|
||||
// Create temporary directory for test
|
||||
tempDir := b.TempDir()
|
||||
inputFile := filepath.Join(tempDir, "test-input.txt")
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
// Generate test input file
|
||||
createTestInputFile(b, inputFile, iconCount)
|
||||
|
||||
// Create generator for testing with complexity limits disabled for consistent benchmarks
|
||||
config, err := jdenticon.Configure(jdenticon.WithMaxComplexity(-1))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, concurrency*100)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Clean and recreate output directory for each iteration
|
||||
os.RemoveAll(outputDir)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
b.Fatalf("Failed to create output directory: %v", err)
|
||||
}
|
||||
|
||||
// Measure processing time
|
||||
start := time.Now()
|
||||
|
||||
// Execute batch processing
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to prepare jobs: %v", err)
|
||||
}
|
||||
|
||||
if total != iconCount {
|
||||
b.Fatalf("Expected %d jobs, got %d", iconCount, total)
|
||||
}
|
||||
|
||||
// Process jobs (simplified version without progress bar for benchmarking)
|
||||
stats := processBenchmarkJobs(jobs, generator, FormatSVG, concurrency)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Verify all jobs completed successfully
|
||||
processed := atomic.LoadInt64(&stats.processed)
|
||||
failed := atomic.LoadInt64(&stats.failed)
|
||||
|
||||
if processed != int64(iconCount) {
|
||||
b.Fatalf("Expected %d processed, got %d", iconCount, processed)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
b.Fatalf("Expected 0 failures, got %d", failed)
|
||||
}
|
||||
|
||||
// Report custom metrics
|
||||
b.ReportMetric(float64(iconCount)/duration.Seconds(), "icons/sec")
|
||||
b.ReportMetric(float64(concurrency), "workers")
|
||||
}
|
||||
}
|
||||
|
||||
// processBenchmarkJobs executes jobs for benchmarking (without context cancellation)
|
||||
func processBenchmarkJobs(jobs []batchJob, generator *jdenticon.Generator, format FormatFlag, concurrency int) *batchStats {
|
||||
stats := &batchStats{}
|
||||
jobChan := make(chan batchJob, len(jobs))
|
||||
|
||||
// Start workers
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for job := range jobChan {
|
||||
if err := processJob(context.Background(), job, generator, format); err != nil {
|
||||
atomic.AddInt64(&stats.failed, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&stats.processed, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Send jobs
|
||||
go func() {
|
||||
defer close(jobChan)
|
||||
for _, job := range jobs {
|
||||
jobChan <- job
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for completion
|
||||
for i := 0; i < concurrency; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// createTestInputFile generates a test input file with specified number of entries
|
||||
func createTestInputFile(b *testing.B, filename string, count int) {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create test input file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < count; i++ {
|
||||
builder.WriteString(fmt.Sprintf("user%d@example.com\n", i))
|
||||
}
|
||||
|
||||
if _, err := file.WriteString(builder.String()); err != nil {
|
||||
b.Fatalf("Failed to write test input file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJobPreparation benchmarks the job preparation phase
|
||||
func BenchmarkJobPreparation(b *testing.B) {
|
||||
for _, size := range benchmarkSizes {
|
||||
b.Run(size.name, func(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
inputFile := filepath.Join(tempDir, "test-input.txt")
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
createTestInputFile(b, inputFile, size.count)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to prepare jobs: %v", err)
|
||||
}
|
||||
|
||||
if total != size.count {
|
||||
b.Fatalf("Expected %d jobs, got %d", size.count, total)
|
||||
}
|
||||
|
||||
// Prevent compiler optimization
|
||||
_ = jobs
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSingleJobProcessing benchmarks individual job processing
|
||||
func BenchmarkSingleJobProcessing(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
config, err := jdenticon.Configure(jdenticon.WithMaxComplexity(-1))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, 100)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
job := batchJob{
|
||||
value: "test@example.com",
|
||||
outputPath: filepath.Join(tempDir, "test.svg"),
|
||||
size: 64,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := processJob(context.Background(), job, generator, FormatSVG); err != nil {
|
||||
b.Fatalf("Failed to process job: %v", err)
|
||||
}
|
||||
|
||||
// Clean up for next iteration
|
||||
os.Remove(job.outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConcurrencyScaling analyzes how performance scales with worker count
|
||||
func BenchmarkConcurrencyScaling(b *testing.B) {
|
||||
const iconCount = 500
|
||||
maxWorkers := runtime.NumCPU() * 2
|
||||
|
||||
for workers := 1; workers <= maxWorkers; workers *= 2 {
|
||||
b.Run(fmt.Sprintf("Workers%d", workers), func(b *testing.B) {
|
||||
benchmarkBatchWithConcurrency(b, iconCount, workers)
|
||||
})
|
||||
}
|
||||
}
|
||||
599
cmd/jdenticon/batch_test.go
Normal file
599
cmd/jdenticon/batch_test.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// TestBatchCommand tests the batch command functionality
|
||||
func TestBatchCommand(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-batch-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test input file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
testInputs := []string{
|
||||
"user1@example.com",
|
||||
"user2@example.com",
|
||||
"test-user",
|
||||
"unicode-üser",
|
||||
"", // Empty line should be skipped
|
||||
"special@chars!",
|
||||
}
|
||||
inputContent := strings.Join(testInputs, "\n")
|
||||
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
outputCheck func(t *testing.T, outputDir string)
|
||||
}{
|
||||
{
|
||||
name: "batch generate SVG",
|
||||
args: []string{"batch", inputFile, "--output-dir", outputDir},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Check that SVG files were created
|
||||
expectedFiles := []string{
|
||||
"user1_at_example.com.svg",
|
||||
"user2_at_example.com.svg",
|
||||
"test-user.svg",
|
||||
"unicode-_ser.svg", // Unicode characters get sanitized
|
||||
"special_at_chars_.svg", // Special chars get sanitized
|
||||
}
|
||||
|
||||
for _, filename := range expectedFiles {
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected file %s to be created", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file content
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read file %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "<svg") {
|
||||
t.Errorf("File %s should contain SVG content", filename)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "batch generate PNG",
|
||||
args: []string{"batch", "--format", "png", inputFile, "--output-dir", outputDir + "-png"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Check that PNG files were created
|
||||
expectedFiles := []string{
|
||||
"user1_at_example.com.png",
|
||||
"user2_at_example.com.png",
|
||||
}
|
||||
|
||||
for _, filename := range expectedFiles {
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected file %s to be created", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check PNG magic bytes
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read file %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(content) < 8 || string(content[:4]) != "\x89PNG" {
|
||||
t.Errorf("File %s should contain PNG content", filename)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing output-dir",
|
||||
args: []string{"batch", inputFile},
|
||||
wantErr: true,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Should not create any files
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing input file",
|
||||
args: []string{"batch", "nonexistent.txt", "--output-dir", outputDir},
|
||||
wantErr: true,
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Should not create any files
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no arguments",
|
||||
args: []string{},
|
||||
wantErr: false, // Root command shows help, doesn't error
|
||||
outputCheck: func(t *testing.T, outputDir string) {
|
||||
// Should not create any files
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
cmd := createTestBatchCommand()
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("batchCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.outputCheck != nil {
|
||||
// Extract output dir from args
|
||||
var testOutputDir string
|
||||
for i, arg := range tt.args {
|
||||
if arg == "--output-dir" && i+1 < len(tt.args) {
|
||||
testOutputDir = tt.args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if testOutputDir != "" {
|
||||
tt.outputCheck(t, testOutputDir)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchSanitizeFilename tests filename sanitization
|
||||
func TestBatchSanitizeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "user@example.com",
|
||||
expected: "user_at_example.com",
|
||||
},
|
||||
{
|
||||
input: "test-user_123",
|
||||
expected: "test-user_123",
|
||||
},
|
||||
{
|
||||
input: "unicode-üser",
|
||||
expected: "unicode-_ser",
|
||||
},
|
||||
{
|
||||
input: "special!@#$%^&*()",
|
||||
expected: "special__at__",
|
||||
},
|
||||
{
|
||||
input: "very-long-username-with-many-characters-that-exceeds-normal-length-limits-and-should-be-truncated-to-prevent-filesystem-issues",
|
||||
expected: func() string {
|
||||
longStr := "very-long-username-with-many-characters-that-exceeds-normal-length-limits-and-should-be-truncated-to-prevent-filesystem-issues"
|
||||
if len(longStr) > 200 {
|
||||
return longStr[:200]
|
||||
}
|
||||
return longStr
|
||||
}(),
|
||||
},
|
||||
{
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := sanitizeFilename(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchWithCustomConfig tests batch generation with custom configuration
|
||||
func TestBatchWithCustomConfig(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-batch-config-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create simple input file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
if err := os.WriteFile(inputFile, []byte("test@example.com\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
outputCheck func(t *testing.T, outputPath string)
|
||||
}{
|
||||
{
|
||||
name: "custom size",
|
||||
args: []string{"batch", "--size", "128", inputFile, "--output-dir", outputDir + "-size"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputPath string) {
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "width=\"128\"") {
|
||||
t.Error("Expected SVG to have custom size")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom background color",
|
||||
args: []string{"batch", "--bg-color", "#ff0000", inputFile, "--output-dir", outputDir + "-bg"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputPath string) {
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "#ff0000") {
|
||||
t.Error("Expected SVG to have custom background color")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom padding",
|
||||
args: []string{"batch", "--padding", "0.2", inputFile, "--output-dir", outputDir + "-pad"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, outputPath string) {
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output: %v", err)
|
||||
}
|
||||
// Should still generate valid SVG
|
||||
if !strings.Contains(string(content), "<svg") {
|
||||
t.Error("Expected valid SVG output")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
cmd := createTestBatchCommand()
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("batchCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.outputCheck != nil {
|
||||
// Find the output directory and check the generated file
|
||||
var testOutputDir string
|
||||
for i, arg := range tt.args {
|
||||
if arg == "--output-dir" && i+1 < len(tt.args) {
|
||||
testOutputDir = tt.args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if testOutputDir != "" {
|
||||
expectedFile := filepath.Join(testOutputDir, "test_at_example.com.svg")
|
||||
tt.outputCheck(t, expectedFile)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestBatchCommand creates a batch command for testing
|
||||
func createTestBatchCommand() *cobra.Command {
|
||||
// Create root command with flags
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
}
|
||||
|
||||
// Initialize root flags
|
||||
initTestFlags(rootCmd)
|
||||
|
||||
// Create batch command similar to the actual one
|
||||
batchCmd := &cobra.Command{
|
||||
Use: "batch <input-file>",
|
||||
Short: "Generate multiple identicons from a list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConcurrentBatch(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
// Add batch-specific flags
|
||||
batchCmd.Flags().StringP("output-dir", "d", "", "Output directory for generated identicons (required)")
|
||||
batchCmd.MarkFlagRequired("output-dir")
|
||||
|
||||
// Concurrency control
|
||||
batchCmd.Flags().IntP("concurrency", "c", runtime.NumCPU(),
|
||||
fmt.Sprintf("Number of concurrent workers (default: %d)", runtime.NumCPU()))
|
||||
|
||||
// Add to root command
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// TestConcurrentBatchProcessing tests the concurrent batch processing functionality
|
||||
func TestConcurrentBatchProcessing(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-concurrent-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test input file with more entries to test concurrency
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
var inputs []string
|
||||
for i := 0; i < 50; i++ {
|
||||
inputs = append(inputs, fmt.Sprintf("user%d@example.com", i))
|
||||
}
|
||||
inputContent := strings.Join(inputs, "\n")
|
||||
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
concurrency int
|
||||
expectFiles int
|
||||
}{
|
||||
{"sequential", 1, 50},
|
||||
{"small_pool", 2, 50},
|
||||
{"medium_pool", 4, 50},
|
||||
{"large_pool", runtime.NumCPU(), 50},
|
||||
{"over_provisioned", runtime.NumCPU() * 2, 50},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clean output directory
|
||||
os.RemoveAll(outputDir)
|
||||
|
||||
// Test the concurrent batch command
|
||||
cmd := createTestBatchCommand()
|
||||
args := []string{"batch", inputFile, "--output-dir", outputDir, "--concurrency", fmt.Sprintf("%d", tt.concurrency)}
|
||||
cmd.SetArgs(args)
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Execute()
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Command failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify output files
|
||||
files, err := os.ReadDir(outputDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != tt.expectFiles {
|
||||
t.Errorf("Expected %d files, got %d", tt.expectFiles, len(files))
|
||||
}
|
||||
|
||||
// Verify all files are valid SVG
|
||||
for _, file := range files {
|
||||
if !strings.HasSuffix(file.Name(), ".svg") {
|
||||
t.Errorf("Expected SVG file, got %s", file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(outputDir, file.Name()))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read file %s: %v", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "<svg") {
|
||||
t.Errorf("File %s does not contain SVG content", file.Name())
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Processed %d files with %d workers in %v", tt.expectFiles, tt.concurrency, duration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobPreparation tests the job preparation functionality
|
||||
func TestJobPreparation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-job-prep-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Test with various input scenarios
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expectJobs int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "normal_inputs",
|
||||
content: "user1@example.com\nuser2@example.com\nuser3@example.com",
|
||||
expectJobs: 3,
|
||||
},
|
||||
{
|
||||
name: "with_empty_lines",
|
||||
content: "user1@example.com\n\nuser2@example.com\n\n",
|
||||
expectJobs: 2,
|
||||
},
|
||||
{
|
||||
name: "only_empty_lines",
|
||||
content: "\n\n\n",
|
||||
expectJobs: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed_content",
|
||||
content: "user1@example.com\n \nuser2@example.com\n\t\nuser3@example.com",
|
||||
expectJobs: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
if err := os.WriteFile(inputFile, []byte(tt.content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error, got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if total != tt.expectJobs {
|
||||
t.Errorf("Expected %d jobs, got %d", tt.expectJobs, total)
|
||||
}
|
||||
|
||||
if len(jobs) != tt.expectJobs {
|
||||
t.Errorf("Expected %d jobs in slice, got %d", tt.expectJobs, len(jobs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchWorkerShutdown tests graceful shutdown of batch workers
|
||||
func TestBatchWorkerShutdown(t *testing.T) {
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create generator: %v", err)
|
||||
}
|
||||
|
||||
// Create a temp directory for output files (cross-platform)
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a context that will be canceled
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create job channel with some jobs
|
||||
jobChan := make(chan batchJob, 10)
|
||||
for i := 0; i < 5; i++ {
|
||||
jobChan <- batchJob{
|
||||
value: fmt.Sprintf("user%d@example.com", i),
|
||||
outputPath: filepath.Join(tempDir, fmt.Sprintf("test%d.svg", i)),
|
||||
size: 64,
|
||||
}
|
||||
}
|
||||
|
||||
stats := &batchStats{}
|
||||
|
||||
// Start worker
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
batchWorker(ctx, 1, jobChan, generator, FormatSVG, stats, nil)
|
||||
}()
|
||||
|
||||
// Let it process a bit, then cancel
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Wait for worker to shutdown (should be quick)
|
||||
select {
|
||||
case <-done:
|
||||
// Good, worker shut down gracefully
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Worker did not shut down within timeout")
|
||||
}
|
||||
|
||||
// Verify some jobs were processed
|
||||
processed := atomic.LoadInt64(&stats.processed)
|
||||
t.Logf("Processed %d jobs before shutdown", processed)
|
||||
}
|
||||
|
||||
// TestConcurrencyFlagValidation tests the validation of concurrency flag
|
||||
func TestConcurrencyFlagValidation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-concurrency-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create minimal input file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
if err := os.WriteFile(inputFile, []byte("test@example.com\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "output")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
concurrency string
|
||||
expectError bool
|
||||
}{
|
||||
{"valid_positive", "4", false},
|
||||
{"valid_one", "1", false},
|
||||
{"invalid_zero", "0", true},
|
||||
{"invalid_negative", "-1", true},
|
||||
{"invalid_non_numeric", "abc", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := createTestBatchCommand()
|
||||
args := []string{"batch", inputFile, "--output-dir", outputDir, "--concurrency", tt.concurrency}
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error for concurrency %s, got none", tt.concurrency)
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error for concurrency %s: %v", tt.concurrency, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
83
cmd/jdenticon/doc.go
Normal file
83
cmd/jdenticon/doc.go
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
The jdenticon command provides a command-line interface for generating Jdenticon
|
||||
identicons.
|
||||
|
||||
It serves as a wrapper around the `jdenticon` library, allowing users to generate
|
||||
icons directly from their terminal without writing any Go code. The tool supports
|
||||
both single icon generation and high-performance batch processing with concurrent
|
||||
workers.
|
||||
|
||||
# Usage
|
||||
|
||||
Single icon generation:
|
||||
|
||||
jdenticon [flags] <input-string>
|
||||
|
||||
Batch processing:
|
||||
|
||||
jdenticon batch [flags] <input-file>
|
||||
|
||||
# Single Icon Examples
|
||||
|
||||
1. Generate a default SVG icon and save it to a file:
|
||||
|
||||
jdenticon "my-awesome-user" > avatar.svg
|
||||
|
||||
2. Generate a 128x128 PNG icon with a custom output path:
|
||||
|
||||
jdenticon --size=128 --format=png --output=avatar.png "my-awesome-user"
|
||||
|
||||
3. Generate an SVG with custom styling:
|
||||
|
||||
jdenticon --hue=0.5 --saturation=0.8 --padding=0.1 "user@example.com" > avatar.svg
|
||||
|
||||
# Batch Processing Examples
|
||||
|
||||
1. Generate icons for multiple users (one per line in input file):
|
||||
|
||||
jdenticon batch users.txt --output-dir ./avatars
|
||||
|
||||
2. High-performance concurrent batch processing:
|
||||
|
||||
jdenticon batch large-list.txt --output-dir ./avatars --concurrency 8 --format png
|
||||
|
||||
3. Sequential processing (disable concurrency):
|
||||
|
||||
jdenticon batch users.txt --output-dir ./avatars --concurrency 1
|
||||
|
||||
# Available Flags
|
||||
|
||||
## Global Flags (apply to both single and batch generation):
|
||||
- --size: Icon size in pixels (default: 200)
|
||||
- --format: Output format, either "svg" or "png" (default: "svg")
|
||||
- --padding: Padding as percentage between 0.0 and 0.5 (default: 0.08)
|
||||
- --color-saturation: Saturation for colored shapes between 0.0 and 1.0 (default: 0.5)
|
||||
- --grayscale-saturation: Saturation for grayscale shapes between 0.0 and 1.0 (default: 0.0)
|
||||
- --bg-color: Background color in hex format (e.g., "#ffffff")
|
||||
- --hue-restrictions: Restrict hues to specific degrees (0-360)
|
||||
- --color-lightness: Color lightness range as min,max (default: "0.4,0.8")
|
||||
- --grayscale-lightness: Grayscale lightness range as min,max (default: "0.3,0.9")
|
||||
|
||||
## Single Icon Flags:
|
||||
- --output: Output file path (default: stdout)
|
||||
|
||||
## Batch Processing Flags:
|
||||
- --output-dir: Output directory for generated identicons (required)
|
||||
- --concurrency: Number of concurrent workers (default: CPU count)
|
||||
|
||||
# Performance Features
|
||||
|
||||
The batch command uses a high-performance worker pool pattern for concurrent
|
||||
processing. Key features include:
|
||||
|
||||
- Concurrent generation with configurable worker count
|
||||
- Graceful shutdown on interrupt signals (Ctrl+C)
|
||||
- Real-time progress tracking with statistics
|
||||
- Up to 3-4x performance improvement vs sequential processing
|
||||
- Thread-safe operation with no race conditions
|
||||
|
||||
This tool serves as both a practical utility and a reference implementation
|
||||
for consuming the `jdenticon` library with proper error handling and
|
||||
configuration management.
|
||||
*/
|
||||
package main
|
||||
180
cmd/jdenticon/generate.go
Normal file
180
cmd/jdenticon/generate.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// isRootPath checks if the given path is a filesystem root.
|
||||
// On Unix, this is "/". On Windows, this is a drive root like "C:\" or a UNC root.
|
||||
func isRootPath(path string) bool {
|
||||
if path == "/" {
|
||||
return true
|
||||
}
|
||||
// On Windows, check for drive roots (e.g., "C:\") or when Dir(path) == path
|
||||
if runtime.GOOS == "windows" {
|
||||
// filepath.VolumeName returns "C:" for "C:\", empty for Unix paths
|
||||
vol := filepath.VolumeName(path)
|
||||
if vol != "" && (path == vol || path == vol+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Generic check: if going up doesn't change the path, we're at root
|
||||
return filepath.Dir(path) == path
|
||||
}
|
||||
|
||||
// validateAndResolveOutputPath ensures the target path is within or is the base directory.
|
||||
// It returns the absolute, cleaned, and validated path, or an error.
|
||||
// The baseDir is typically os.Getwd() for a CLI tool, representing the trusted root.
|
||||
func validateAndResolveOutputPath(baseDir, outputPath string) (string, error) {
|
||||
// 1. Get absolute and cleaned path of the base directory.
|
||||
// This ensures we have a canonical, absolute reference for our allowed root.
|
||||
absBaseDir, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path for base directory %q: %w", baseDir, err)
|
||||
}
|
||||
absBaseDir = filepath.Clean(absBaseDir)
|
||||
|
||||
// 2. Get absolute and cleaned path of the user-provided output file.
|
||||
// This resolves all relative components (., ..) and converts to an absolute path.
|
||||
absOutputPath, err := filepath.Abs(outputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path for output file %q: %w", outputPath, err)
|
||||
}
|
||||
absOutputPath = filepath.Clean(absOutputPath)
|
||||
|
||||
// 3. Resolve any symlinks to ensure we're comparing canonical paths.
|
||||
// This handles cases like macOS where /var is symlinked to /private/var.
|
||||
absBaseDir, err = filepath.EvalSymlinks(absBaseDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve symlinks for base directory %q: %w", absBaseDir, err)
|
||||
}
|
||||
|
||||
originalOutputPath := absOutputPath
|
||||
resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(absOutputPath))
|
||||
if err != nil {
|
||||
// If the directory doesn't exist yet, try to resolve up to the existing parent
|
||||
parentDir := filepath.Dir(absOutputPath)
|
||||
for !isRootPath(parentDir) && parentDir != "." {
|
||||
if resolvedParent, err := filepath.EvalSymlinks(parentDir); err == nil {
|
||||
absOutputPath = filepath.Join(resolvedParent, filepath.Base(originalOutputPath))
|
||||
break
|
||||
}
|
||||
parentDir = filepath.Dir(parentDir)
|
||||
}
|
||||
// If we couldn't resolve any parent, keep the original path
|
||||
// (absOutputPath already equals originalOutputPath, nothing to do)
|
||||
} else {
|
||||
absOutputPath = filepath.Join(resolvedDir, filepath.Base(originalOutputPath))
|
||||
}
|
||||
|
||||
absOutputPath = filepath.Clean(absOutputPath)
|
||||
|
||||
// 4. Crucial Security Check: Ensure absOutputPath is within absBaseDir.
|
||||
// a. If absOutputPath is identical to absBaseDir (e.g., user specified "."), it's allowed.
|
||||
// b. Otherwise, absOutputPath must start with absBaseDir followed by a path separator.
|
||||
// This prevents cases where absOutputPath is merely a prefix of absBaseDir
|
||||
// (e.g., /home/user/project_other vs /home/user/project).
|
||||
if absOutputPath != absBaseDir && !strings.HasPrefix(absOutputPath, absBaseDir+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid output path: %q (resolved to %q) is outside allowed directory %q",
|
||||
outputPath, absOutputPath, absBaseDir)
|
||||
}
|
||||
|
||||
return absOutputPath, nil
|
||||
}
|
||||
|
||||
// generateCmd represents the generate command
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate <value>",
|
||||
Short: "Generate a single identicon",
|
||||
Long: `Generate a single identicon based on the provided value (e.g., a hash, username, or email).
|
||||
|
||||
The identicon will be written to stdout by default, or to a file if --output is specified.
|
||||
All configuration options from the root command apply.
|
||||
|
||||
Examples:
|
||||
jdenticon generate "user@example.com"
|
||||
jdenticon generate "user@example.com" --size 128 --format png --output avatar.png
|
||||
jdenticon generate "github-username" --color-saturation 0.7 --padding 0.1`,
|
||||
Args: cobra.ExactArgs(1), // Validate argument count at the Cobra level
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get the input value
|
||||
value := args[0]
|
||||
|
||||
// Get output file flag
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// Get format from viper
|
||||
format, err := getFormatFromViper()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Populate library config from root persistent flags
|
||||
config, size, err := populateConfigFromFlags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate the identicon with custom config
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, generatorCacheSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create generator: %w", err)
|
||||
}
|
||||
|
||||
icon, err := generator.Generate(context.Background(), value, size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate identicon: %w", err)
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
result, err := renderIcon(icon, format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Output to file or stdout
|
||||
if outputFile != "" {
|
||||
// Determine the base directory for allowed writes. For a CLI, this is typically the CWD.
|
||||
baseDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
// Validate and resolve the user-provided output path.
|
||||
safeOutputPath, err := validateAndResolveOutputPath(baseDir, outputFile)
|
||||
if err != nil {
|
||||
// This is a security-related error, explicitly state it.
|
||||
return fmt.Errorf("security error: %w", err)
|
||||
}
|
||||
|
||||
// Now use the safe and validated path for writing.
|
||||
// #nosec G306 -- 0644 is appropriate for generated image files (world-readable)
|
||||
if err := os.WriteFile(safeOutputPath, result, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write output file %q: %w", safeOutputPath, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Identicon saved to %s\n", safeOutputPath)
|
||||
} else {
|
||||
// Write to stdout for piping
|
||||
if _, err := cmd.OutOrStdout().Write(result); err != nil {
|
||||
return fmt.Errorf("failed to write to stdout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
|
||||
// Define local flags specific to 'generate'
|
||||
generateCmd.Flags().StringP("output", "o", "", "Output file path. If empty, writes to stdout.")
|
||||
}
|
||||
660
cmd/jdenticon/generate_test.go
Normal file
660
cmd/jdenticon/generate_test.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// TestGenerateCommand tests the generate command functionality
|
||||
func TestGenerateCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
outputCheck func(t *testing.T, output []byte, outputFile string)
|
||||
}{
|
||||
{
|
||||
name: "generate SVG to stdout",
|
||||
args: []string{"generate", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
if !bytes.Contains(output, []byte("<svg")) {
|
||||
t.Error("Expected SVG output to contain <svg tag")
|
||||
}
|
||||
if !bytes.Contains(output, []byte("xmlns=\"http://www.w3.org/2000/svg\"")) {
|
||||
t.Error("Expected SVG output to contain namespace declaration")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate with custom size",
|
||||
args: []string{"generate", "--size", "128", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
if !bytes.Contains(output, []byte("width=\"128\"")) {
|
||||
t.Error("Expected SVG to have width=128")
|
||||
}
|
||||
if !bytes.Contains(output, []byte("height=\"128\"")) {
|
||||
t.Error("Expected SVG to have height=128")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate PNG format",
|
||||
args: []string{"generate", "--format", "png", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// PNG files start with specific magic bytes
|
||||
if len(output) < 8 || !bytes.Equal(output[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
|
||||
t.Error("Expected PNG output to have PNG magic bytes")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate with background color",
|
||||
args: []string{"generate", "--bg-color", "#ffffff", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// SVG should contain background rect
|
||||
if !bytes.Contains(output, []byte("fill=\"#ffffff\"")) {
|
||||
t.Error("Expected SVG to contain background color")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate with custom padding",
|
||||
args: []string{"generate", "--padding", "0.2", "test@example.com"},
|
||||
wantErr: false,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// Should generate valid SVG
|
||||
if !bytes.Contains(output, []byte("<svg")) {
|
||||
t.Error("Expected valid SVG output")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no arguments",
|
||||
args: []string{},
|
||||
wantErr: false, // Shows help, doesn't error
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// Should show help text
|
||||
outputStr := string(output)
|
||||
if !strings.Contains(outputStr, "Usage:") && !strings.Contains(outputStr, "generate") {
|
||||
t.Error("Expected help text to be shown when no arguments provided")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
args: []string{"arg1", "arg2"},
|
||||
wantErr: true,
|
||||
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
||||
// Should not produce output on error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset viper for clean state
|
||||
viper.Reset()
|
||||
|
||||
// Create a buffer to capture output
|
||||
var output bytes.Buffer
|
||||
|
||||
// Create the generate command for testing
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
|
||||
// Set args and execute
|
||||
cmd.SetArgs(tt.args)
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.outputCheck != nil {
|
||||
tt.outputCheck(t, output.Bytes(), "")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateToFile tests file output functionality
|
||||
func TestGenerateToFile(t *testing.T) {
|
||||
// Create a temporary directory for test outputs
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Change to temp directory to test file creation there
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalDir)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
filename string
|
||||
wantErr bool
|
||||
fileCheck func(t *testing.T, filepath string)
|
||||
}{
|
||||
{
|
||||
name: "generate SVG to file",
|
||||
args: []string{"generate", "test@example.com"},
|
||||
filename: "test.svg",
|
||||
wantErr: false,
|
||||
fileCheck: func(t *testing.T, filepath string) {
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
if !bytes.Contains(content, []byte("<svg")) {
|
||||
t.Error("Expected SVG file to contain <svg tag")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generate PNG to file",
|
||||
args: []string{"generate", "--format", "png", "test@example.com"},
|
||||
filename: "test.png",
|
||||
wantErr: false,
|
||||
fileCheck: func(t *testing.T, filepath string) {
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
// Check PNG magic bytes
|
||||
if len(content) < 8 || !bytes.Equal(content[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
|
||||
t.Error("Expected PNG file to have PNG magic bytes")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
// Use relative path since we're in temp directory
|
||||
outputPath := tt.filename
|
||||
args := append(tt.args, "--output", outputPath)
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Check that file was created
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected output file to be created at %s", outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.fileCheck != nil {
|
||||
tt.fileCheck(t, outputPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateValidation tests input validation
|
||||
func TestGenerateValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
errorCheck func(t *testing.T, err error)
|
||||
}{
|
||||
{
|
||||
name: "negative size",
|
||||
args: []string{"generate", "--size", "-1", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "size must be positive") {
|
||||
t.Errorf("Expected size validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
args: []string{"generate", "--size", "0", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "size must be positive") {
|
||||
t.Errorf("Expected size validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid padding",
|
||||
args: []string{"generate", "--padding", "-0.1", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "padding") {
|
||||
t.Errorf("Expected padding validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid color saturation",
|
||||
args: []string{"generate", "--color-saturation", "1.5", "test@example.com"},
|
||||
wantErr: true,
|
||||
errorCheck: func(t *testing.T, err error) {
|
||||
if !strings.Contains(err.Error(), "saturation") {
|
||||
t.Errorf("Expected saturation validation error, got: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr && tt.errorCheck != nil {
|
||||
tt.errorCheck(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathTraversalSecurity tests the path traversal vulnerability fix
|
||||
func TestPathTraversalSecurity(t *testing.T) {
|
||||
// Create a temporary directory for test outputs
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-security-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Change to temp directory to have a controlled test environment
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalDir)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a subdirectory to test valid subdirectory writes
|
||||
subDir := filepath.Join(tempDir, "images")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
outputPath string
|
||||
expectError bool
|
||||
errorMessage string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "valid_current_dir",
|
||||
outputPath: "avatar.png",
|
||||
expectError: false,
|
||||
description: "Should allow files in current directory",
|
||||
},
|
||||
{
|
||||
name: "valid_subdirectory",
|
||||
outputPath: "images/avatar.png",
|
||||
expectError: false,
|
||||
description: "Should allow files in subdirectories",
|
||||
},
|
||||
{
|
||||
name: "valid_relative_current",
|
||||
outputPath: "./avatar.png",
|
||||
expectError: false,
|
||||
description: "Should allow explicit current directory notation",
|
||||
},
|
||||
{
|
||||
name: "path_traversal_up_one",
|
||||
outputPath: "../avatar.png",
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block path traversal attempts with ../",
|
||||
},
|
||||
{
|
||||
name: "path_traversal_up_multiple",
|
||||
outputPath: "../../avatar.png",
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block multiple directory traversal attempts",
|
||||
},
|
||||
{
|
||||
name: "path_traversal_complex",
|
||||
outputPath: "../../../etc/passwd",
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block attempts to write to system directories",
|
||||
},
|
||||
{
|
||||
name: "absolute_path_system",
|
||||
outputPath: filepath.Join(os.TempDir(), "avatar.png"),
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block absolute paths to system directories",
|
||||
},
|
||||
{
|
||||
name: "absolute_path_root",
|
||||
outputPath: func() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\Windows\test.png`
|
||||
} else {
|
||||
return "/etc/passwd"
|
||||
}
|
||||
}(),
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block attempts to overwrite system files",
|
||||
},
|
||||
{
|
||||
name: "mixed_path_traversal",
|
||||
outputPath: filepath.Join(".", "images", "..", "..", "..", os.TempDir(), "avatar.png"),
|
||||
expectError: true,
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block mixed path traversal attempts",
|
||||
},
|
||||
{
|
||||
name: "windows_style_traversal",
|
||||
outputPath: "..\\..\\evil.exe",
|
||||
expectError: runtime.GOOS == "windows", // Only expect error on Windows
|
||||
errorMessage: "outside allowed directory",
|
||||
description: "Should block Windows-style path traversal on Windows (backslashes are valid filename chars on Unix)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
// Test args for generating an identicon with the specified output path
|
||||
args := []string{"generate", "--output", tt.outputPath, "test@example.com"}
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd := createTestGenerateCommand()
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Test %s: Expected error but got none. %s", tt.name, tt.description)
|
||||
return
|
||||
}
|
||||
if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) {
|
||||
t.Errorf("Test %s: Expected error to contain %q, got: %v", tt.name, tt.errorMessage, err)
|
||||
}
|
||||
t.Logf("Test %s: Correctly blocked path %q with error: %v", tt.name, tt.outputPath, err)
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Test %s: Expected no error but got: %v. %s", tt.name, err, tt.description)
|
||||
return
|
||||
}
|
||||
|
||||
// For successful cases, verify the file was created in the expected location
|
||||
expectedPath := filepath.Join(tempDir, tt.outputPath)
|
||||
expectedPath = filepath.Clean(expectedPath)
|
||||
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Test %s: Expected file to be created at %s", tt.name, expectedPath)
|
||||
} else {
|
||||
t.Logf("Test %s: Successfully created file at %s", tt.name, expectedPath)
|
||||
// Clean up the created file
|
||||
os.Remove(expectedPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAndResolveOutputPath tests the security helper function directly
|
||||
func TestValidateAndResolveOutputPath(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-path-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Change to temp directory to test relative path resolution correctly
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalDir)
|
||||
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("Failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseDir string
|
||||
outputPath string
|
||||
expectError bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "valid_relative_file",
|
||||
baseDir: tempDir,
|
||||
outputPath: "test.png",
|
||||
expectError: false,
|
||||
description: "Valid relative file path should be allowed",
|
||||
},
|
||||
{
|
||||
name: "valid_subdirectory_file",
|
||||
baseDir: tempDir,
|
||||
outputPath: "sub/test.png",
|
||||
expectError: false,
|
||||
description: "Valid file in subdirectory should be allowed",
|
||||
},
|
||||
{
|
||||
name: "traversal_attack",
|
||||
baseDir: tempDir,
|
||||
outputPath: "../../../etc/passwd",
|
||||
expectError: true,
|
||||
description: "Path traversal attack should be blocked",
|
||||
},
|
||||
{
|
||||
name: "absolute_outside_path",
|
||||
baseDir: tempDir,
|
||||
outputPath: filepath.Join(os.TempDir(), "test.png"),
|
||||
expectError: true,
|
||||
description: "Absolute path outside base should be blocked",
|
||||
},
|
||||
{
|
||||
name: "current_dir_notation",
|
||||
baseDir: tempDir,
|
||||
outputPath: "./test.png",
|
||||
expectError: false,
|
||||
description: "Current directory notation should be allowed",
|
||||
},
|
||||
{
|
||||
name: "complex_traversal",
|
||||
baseDir: tempDir,
|
||||
outputPath: "sub/../../../secret.txt",
|
||||
expectError: true,
|
||||
description: "Complex path traversal should be blocked",
|
||||
},
|
||||
{
|
||||
name: "absolute_inside_allowed",
|
||||
baseDir: tempDir,
|
||||
outputPath: filepath.Join(tempDir, "allowed.png"),
|
||||
expectError: false,
|
||||
description: "Absolute path inside base directory should be allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := validateAndResolveOutputPath(tt.baseDir, tt.outputPath)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for %s, but got none. Result: %s", tt.description, result)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for %s: %v", tt.description, err)
|
||||
} else {
|
||||
// Verify the result is within the base directory
|
||||
// Use EvalSymlinks to handle macOS symlink issues
|
||||
absBase, _ := filepath.Abs(tt.baseDir)
|
||||
resolvedBase, err := filepath.EvalSymlinks(absBase)
|
||||
if err != nil {
|
||||
resolvedBase = absBase // fallback to original if symlink resolution fails
|
||||
}
|
||||
|
||||
resolvedResult, err := filepath.EvalSymlinks(filepath.Dir(result))
|
||||
if err != nil {
|
||||
resolvedResult = filepath.Dir(result) // fallback to original if symlink resolution fails
|
||||
}
|
||||
resolvedResult = filepath.Join(resolvedResult, filepath.Base(result))
|
||||
|
||||
if !strings.HasPrefix(resolvedResult, resolvedBase) {
|
||||
t.Errorf("Result path %s (resolved: %s) is not within base directory %s (resolved: %s)", result, resolvedResult, absBase, resolvedBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestGenerateCommand creates a generate command for testing
|
||||
func createTestGenerateCommand() *cobra.Command {
|
||||
// Create root command with flags
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
}
|
||||
|
||||
// Initialize root flags
|
||||
initTestFlags(rootCmd)
|
||||
|
||||
// Create generate command similar to the actual one
|
||||
generateCmd := &cobra.Command{
|
||||
Use: "generate <value>",
|
||||
Short: "Generate a single identicon",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get the input value
|
||||
value := args[0]
|
||||
|
||||
// Get output file flag
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// Get format from viper
|
||||
format, err := getFormatFromViper()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Populate library config from root persistent flags
|
||||
config, size, err := populateConfigFromFlags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate the identicon with custom config
|
||||
generator, err := jdenticon.NewGeneratorWithConfig(config, generatorCacheSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
icon, err := generator.Generate(context.Background(), value, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
result, err := renderIcon(icon, format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Output to file or stdout
|
||||
if outputFile != "" {
|
||||
// Determine the base directory for allowed writes. For a CLI, this is typically the CWD.
|
||||
baseDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate and resolve the user-provided output path.
|
||||
safeOutputPath, err := validateAndResolveOutputPath(baseDir, outputFile)
|
||||
if err != nil {
|
||||
// This is a security-related error, explicitly state it.
|
||||
return err
|
||||
}
|
||||
|
||||
// Now use the safe and validated path for writing.
|
||||
if err := os.WriteFile(safeOutputPath, result, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Write to stdout for piping
|
||||
if _, err := cmd.OutOrStdout().Write(result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add generate-specific flags
|
||||
generateCmd.Flags().StringP("output", "o", "", "Output file path. If empty, writes to stdout.")
|
||||
|
||||
// Add to root command
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
402
cmd/jdenticon/integration_test.go
Normal file
402
cmd/jdenticon/integration_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
// testBinaryName returns the correct test binary name for the current OS.
|
||||
// On Windows, executables need the .exe extension.
|
||||
func testBinaryName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "jdenticon-test.exe"
|
||||
}
|
||||
return "jdenticon-test"
|
||||
}
|
||||
|
||||
// TestCLIVsLibraryOutputIdentical verifies that CLI generates identical output to the library API
|
||||
func TestCLIVsLibraryOutputIdentical(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
size int
|
||||
cliArgs []string
|
||||
configFunc func() jdenticon.Config
|
||||
}{
|
||||
{
|
||||
name: "basic SVG generation",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
return jdenticon.DefaultConfig()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom size",
|
||||
input: "test@example.com",
|
||||
size: 128,
|
||||
cliArgs: []string{"generate", "--size", "128", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
return jdenticon.DefaultConfig()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom padding",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--padding", "0.15", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.Padding = 0.15
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom color saturation",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--color-saturation", "0.8", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.ColorSaturation = 0.8
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background color",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--bg-color", "#ffffff", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.BackgroundColor = "#ffffff"
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grayscale saturation",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--grayscale-saturation", "0.1", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.GrayscaleSaturation = 0.1
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom lightness ranges",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--color-lightness", "0.3,0.7", "--grayscale-lightness", "0.2,0.8", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.ColorLightnessRange = [2]float64{0.3, 0.7}
|
||||
config.GrayscaleLightnessRange = [2]float64{0.2, 0.8}
|
||||
return config
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hue restrictions",
|
||||
input: "test@example.com",
|
||||
size: 200,
|
||||
cliArgs: []string{"generate", "--hue-restrictions", "0,120,240", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.HueRestrictions = []float64{0, 120, 240}
|
||||
return config
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Generate using library API
|
||||
config := tc.configFunc()
|
||||
librarySVG, err := jdenticon.ToSVGWithConfig(context.Background(), tc.input, tc.size, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Library generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate using CLI
|
||||
cmd := exec.Command(cliBinary, tc.cliArgs...)
|
||||
var cliOutput bytes.Buffer
|
||||
cmd.Stdout = &cliOutput
|
||||
cmd.Stderr = &cliOutput
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("CLI command failed: %v, output: %s", err, cliOutput.String())
|
||||
}
|
||||
|
||||
cliSVG := cliOutput.String()
|
||||
|
||||
// Compare outputs
|
||||
if cliSVG != librarySVG {
|
||||
t.Errorf("CLI and library outputs differ")
|
||||
t.Logf("Library output length: %d", len(librarySVG))
|
||||
t.Logf("CLI output length: %d", len(cliSVG))
|
||||
|
||||
// Find the first difference
|
||||
minLen := len(librarySVG)
|
||||
if len(cliSVG) < minLen {
|
||||
minLen = len(cliSVG)
|
||||
}
|
||||
|
||||
for i := 0; i < minLen; i++ {
|
||||
if librarySVG[i] != cliSVG[i] {
|
||||
start := i - 20
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i + 20
|
||||
if end > minLen {
|
||||
end = minLen
|
||||
}
|
||||
|
||||
t.Logf("First difference at position %d:", i)
|
||||
t.Logf("Library: %q", librarySVG[start:end])
|
||||
t.Logf("CLI: %q", cliSVG[start:end])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIPNGVsLibraryOutputIdentical verifies PNG output consistency
|
||||
func TestCLIPNGVsLibraryOutputIdentical(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-png-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
size int
|
||||
cliArgs []string
|
||||
configFunc func() jdenticon.Config
|
||||
}{
|
||||
{
|
||||
name: "basic PNG generation",
|
||||
input: "test@example.com",
|
||||
size: 64,
|
||||
cliArgs: []string{"generate", "--format", "png", "--size", "64", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
return jdenticon.DefaultConfig()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PNG with background",
|
||||
input: "test@example.com",
|
||||
size: 64,
|
||||
cliArgs: []string{"generate", "--format", "png", "--size", "64", "--bg-color", "#ff0000", "test@example.com"},
|
||||
configFunc: func() jdenticon.Config {
|
||||
config := jdenticon.DefaultConfig()
|
||||
config.BackgroundColor = "#ff0000"
|
||||
return config
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Generate using library API
|
||||
config := tc.configFunc()
|
||||
libraryPNG, err := jdenticon.ToPNGWithConfig(context.Background(), tc.input, tc.size, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Library PNG generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate using CLI
|
||||
cmd := exec.Command(cliBinary, tc.cliArgs...)
|
||||
var cliOutput bytes.Buffer
|
||||
cmd.Stdout = &cliOutput
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("CLI command failed: %v", err)
|
||||
}
|
||||
|
||||
cliPNG := cliOutput.Bytes()
|
||||
|
||||
// Compare PNG outputs - they should be identical
|
||||
if !bytes.Equal(cliPNG, libraryPNG) {
|
||||
t.Errorf("CLI and library PNG outputs differ")
|
||||
t.Logf("Library PNG size: %d bytes", len(libraryPNG))
|
||||
t.Logf("CLI PNG size: %d bytes", len(cliPNG))
|
||||
|
||||
// Check PNG headers
|
||||
if len(libraryPNG) >= 8 && len(cliPNG) >= 8 {
|
||||
if !bytes.Equal(libraryPNG[:8], cliPNG[:8]) {
|
||||
t.Logf("PNG headers differ")
|
||||
t.Logf("Library: %v", libraryPNG[:8])
|
||||
t.Logf("CLI: %v", cliPNG[:8])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIBatchIntegration tests batch processing consistency
|
||||
func TestCLIBatchIntegration(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-batch-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
// Create input file
|
||||
inputFile := filepath.Join(tempDir, "inputs.txt")
|
||||
inputs := []string{
|
||||
"user1@example.com",
|
||||
"user2@example.com",
|
||||
"test-user",
|
||||
}
|
||||
inputContent := strings.Join(inputs, "\n")
|
||||
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create input file: %v", err)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(tempDir, "batch-output")
|
||||
|
||||
// Run batch command
|
||||
cmd = exec.Command(cliBinary, "batch", inputFile, "--output-dir", outputDir)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Batch command failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify each generated file matches library output
|
||||
config := jdenticon.DefaultConfig()
|
||||
for _, input := range inputs {
|
||||
filename := sanitizeFilename(input) + ".svg"
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Read CLI-generated file
|
||||
cliContent, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read CLI-generated file for %s: %v", input, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate using library
|
||||
librarySVG, err := jdenticon.ToSVGWithConfig(context.Background(), input, 200, config)
|
||||
if err != nil {
|
||||
t.Errorf("Library generation failed for %s: %v", input, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare
|
||||
if string(cliContent) != librarySVG {
|
||||
t.Errorf("Batch file for %s differs from library output", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIErrorHandling tests that CLI properly handles error cases
|
||||
func TestCLIErrorHandling(t *testing.T) {
|
||||
// Build the CLI binary first
|
||||
tempDir, err := os.MkdirTemp("", "jdenticon-error-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cliBinary := filepath.Join(tempDir, testBinaryName())
|
||||
cmd := exec.Command("go", "build", "-o", cliBinary, ".")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to build CLI binary: %v", err)
|
||||
}
|
||||
|
||||
errorCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
args: []string{},
|
||||
expectError: false, // Should show help, not error
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{"generate", "--format", "invalid", "test"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
args: []string{"generate", "--size", "-1", "test"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "generate no arguments",
|
||||
args: []string{"generate"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "batch missing output dir",
|
||||
args: []string{"batch", "somefile.txt"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "batch missing input file",
|
||||
args: []string{"batch", "nonexistent.txt", "--output-dir", "/tmp"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range errorCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command(cliBinary, tc.args...)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Errorf("Expected error but command succeeded. Output: %s", output.String())
|
||||
} else if !tc.expectError && err != nil {
|
||||
t.Errorf("Expected success but command failed: %v. Output: %s", err, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,62 +1,5 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kevin/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
value = flag.String("value", "", "Input value to generate identicon for (required)")
|
||||
size = flag.Int("size", 200, "Size of the identicon in pixels")
|
||||
format = flag.String("format", "svg", "Output format: svg or png")
|
||||
output = flag.String("output", "", "Output file (if empty, prints to stdout)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *value == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: -value is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
icon, err := jdenticon.Generate(*value, *size)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating identicon: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result []byte
|
||||
switch *format {
|
||||
case "svg":
|
||||
svgStr, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
result = []byte(svgStr)
|
||||
case "png":
|
||||
pngBytes, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating PNG: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
result = pngBytes
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid format %s (use svg or png)\n", *format)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *output != "" {
|
||||
if err := os.WriteFile(*output, result, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Identicon saved to %s\n", *output)
|
||||
} else {
|
||||
fmt.Print(string(result))
|
||||
}
|
||||
}
|
||||
Execute()
|
||||
}
|
||||
|
||||
239
cmd/jdenticon/root.go
Normal file
239
cmd/jdenticon/root.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
||||
)
|
||||
|
||||
const (
|
||||
// generatorCacheSize defines the number of icons to cache in memory for performance.
|
||||
generatorCacheSize = 100
|
||||
)
|
||||
|
||||
var (
|
||||
// Version information - injected at build time via ldflags
|
||||
|
||||
// Version is the version string for the jdenticon CLI tool
|
||||
Version = "dev"
|
||||
|
||||
// Commit is the git commit hash for the jdenticon CLI build
|
||||
Commit = "unknown"
|
||||
|
||||
// BuildDate is the timestamp when the jdenticon CLI was built
|
||||
BuildDate = "unknown"
|
||||
|
||||
cfgFile string
|
||||
)
|
||||
|
||||
// getVersionString returns formatted version information
|
||||
func getVersionString() string {
|
||||
version := fmt.Sprintf("jdenticon version %s\n", Version)
|
||||
|
||||
if Commit != "unknown" && BuildDate != "unknown" {
|
||||
version += fmt.Sprintf("Built from commit %s on %s\n", Commit, BuildDate)
|
||||
} else if Commit != "unknown" {
|
||||
version += fmt.Sprintf("Built from commit %s\n", Commit)
|
||||
} else if BuildDate != "unknown" {
|
||||
version += fmt.Sprintf("Built on %s\n", BuildDate)
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
Long: `jdenticon is a command-line tool for generating highly recognizable identicons -
|
||||
geometric avatar images generated deterministically from any input string.
|
||||
|
||||
Generate consistent, beautiful identicons as PNG or SVG files with customizable
|
||||
color themes, padding, and styling options.`,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
// Set custom version template to use our detailed version info
|
||||
rootCmd.SetVersionTemplate(getVersionString())
|
||||
|
||||
// Config file flag
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jdenticon.yaml)")
|
||||
|
||||
// Basic flags shared across commands
|
||||
var formatFlag FormatFlag = FormatSVG // Default to SVG
|
||||
rootCmd.PersistentFlags().IntP("size", "s", 200, "Size of the identicon in pixels")
|
||||
rootCmd.PersistentFlags().VarP(&formatFlag, "format", "f", `Output format ("png" or "svg")`)
|
||||
rootCmd.PersistentFlags().Float64P("padding", "p", 0.08, "Padding as percentage of icon size (0.0-0.5)")
|
||||
|
||||
// Color configuration flags
|
||||
rootCmd.PersistentFlags().Float64("color-saturation", 0.5, "Saturation for colored shapes (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().Float64("grayscale-saturation", 0.0, "Saturation for grayscale shapes (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().String("bg-color", "", "Background color (hex format, e.g., #ffffff)")
|
||||
|
||||
// Advanced configuration flags
|
||||
rootCmd.PersistentFlags().StringSlice("hue-restrictions", nil, "Restrict hues to specific degrees (0-360), e.g., --hue-restrictions=0,120,240")
|
||||
rootCmd.PersistentFlags().String("color-lightness", "0.4,0.8", "Color lightness range as min,max (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().String("grayscale-lightness", "0.3,0.9", "Grayscale lightness range as min,max (0.0-1.0)")
|
||||
rootCmd.PersistentFlags().Int("png-supersampling", 8, "PNG supersampling factor (1-16)")
|
||||
|
||||
// Bind flags to viper (errors are intentionally ignored as these bindings are non-critical)
|
||||
_ = viper.BindPFlag("size", rootCmd.PersistentFlags().Lookup("size"))
|
||||
_ = viper.BindPFlag("format", rootCmd.PersistentFlags().Lookup("format"))
|
||||
_ = viper.BindPFlag("padding", rootCmd.PersistentFlags().Lookup("padding"))
|
||||
_ = viper.BindPFlag("color-saturation", rootCmd.PersistentFlags().Lookup("color-saturation"))
|
||||
_ = viper.BindPFlag("grayscale-saturation", rootCmd.PersistentFlags().Lookup("grayscale-saturation"))
|
||||
_ = viper.BindPFlag("bg-color", rootCmd.PersistentFlags().Lookup("bg-color"))
|
||||
_ = viper.BindPFlag("hue-restrictions", rootCmd.PersistentFlags().Lookup("hue-restrictions"))
|
||||
_ = viper.BindPFlag("color-lightness", rootCmd.PersistentFlags().Lookup("color-lightness"))
|
||||
_ = viper.BindPFlag("grayscale-lightness", rootCmd.PersistentFlags().Lookup("grayscale-lightness"))
|
||||
_ = viper.BindPFlag("png-supersampling", rootCmd.PersistentFlags().Lookup("png-supersampling"))
|
||||
|
||||
// Register format flag completion
|
||||
_ = rootCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"png", "svg"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Find home directory.
|
||||
home, err := os.UserHomeDir()
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Search config in home directory with name ".jdenticon" (without extension).
|
||||
viper.AddConfigPath(home)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigName(".jdenticon")
|
||||
}
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// If a config file is found, read it in.
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
// Only ignore the error if the config file doesn't exist.
|
||||
// All other errors (e.g., permission denied, malformed file) should be noted.
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
fmt.Fprintln(os.Stderr, "Error reading config file:", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
|
||||
// populateConfigFromFlags creates a jdenticon.Config from viper settings and validates it
|
||||
func populateConfigFromFlags() (jdenticon.Config, int, error) {
|
||||
config := jdenticon.DefaultConfig()
|
||||
|
||||
// Get size from viper
|
||||
size := viper.GetInt("size")
|
||||
if size <= 0 {
|
||||
return config, 0, fmt.Errorf("size must be positive, got %d", size)
|
||||
}
|
||||
|
||||
// Basic configuration
|
||||
config.Padding = viper.GetFloat64("padding")
|
||||
config.ColorSaturation = viper.GetFloat64("color-saturation")
|
||||
config.GrayscaleSaturation = viper.GetFloat64("grayscale-saturation")
|
||||
config.BackgroundColor = viper.GetString("bg-color")
|
||||
config.PNGSupersampling = viper.GetInt("png-supersampling")
|
||||
|
||||
// Handle hue restrictions
|
||||
hueRestrictions := viper.GetStringSlice("hue-restrictions")
|
||||
if len(hueRestrictions) > 0 {
|
||||
hues := make([]float64, len(hueRestrictions))
|
||||
for i, hueStr := range hueRestrictions {
|
||||
var hue float64
|
||||
if _, err := fmt.Sscanf(hueStr, "%f", &hue); err != nil {
|
||||
return config, 0, fmt.Errorf("invalid hue restriction %q: %w", hueStr, err)
|
||||
}
|
||||
hues[i] = hue
|
||||
}
|
||||
config.HueRestrictions = hues
|
||||
}
|
||||
|
||||
// Handle lightness ranges
|
||||
if colorLightnessStr := viper.GetString("color-lightness"); colorLightnessStr != "" {
|
||||
parts := strings.Split(colorLightnessStr, ",")
|
||||
if len(parts) != 2 {
|
||||
return config, 0, fmt.Errorf("invalid color-lightness format: expected 'min,max', got %q", colorLightnessStr)
|
||||
}
|
||||
min, errMin := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
max, errMax := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if errMin != nil || errMax != nil {
|
||||
return config, 0, fmt.Errorf("invalid color-lightness value: %w", errors.Join(errMin, errMax))
|
||||
}
|
||||
config.ColorLightnessRange = [2]float64{min, max}
|
||||
}
|
||||
|
||||
if grayscaleLightnessStr := viper.GetString("grayscale-lightness"); grayscaleLightnessStr != "" {
|
||||
parts := strings.Split(grayscaleLightnessStr, ",")
|
||||
if len(parts) != 2 {
|
||||
return config, 0, fmt.Errorf("invalid grayscale-lightness format: expected 'min,max', got %q", grayscaleLightnessStr)
|
||||
}
|
||||
min, errMin := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
max, errMax := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if errMin != nil || errMax != nil {
|
||||
return config, 0, fmt.Errorf("invalid grayscale-lightness value: %w", errors.Join(errMin, errMax))
|
||||
}
|
||||
config.GrayscaleLightnessRange = [2]float64{min, max}
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
return config, 0, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return config, size, nil
|
||||
}
|
||||
|
||||
// getFormatFromViper retrieves and validates the format from Viper configuration
|
||||
func getFormatFromViper() (FormatFlag, error) {
|
||||
formatStr := viper.GetString("format")
|
||||
var format FormatFlag
|
||||
if err := format.Set(formatStr); err != nil {
|
||||
return FormatSVG, fmt.Errorf("invalid format in config: %w", err)
|
||||
}
|
||||
return format, nil
|
||||
}
|
||||
|
||||
// renderIcon converts an icon to bytes based on the specified format
|
||||
func renderIcon(icon *jdenticon.Icon, format FormatFlag) ([]byte, error) {
|
||||
switch format {
|
||||
case FormatSVG:
|
||||
svgStr, err := icon.ToSVG()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate SVG: %w", err)
|
||||
}
|
||||
return []byte(svgStr), nil
|
||||
case FormatPNG:
|
||||
pngBytes, err := icon.ToPNG()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PNG: %w", err)
|
||||
}
|
||||
return pngBytes, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
}
|
||||
307
cmd/jdenticon/root_test.go
Normal file
307
cmd/jdenticon/root_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// TestRootCommand tests the basic structure and flags of the root command
|
||||
func TestRootCommand(t *testing.T) {
|
||||
// Reset viper for clean test state
|
||||
viper.Reset()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
validate func(t *testing.T, cmd *cobra.Command)
|
||||
}{
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
// Help should be available
|
||||
if !cmd.HasAvailableFlags() {
|
||||
t.Error("Expected command to have available flags")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "size flag",
|
||||
args: []string{"--size", "128"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetInt("size") != 128 {
|
||||
t.Errorf("Expected size=128, got %d", viper.GetInt("size"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "format flag svg",
|
||||
args: []string{"--format", "svg"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetString("format") != "svg" {
|
||||
t.Errorf("Expected format=svg, got %s", viper.GetString("format"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "format flag png",
|
||||
args: []string{"--format", "png"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetString("format") != "png" {
|
||||
t.Errorf("Expected format=png, got %s", viper.GetString("format"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{"--format", "invalid"},
|
||||
wantErr: true,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
// Should not reach here on error
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "padding flag",
|
||||
args: []string{"--padding", "0.15"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetFloat64("padding") != 0.15 {
|
||||
t.Errorf("Expected padding=0.15, got %f", viper.GetFloat64("padding"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "color-saturation flag",
|
||||
args: []string{"--color-saturation", "0.8"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetFloat64("color-saturation") != 0.8 {
|
||||
t.Errorf("Expected color-saturation=0.8, got %f", viper.GetFloat64("color-saturation"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bg-color flag",
|
||||
args: []string{"--bg-color", "#ffffff"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
if viper.GetString("bg-color") != "#ffffff" {
|
||||
t.Errorf("Expected bg-color=#ffffff, got %s", viper.GetString("bg-color"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hue-restrictions flag",
|
||||
args: []string{"--hue-restrictions", "0,120,240"},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cmd *cobra.Command) {
|
||||
hues := viper.GetStringSlice("hue-restrictions")
|
||||
expected := []string{"0", "120", "240"}
|
||||
if len(hues) != len(expected) {
|
||||
t.Errorf("Expected %d hue restrictions, got %d", len(expected), len(hues))
|
||||
}
|
||||
for i, hue := range expected {
|
||||
if i >= len(hues) || hues[i] != hue {
|
||||
t.Errorf("Expected hue[%d]=%s, got %s", i, hue, hues[i])
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset viper for each test
|
||||
viper.Reset()
|
||||
|
||||
// Create a fresh root command for each test
|
||||
cmd := &cobra.Command{
|
||||
Use: "jdenticon",
|
||||
Short: "Generate identicons from any input string",
|
||||
}
|
||||
|
||||
// Re-initialize flags
|
||||
initTestFlags(cmd)
|
||||
|
||||
// Set args and execute
|
||||
cmd.SetArgs(tt.args)
|
||||
err := cmd.Execute()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.validate != nil {
|
||||
tt.validate(t, cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// initTestFlags initializes flags for testing (similar to root init())
|
||||
func initTestFlags(cmd *cobra.Command) {
|
||||
// Basic flags shared across commands
|
||||
var formatFlag FormatFlag = FormatSVG // Default to SVG
|
||||
cmd.PersistentFlags().IntP("size", "s", 200, "Size of the identicon in pixels")
|
||||
cmd.PersistentFlags().VarP(&formatFlag, "format", "f", `Output format ("png" or "svg")`)
|
||||
cmd.PersistentFlags().Float64P("padding", "p", 0.08, "Padding as percentage of icon size (0.0-0.5)")
|
||||
|
||||
// Color configuration flags
|
||||
cmd.PersistentFlags().Float64("color-saturation", 0.5, "Saturation for colored shapes (0.0-1.0)")
|
||||
cmd.PersistentFlags().Float64("grayscale-saturation", 0.0, "Saturation for grayscale shapes (0.0-1.0)")
|
||||
cmd.PersistentFlags().String("bg-color", "", "Background color (hex format, e.g., #ffffff)")
|
||||
|
||||
// Advanced configuration flags
|
||||
cmd.PersistentFlags().StringSlice("hue-restrictions", nil, "Restrict hues to specific degrees (0-360), e.g., --hue-restrictions=0,120,240")
|
||||
cmd.PersistentFlags().String("color-lightness", "0.4,0.8", "Color lightness range as min,max (0.0-1.0)")
|
||||
cmd.PersistentFlags().String("grayscale-lightness", "0.3,0.9", "Grayscale lightness range as min,max (0.0-1.0)")
|
||||
cmd.PersistentFlags().Int("png-supersampling", 8, "PNG supersampling factor (1-16)")
|
||||
|
||||
// Bind flags to viper
|
||||
viper.BindPFlag("size", cmd.PersistentFlags().Lookup("size"))
|
||||
viper.BindPFlag("format", cmd.PersistentFlags().Lookup("format"))
|
||||
viper.BindPFlag("padding", cmd.PersistentFlags().Lookup("padding"))
|
||||
viper.BindPFlag("color-saturation", cmd.PersistentFlags().Lookup("color-saturation"))
|
||||
viper.BindPFlag("grayscale-saturation", cmd.PersistentFlags().Lookup("grayscale-saturation"))
|
||||
viper.BindPFlag("bg-color", cmd.PersistentFlags().Lookup("bg-color"))
|
||||
viper.BindPFlag("hue-restrictions", cmd.PersistentFlags().Lookup("hue-restrictions"))
|
||||
viper.BindPFlag("color-lightness", cmd.PersistentFlags().Lookup("color-lightness"))
|
||||
viper.BindPFlag("grayscale-lightness", cmd.PersistentFlags().Lookup("grayscale-lightness"))
|
||||
viper.BindPFlag("png-supersampling", cmd.PersistentFlags().Lookup("png-supersampling"))
|
||||
}
|
||||
|
||||
// TestPopulateConfigFromFlags tests the configuration building logic
|
||||
func TestPopulateConfigFromFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func()
|
||||
wantErr bool
|
||||
validate func(t *testing.T, config interface{}, size int)
|
||||
}{
|
||||
{
|
||||
name: "default config",
|
||||
setup: func() {
|
||||
viper.Reset()
|
||||
viper.Set("size", 200)
|
||||
viper.Set("format", "svg")
|
||||
viper.Set("png-supersampling", 8)
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, config interface{}, size int) {
|
||||
if size != 200 {
|
||||
t.Errorf("Expected size=200, got %d", size)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom config",
|
||||
setup: func() {
|
||||
viper.Reset()
|
||||
viper.Set("size", 128)
|
||||
viper.Set("padding", 0.12)
|
||||
viper.Set("color-saturation", 0.7)
|
||||
viper.Set("bg-color", "#000000")
|
||||
viper.Set("png-supersampling", 8)
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, config interface{}, size int) {
|
||||
if size != 128 {
|
||||
t.Errorf("Expected size=128, got %d", size)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid size",
|
||||
setup: func() {
|
||||
viper.Reset()
|
||||
viper.Set("size", -1)
|
||||
},
|
||||
wantErr: true,
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.setup()
|
||||
|
||||
config, size, err := populateConfigFromFlags()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("populateConfigFromFlags() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.validate != nil {
|
||||
tt.validate(t, config, size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFormatFromViper tests format flag validation
|
||||
func TestGetFormatFromViper(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
formatValue string
|
||||
wantFormat FormatFlag
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "svg format",
|
||||
formatValue: "svg",
|
||||
wantFormat: FormatSVG,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "png format",
|
||||
formatValue: "png",
|
||||
wantFormat: FormatPNG,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
formatValue: "invalid",
|
||||
wantFormat: FormatSVG,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
viper.Set("format", tt.formatValue)
|
||||
|
||||
format, err := getFormatFromViper()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getFormatFromViper() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if format != tt.wantFormat {
|
||||
t.Errorf("getFormatFromViper() = %v, want %v", format, tt.wantFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain sets up and tears down for tests
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
code := m.Run()
|
||||
|
||||
// Teardown
|
||||
viper.Reset()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
35
cmd/jdenticon/types.go
Normal file
35
cmd/jdenticon/types.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FormatFlag is a custom pflag.Value for handling --format validation and completion
|
||||
type FormatFlag string
|
||||
|
||||
const (
|
||||
// FormatPNG represents PNG output format for identicon generation
|
||||
FormatPNG FormatFlag = "png"
|
||||
|
||||
// FormatSVG represents SVG output format for identicon generation
|
||||
FormatSVG FormatFlag = "svg"
|
||||
)
|
||||
|
||||
// String is used both by pflag and fmt.Stringer
|
||||
func (f *FormatFlag) String() string {
|
||||
return string(*f)
|
||||
}
|
||||
|
||||
// Set must have pointer receiver, so it can change the value of f.
|
||||
func (f *FormatFlag) Set(v string) error {
|
||||
switch v {
|
||||
case "png", "svg":
|
||||
*f = FormatFlag(v)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf(`must be one of "png" or "svg"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Type is only used in help text
|
||||
func (f *FormatFlag) Type() string {
|
||||
return "string"
|
||||
}
|
||||
110
cmd/jdenticon/types_test.go
Normal file
110
cmd/jdenticon/types_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFormatFlag tests the custom FormatFlag type
|
||||
func TestFormatFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
expected FormatFlag
|
||||
}{
|
||||
{
|
||||
name: "valid svg",
|
||||
value: "svg",
|
||||
wantErr: false,
|
||||
expected: FormatSVG,
|
||||
},
|
||||
{
|
||||
name: "valid png",
|
||||
value: "png",
|
||||
wantErr: false,
|
||||
expected: FormatPNG,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
value: "jpeg",
|
||||
wantErr: true,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "case sensitivity",
|
||||
value: "SVG",
|
||||
wantErr: true,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var f FormatFlag
|
||||
err := f.Set(tt.value)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FormatFlag.Set() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && f != tt.expected {
|
||||
t.Errorf("FormatFlag.Set() = %v, want %v", f, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagString tests the String() method
|
||||
func TestFormatFlagString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flag FormatFlag
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "svg format",
|
||||
flag: FormatSVG,
|
||||
expected: "svg",
|
||||
},
|
||||
{
|
||||
name: "png format",
|
||||
flag: FormatPNG,
|
||||
expected: "png",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.flag.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("FormatFlag.String() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagType tests the Type() method
|
||||
func TestFormatFlagType(t *testing.T) {
|
||||
var f FormatFlag
|
||||
if f.Type() != "string" {
|
||||
t.Errorf("FormatFlag.Type() = %v, want %v", f.Type(), "string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagConstants tests that the constants are defined correctly
|
||||
func TestFormatFlagConstants(t *testing.T) {
|
||||
if FormatSVG != "svg" {
|
||||
t.Errorf("FormatSVG = %v, want %v", FormatSVG, "svg")
|
||||
}
|
||||
|
||||
if FormatPNG != "png" {
|
||||
t.Errorf("FormatPNG = %v, want %v", FormatPNG, "png")
|
||||
}
|
||||
}
|
||||
20
cmd/jdenticon/version.go
Normal file
20
cmd/jdenticon/version.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Long: "Print the version, build commit, and build date information for jdenticon CLI",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Print(getVersionString())
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
Reference in New Issue
Block a user