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:
Kevin McIntyre
2026-01-02 23:56:48 -05:00
parent f84b511895
commit d9e84812ff
292 changed files with 19725 additions and 38884 deletions

332
cmd/jdenticon/batch.go Normal file
View 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())
}

View 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
View 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
View 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
View 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.")
}

View 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
}

View 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.

View File

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