From a5568159ccc2d2fc19ea78a7d9c7186d1c74e24f Mon Sep 17 00:00:00 2001 From: Karn Date: Tue, 28 Oct 2025 23:42:31 +0530 Subject: [PATCH 1/4] add watch cmd --- ROADMAP.md | 326 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/cfgx/main.go | 154 ++++++++++++++++++++++ go.mod | 2 + go.sum | 4 + readme.md | 42 ++++++ 5 files changed, 528 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d4fdf5e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,326 @@ +# cfgx Roadmap + +This document outlines planned CLI commands and features for cfgx, organized by priority. We aim to keep cfgx minimal and focused on individual developers and small teams. + +## Status: v0.x.x + +The current focus is on stability and core functionality refinement. New commands will be added incrementally based on user feedback. + +--- + +## ✅ Implemented + +### Core Commands + +- **`generate`** - Generate type-safe Go code from TOML config +- **`version`** - Display version information +- **`watch`** - Auto-regenerate on TOML file changes (✨ NEW) + +--- + +## 🚀 Immediate Priority + +### `diff` + +Compare two TOML files and highlight configuration differences. + +**Purpose:** Help developers understand what changes between environments (dev vs prod, base vs override). + +**Usage:** + +```bash +# Compare two config files +cfgx diff config.dev.toml config.prod.toml + +# Show only changed keys +cfgx diff config.dev.toml config.prod.toml --keys-only + +# Output as JSON for scripting +cfgx diff base.toml override.toml --format json +``` + +**Output Example:** + +``` +Differences between config.dev.toml and config.prod.toml: + + server.addr + - ":8080" (config.dev.toml) + + ":443" (config.prod.toml) + + database.max_conns + - 10 (config.dev.toml) + + 100 (config.prod.toml) + + + server.tls_enabled = true (only in config.prod.toml) + - server.debug = true (only in config.dev.toml) +``` + +**Priority:** High - Common use case for multi-environment deployments + +--- + +### `merge` + +Combine multiple TOML files with override semantics (last wins). + +**Purpose:** Enable base + environment-specific config pattern without duplication. + +**Usage:** + +```bash +# Merge configs and generate +cfgx merge config.base.toml config.prod.toml --out merged.toml + +# Merge and generate in one step +cfgx merge config.base.toml config.dev.toml | cfgx generate --in - --out config.go + +# Multiple layers +cfgx merge base.toml region.toml env.toml --out final.toml +``` + +**Merge Behavior:** + +- Later files override earlier ones +- Arrays are replaced, not merged +- Nested tables merged recursively +- Preserves comments from last file with the key + +**Priority:** High - Eliminates config duplication across environments + +--- + +## 📦 High Value + +### `init` + +Bootstrap a new project with sensible defaults and examples. + +**Purpose:** Quick start for new users, reduces friction. + +**Usage:** + +```bash +# Create example config in current directory +cfgx init + +# Specify output directory +cfgx init --dir config/ + +# Choose a template +cfgx init --template web-server +cfgx init --template cli-tool +``` + +**Creates:** + +``` +config/ +├── config.toml # Example TOML with comments +├── config.go # Generated output (gitignored) +└── gen.go # go:generate directive +``` + +**Priority:** Medium - Improves onboarding experience + +--- + +### `validate` + +Validate TOML syntax and check for common configuration issues. + +**Purpose:** Catch errors before generation, provide helpful feedback. + +**Usage:** + +```bash +# Validate single file +cfgx validate config.toml + +# Validate with generation mode checks +cfgx validate config.toml --mode getter + +# Check all TOML files in directory +cfgx validate --dir config/ + +# CI mode: exit code only +cfgx validate config.toml --quiet +``` + +**Checks:** + +- TOML syntax errors +- File references exist and are readable +- Duration strings are valid +- Array type consistency +- Reasonable file sizes for `file:` references +- Warn about common mistakes (e.g., forgetting quotes on duration strings) + +**Priority:** Medium - Improves error messages and catches issues early + +--- + +### `fmt` + +Format TOML files with consistent style. + +**Purpose:** Like `go fmt` but for TOML - standardize formatting across projects. + +**Usage:** + +```bash +# Format in place +cfgx fmt config.toml + +# Check if formatted +cfgx fmt --check config.toml + +# Format all TOML files +cfgx fmt config/*.toml + +# Preview changes +cfgx fmt --diff config.toml +``` + +**Formatting Rules:** + +- Consistent indentation (2 spaces) +- Alphabetize keys within sections +- Blank line between sections +- Comments stay with their keys +- Arrays formatted consistently + +**Priority:** Medium - Nice-to-have for team consistency + +--- + +## 🎨 Nice-to-Have + +### `preview` + +Show generated code without writing to disk (dry-run). + +**Usage:** + +```bash +# Print to stdout +cfgx preview --in config.toml + +# Preview with syntax highlighting (if terminal supports it) +cfgx preview --in config.toml --color + +# Preview specific section +cfgx preview --in config.toml --section server +``` + +**Priority:** Low - Can achieve with `--out /dev/stdout` or similar + +--- + +### `inspect` + +Display parsed TOML structure in human-readable format. + +**Purpose:** Debug what cfgx sees, understand nested structures. + +**Usage:** + +```bash +# Show structure +cfgx inspect config.toml + +# As JSON +cfgx inspect config.toml --format json + +# Show types that will be generated +cfgx inspect config.toml --show-types +``` + +**Output Example:** + +``` +server (table) + ├─ addr: ":8080" (string) + ├─ timeout: "30s" (duration) + └─ debug: true (bool) + +database (table) + ├─ dsn: "..." (string) + └─ pool (table) + ├─ max_size: 10 (int64) + └─ timeout: "5s" (duration) +``` + +**Priority:** Low - Useful for debugging, not critical + +--- + +### `comment-sync` + +Extract TOML comments and generate Go documentation. + +**Purpose:** Keep config documentation in one place (TOML), flow to generated code. + +**Usage:** + +```bash +# Generate with inline comments as doc strings +cfgx generate --in config.toml --out config.go --with-comments + +# Or as separate command +cfgx comment-sync --in config.toml --out config.go +``` + +**Example:** + +```toml +[server] +# The address to bind the HTTP server to. +# Supports host:port format, e.g., "localhost:8080" or ":8080" for all interfaces. +addr = ":8080" + +# Request timeout duration. Must be at least 1 second. +timeout = "30s" +``` + +Generates: + +```go +type ServerConfig struct { + // Addr is the address to bind the HTTP server to. + // Supports host:port format, e.g., "localhost:8080" or ":8080" for all interfaces. + Addr string + + // Timeout is the request timeout duration. Must be at least 1 second. + Timeout time.Duration +} +``` + +**Priority:** Low - Nice for documentation, but adds complexity + +--- + +## 🚫 Out of Scope + +These features go against cfgx's minimal philosophy or are better suited as separate projects: + +- **Secret manager integration** - Too complex, should use external tools (e.g., inject at build time) +- **GUI/web interface** - CLI-first tool, GUIs add maintenance burden +- **LSP/IDE plugins** - Separate project if needed +- **Multi-format support** (YAML, JSON, etc.) - TOML is purposefully chosen for config +- **Config encryption** - Use external secrets management +- **Remote config fetching** - Violates build-time philosophy +- **Dynamic reloading** - Runtime concern, not generation tool's job + +--- + +## Contributing + +Have an idea for a command? Start a discussion in [GitHub Discussions](https://github.com/gomantics/cfgx/discussions/categories/ideas). + +Keep it minimal! Commands should: + +- Solve a real, common problem +- Align with cfgx's philosophy (build-time, type-safe, simple) +- Not duplicate functionality available elsewhere +- Be implementable in <200 lines of code diff --git a/cmd/cfgx/main.go b/cmd/cfgx/main.go index baca746..8f96358 100644 --- a/cmd/cfgx/main.go +++ b/cmd/cfgx/main.go @@ -4,11 +4,16 @@ package main import ( "fmt" "os" + "os/signal" + "path/filepath" "runtime" "runtime/debug" "strconv" "strings" + "syscall" + "time" + "github.com/fsnotify/fsnotify" "github.com/spf13/cobra" "github.com/gomantics/cfgx" @@ -26,6 +31,7 @@ var ( noEnv bool maxFileSize string mode string + debounce int ) func main() { @@ -136,6 +142,142 @@ var generateCmd = &cobra.Command{ SilenceUsage: true, } +var watchCmd = &cobra.Command{ + Use: "watch", + Short: "Watch TOML file and auto-regenerate on changes", + Long: `Watch a TOML configuration file and automatically regenerate Go code when it changes.`, + Example: ` # Watch and auto-regenerate + cfgx watch --in config.toml --out config/config.go + + # With custom debounce (default 100ms) + cfgx watch --in config.toml --out config.go --debounce 200 + + # Watch with custom mode + cfgx watch --in config.toml --out config.go --mode getter`, + RunE: func(cmd *cobra.Command, args []string) error { + // Require -out flag + if outputFile == "" { + return fmt.Errorf("--out flag is required") + } + + // Validate mode + if mode != "static" && mode != "getter" { + return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) + } + + // Parse max file size + maxFileSizeBytes, err := parseFileSize(maxFileSize) + if err != nil { + return fmt.Errorf("invalid --max-file-size: %w", err) + } + + // Get absolute path for watching (fsnotify works better with absolute paths) + absInputFile, err := filepath.Abs(inputFile) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Create generate options + opts := &cfgx.GenerateOptions{ + InputFile: inputFile, + OutputFile: outputFile, + PackageName: packageName, + EnableEnv: !noEnv, + MaxFileSize: maxFileSizeBytes, + Mode: mode, + } + + // Perform initial generation + fmt.Printf("Generating %s...\n", outputFile) + if err := cfgx.GenerateFromFile(opts); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Println("Continuing to watch for changes...") + } else { + fmt.Printf("✓ Generated %s\n", outputFile) + } + + // Create file watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create watcher: %w", err) + } + defer watcher.Close() + + // Add file to watcher + if err := watcher.Add(absInputFile); err != nil { + return fmt.Errorf("failed to watch %s: %w", absInputFile, err) + } + + fmt.Printf("\nWatching %s for changes (Ctrl+C to stop)...\n", inputFile) + + // Setup signal handler for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Debounce timer + var debounceTimer *time.Timer + debounceDuration := time.Duration(debounce) * time.Millisecond + + // Watch loop + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + // Handle file events (Write, Create, Remove) + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + // Debounce: reset timer on each event + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(debounceDuration, func() { + fmt.Printf("\n[%s] Change detected, regenerating...\n", time.Now().Format("15:04:05")) + if err := cfgx.GenerateFromFile(opts); err != nil { + fmt.Fprintf(os.Stderr, "✗ Error: %v\n", err) + } else { + fmt.Printf("✓ Generated %s\n", outputFile) + } + }) + } else if event.Has(fsnotify.Remove) { + // File was removed - common with some editors (vim, etc.) + // Try to re-add the watcher when file is recreated + fmt.Println("File removed, waiting for recreation...") + // Remove from watcher (it's already gone) + watcher.Remove(absInputFile) + + // Try to re-add (with retries for editor save patterns) + go func() { + for i := 0; i < 10; i++ { + time.Sleep(100 * time.Millisecond) + if err := watcher.Add(absInputFile); err == nil { + fmt.Println("File recreated, watching again...") + return + } + } + fmt.Fprintf(os.Stderr, "Warning: Could not re-watch file after removal\n") + }() + } + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + fmt.Fprintf(os.Stderr, "Watch error: %v\n", err) + + case <-sigChan: + fmt.Println("\nStopping watch...") + if debounceTimer != nil { + debounceTimer.Stop() + } + return nil + } + } + }, + SilenceUsage: true, +} + var versionCmd = &cobra.Command{ Use: "version", Short: "Print version information", @@ -166,7 +308,19 @@ func init() { generateCmd.MarkFlagRequired("out") + // Watch command flags (reuse generate flags) + watchCmd.Flags().StringVarP(&inputFile, "in", "i", "config.toml", "input TOML file") + watchCmd.Flags().StringVarP(&outputFile, "out", "o", "", "output Go file (required)") + watchCmd.Flags().StringVarP(&packageName, "pkg", "p", "", "package name (default: inferred from output path or 'config')") + watchCmd.Flags().BoolVar(&noEnv, "no-env", false, "disable environment variable overrides") + watchCmd.Flags().StringVar(&maxFileSize, "max-file-size", "1MB", "maximum file size for file: references (e.g., 10MB, 1GB, 512KB)") + watchCmd.Flags().StringVar(&mode, "mode", "static", "generation mode: 'static' (values baked at build time) or 'getter' (runtime env var overrides)") + watchCmd.Flags().IntVar(&debounce, "debounce", 100, "debounce delay in milliseconds (prevents rapid regeneration)") + + watchCmd.MarkFlagRequired("out") + // Add subcommands rootCmd.AddCommand(generateCmd) + rootCmd.AddCommand(watchCmd) rootCmd.AddCommand(versionCmd) } diff --git a/go.mod b/go.mod index 4981d11..7a13e23 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.1 require ( github.com/BurntSushi/toml v1.5.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/gomantics/sx v0.0.3 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 @@ -14,5 +15,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.13.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2769f48..8498acf 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gomantics/sx v0.0.3 h1:Jvv3Wph5pGVolInwkpzdk4Ni3iIwlkdpsjRRBAu7v9g= github.com/gomantics/sx v0.0.3/go.mod h1:lghgwBNj3n0Wrwbwa5Ln7RZjSinlOZR2jQmv2vykEKE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -16,6 +18,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/readme.md b/readme.md index b6e01d0..a98357c 100644 --- a/readme.md +++ b/readme.md @@ -86,6 +86,10 @@ cfgx generate --in worker.toml --out config/worker.go ## CLI Reference +### `cfgx generate` + +Generate type-safe Go code from TOML configuration. + ``` cfgx generate [flags] @@ -99,6 +103,44 @@ Flags: Supports: KB, MB, GB (e.g., "5MB", "1GB", "512KB") ``` +### `cfgx watch` + +Watch TOML file and auto-regenerate on changes. Perfect for development workflows. + +``` +cfgx watch [flags] + +Flags: + -i, --in string Input TOML file (default "config.toml") + -o, --out string Output Go file (required) + -p, --pkg string Package name (inferred from output path) + --mode string Generation mode: 'static' or 'getter' (default "static") + --no-env Disable environment variable overrides (static mode only) + --max-file-size Maximum file size for file: references (default "1MB") + --debounce int Debounce delay in milliseconds (default 100) +``` + +**Example:** + +```bash +# Start watching +cfgx watch --in config.toml --out config/config.go + +# Watch with getter mode +cfgx watch --in config.toml --out config/config.go --mode getter + +# Adjust debounce for slower editors +cfgx watch --in config.toml --out config.go --debounce 200 +``` + +The watch command: + +- Generates code immediately on start +- Watches for file changes and regenerates automatically +- Continues watching even if generation fails +- Handles editor save patterns (file remove/recreate) +- Gracefully exits on Ctrl+C + ## Modes `cfgx` supports two generation modes, chosen via the `--mode` flag: From 94eafedc2c54f12d670a9e85ec31894fcae8cd1f Mon Sep 17 00:00:00 2001 From: Karn Date: Tue, 28 Oct 2025 23:57:04 +0530 Subject: [PATCH 2/4] cr --- cmd/cfgx/common.go | 61 +++++++++ cmd/cfgx/generate.go | 70 +++++++++++ cmd/cfgx/main.go | 287 ++----------------------------------------- cmd/cfgx/watch.go | 195 +++++++++++++++++++++++++++++ readme.md | 1 + 5 files changed, 335 insertions(+), 279 deletions(-) create mode 100644 cmd/cfgx/common.go create mode 100644 cmd/cfgx/generate.go create mode 100644 cmd/cfgx/watch.go diff --git a/cmd/cfgx/common.go b/cmd/cfgx/common.go new file mode 100644 index 0000000..a3afb24 --- /dev/null +++ b/cmd/cfgx/common.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "strconv" + "strings" +) + +var ( + inputFile string + outputFile string + packageName string + noEnv bool + maxFileSize string + mode string +) + +// parseFileSize parses a human-readable file size string like "10MB", "1GB", "512KB" +// into bytes. Returns 0 and error if parsing fails. +func parseFileSize(sizeStr string) (int64, error) { + if sizeStr == "" { + return 0, nil + } + + sizeStr = strings.TrimSpace(strings.ToUpper(sizeStr)) + + // Define multipliers in order from longest to shortest to avoid prefix issues + multipliers := []struct { + suffix string + multiplier int64 + }{ + {"TB", 1024 * 1024 * 1024 * 1024}, + {"GB", 1024 * 1024 * 1024}, + {"MB", 1024 * 1024}, + {"KB", 1024}, + {"B", 1}, + } + + // Try to parse with suffix (check longest first) + for _, m := range multipliers { + if strings.HasSuffix(sizeStr, m.suffix) { + numStr := strings.TrimSuffix(sizeStr, m.suffix) + numStr = strings.TrimSpace(numStr) + + num, err := strconv.ParseInt(numStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid size format: %s", sizeStr) + } + + return num * m.multiplier, nil + } + } + + // Try to parse as plain number (bytes) + num, err := strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid size format: %s", sizeStr) + } + + return num, nil +} diff --git a/cmd/cfgx/generate.go b/cmd/cfgx/generate.go new file mode 100644 index 0000000..6427ece --- /dev/null +++ b/cmd/cfgx/generate.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/gomantics/cfgx" +) + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate type-safe Go code from TOML config", + Long: `Generate type-safe Go code from TOML configuration files.`, + Example: ` # Generate config code + cfgx generate --in config.toml --out config/config.go + + # Custom package + cfgx generate --in app.toml --out pkg/appcfg/config.go --pkg appcfg + + # Disable environment variable overrides + cfgx generate --in config.toml --out config.go --no-env`, + RunE: func(cmd *cobra.Command, args []string) error { + // Require -out flag + if outputFile == "" { + return fmt.Errorf("--out flag is required") + } + + // Validate mode + if mode != "static" && mode != "getter" { + return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) + } + + // Parse max file size + maxFileSizeBytes, err := parseFileSize(maxFileSize) + if err != nil { + return fmt.Errorf("invalid --max-file-size: %w", err) + } + + // Use the public API + opts := &cfgx.GenerateOptions{ + InputFile: inputFile, + OutputFile: outputFile, + PackageName: packageName, + EnableEnv: !noEnv, + MaxFileSize: maxFileSizeBytes, + Mode: mode, + } + + if err := cfgx.GenerateFromFile(opts); err != nil { + return err + } + + fmt.Printf("Generated %s\n", outputFile) + return nil + }, + SilenceUsage: true, +} + +func init() { + // Generate command flags + generateCmd.Flags().StringVarP(&inputFile, "in", "i", "config.toml", "input TOML file") + generateCmd.Flags().StringVarP(&outputFile, "out", "o", "", "output Go file (required)") + generateCmd.Flags().StringVarP(&packageName, "pkg", "p", "", "package name (default: inferred from output path or 'config')") + generateCmd.Flags().BoolVar(&noEnv, "no-env", false, "disable environment variable overrides") + generateCmd.Flags().StringVar(&maxFileSize, "max-file-size", "1MB", "maximum file size for file: references (e.g., 10MB, 1GB, 512KB)") + generateCmd.Flags().StringVar(&mode, "mode", "static", "generation mode: 'static' (values baked at build time) or 'getter' (runtime env var overrides)") + + generateCmd.MarkFlagRequired("out") +} diff --git a/cmd/cfgx/main.go b/cmd/cfgx/main.go index 8f96358..379c820 100644 --- a/cmd/cfgx/main.go +++ b/cmd/cfgx/main.go @@ -4,19 +4,10 @@ package main import ( "fmt" "os" - "os/signal" - "path/filepath" "runtime" "runtime/debug" - "strconv" - "strings" - "syscall" - "time" - "github.com/fsnotify/fsnotify" "github.com/spf13/cobra" - - "github.com/gomantics/cfgx" ) var ( @@ -24,67 +15,12 @@ var ( version = "dev" ) -var ( - inputFile string - outputFile string - packageName string - noEnv bool - maxFileSize string - mode string - debounce int -) - func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } -// parseFileSize parses a human-readable file size string like "10MB", "1GB", "512KB" -// into bytes. Returns 0 and error if parsing fails. -func parseFileSize(sizeStr string) (int64, error) { - if sizeStr == "" { - return 0, nil - } - - sizeStr = strings.TrimSpace(strings.ToUpper(sizeStr)) - - // Define multipliers in order from longest to shortest to avoid prefix issues - multipliers := []struct { - suffix string - multiplier int64 - }{ - {"TB", 1024 * 1024 * 1024 * 1024}, - {"GB", 1024 * 1024 * 1024}, - {"MB", 1024 * 1024}, - {"KB", 1024}, - {"B", 1}, - } - - // Try to parse with suffix (check longest first) - for _, m := range multipliers { - if strings.HasSuffix(sizeStr, m.suffix) { - numStr := strings.TrimSuffix(sizeStr, m.suffix) - numStr = strings.TrimSpace(numStr) - - num, err := strconv.ParseInt(numStr, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid size format: %s", sizeStr) - } - - return num * m.multiplier, nil - } - } - - // Try to parse as plain number (bytes) - num, err := strconv.ParseInt(sizeStr, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid size format: %s", sizeStr) - } - - return num, nil -} - var rootCmd = &cobra.Command{ Use: "cfgx", Short: "Type-safe config generation for Go", @@ -93,199 +29,6 @@ var rootCmd = &cobra.Command{ It creates strongly-typed structs with values from the TOML file, with optional environment variable overrides.`, } -var generateCmd = &cobra.Command{ - Use: "generate", - Short: "Generate type-safe Go code from TOML config", - Long: `Generate type-safe Go code from TOML configuration files.`, - Example: ` # Generate config code - cfgx generate --in config.toml --out config/config.go - - # Custom package - cfgx generate --in app.toml --out pkg/appcfg/config.go --pkg appcfg - - # Disable environment variable overrides - cfgx generate --in config.toml --out config.go --no-env`, - RunE: func(cmd *cobra.Command, args []string) error { - // Require -out flag - if outputFile == "" { - return fmt.Errorf("--out flag is required") - } - - // Validate mode - if mode != "static" && mode != "getter" { - return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) - } - - // Parse max file size - maxFileSizeBytes, err := parseFileSize(maxFileSize) - if err != nil { - return fmt.Errorf("invalid --max-file-size: %w", err) - } - - // Use the public API - opts := &cfgx.GenerateOptions{ - InputFile: inputFile, - OutputFile: outputFile, - PackageName: packageName, - EnableEnv: !noEnv, - MaxFileSize: maxFileSizeBytes, - Mode: mode, - } - - if err := cfgx.GenerateFromFile(opts); err != nil { - return err - } - - fmt.Printf("Generated %s\n", outputFile) - return nil - }, - SilenceUsage: true, -} - -var watchCmd = &cobra.Command{ - Use: "watch", - Short: "Watch TOML file and auto-regenerate on changes", - Long: `Watch a TOML configuration file and automatically regenerate Go code when it changes.`, - Example: ` # Watch and auto-regenerate - cfgx watch --in config.toml --out config/config.go - - # With custom debounce (default 100ms) - cfgx watch --in config.toml --out config.go --debounce 200 - - # Watch with custom mode - cfgx watch --in config.toml --out config.go --mode getter`, - RunE: func(cmd *cobra.Command, args []string) error { - // Require -out flag - if outputFile == "" { - return fmt.Errorf("--out flag is required") - } - - // Validate mode - if mode != "static" && mode != "getter" { - return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) - } - - // Parse max file size - maxFileSizeBytes, err := parseFileSize(maxFileSize) - if err != nil { - return fmt.Errorf("invalid --max-file-size: %w", err) - } - - // Get absolute path for watching (fsnotify works better with absolute paths) - absInputFile, err := filepath.Abs(inputFile) - if err != nil { - return fmt.Errorf("failed to get absolute path: %w", err) - } - - // Create generate options - opts := &cfgx.GenerateOptions{ - InputFile: inputFile, - OutputFile: outputFile, - PackageName: packageName, - EnableEnv: !noEnv, - MaxFileSize: maxFileSizeBytes, - Mode: mode, - } - - // Perform initial generation - fmt.Printf("Generating %s...\n", outputFile) - if err := cfgx.GenerateFromFile(opts); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Println("Continuing to watch for changes...") - } else { - fmt.Printf("✓ Generated %s\n", outputFile) - } - - // Create file watcher - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("failed to create watcher: %w", err) - } - defer watcher.Close() - - // Add file to watcher - if err := watcher.Add(absInputFile); err != nil { - return fmt.Errorf("failed to watch %s: %w", absInputFile, err) - } - - fmt.Printf("\nWatching %s for changes (Ctrl+C to stop)...\n", inputFile) - - // Setup signal handler for graceful shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - // Debounce timer - var debounceTimer *time.Timer - debounceDuration := time.Duration(debounce) * time.Millisecond - - // Watch loop - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return nil - } - - // Handle file events (Write, Create, Remove) - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { - // Debounce: reset timer on each event - if debounceTimer != nil { - debounceTimer.Stop() - } - debounceTimer = time.AfterFunc(debounceDuration, func() { - fmt.Printf("\n[%s] Change detected, regenerating...\n", time.Now().Format("15:04:05")) - if err := cfgx.GenerateFromFile(opts); err != nil { - fmt.Fprintf(os.Stderr, "✗ Error: %v\n", err) - } else { - fmt.Printf("✓ Generated %s\n", outputFile) - } - }) - } else if event.Has(fsnotify.Remove) { - // File was removed - common with some editors (vim, etc.) - // Try to re-add the watcher when file is recreated - fmt.Println("File removed, waiting for recreation...") - // Remove from watcher (it's already gone) - watcher.Remove(absInputFile) - - // Try to re-add (with retries for editor save patterns) - go func() { - for i := 0; i < 10; i++ { - time.Sleep(100 * time.Millisecond) - if err := watcher.Add(absInputFile); err == nil { - fmt.Println("File recreated, watching again...") - return - } - } - fmt.Fprintf(os.Stderr, "Warning: Could not re-watch file after removal\n") - }() - } - - case err, ok := <-watcher.Errors: - if !ok { - return nil - } - fmt.Fprintf(os.Stderr, "Watch error: %v\n", err) - - case <-sigChan: - fmt.Println("\nStopping watch...") - if debounceTimer != nil { - debounceTimer.Stop() - } - return nil - } - } - }, - SilenceUsage: true, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("cfgx %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) - }, -} - func init() { // If version info wasn't set via ldflags (e.g., when using go install), // try to get it from build info embedded by Go @@ -298,29 +41,15 @@ func init() { } } - // Generate command flags - generateCmd.Flags().StringVarP(&inputFile, "in", "i", "config.toml", "input TOML file") - generateCmd.Flags().StringVarP(&outputFile, "out", "o", "", "output Go file (required)") - generateCmd.Flags().StringVarP(&packageName, "pkg", "p", "", "package name (default: inferred from output path or 'config')") - generateCmd.Flags().BoolVar(&noEnv, "no-env", false, "disable environment variable overrides") - generateCmd.Flags().StringVar(&maxFileSize, "max-file-size", "1MB", "maximum file size for file: references (e.g., 10MB, 1GB, 512KB)") - generateCmd.Flags().StringVar(&mode, "mode", "static", "generation mode: 'static' (values baked at build time) or 'getter' (runtime env var overrides)") - - generateCmd.MarkFlagRequired("out") - - // Watch command flags (reuse generate flags) - watchCmd.Flags().StringVarP(&inputFile, "in", "i", "config.toml", "input TOML file") - watchCmd.Flags().StringVarP(&outputFile, "out", "o", "", "output Go file (required)") - watchCmd.Flags().StringVarP(&packageName, "pkg", "p", "", "package name (default: inferred from output path or 'config')") - watchCmd.Flags().BoolVar(&noEnv, "no-env", false, "disable environment variable overrides") - watchCmd.Flags().StringVar(&maxFileSize, "max-file-size", "1MB", "maximum file size for file: references (e.g., 10MB, 1GB, 512KB)") - watchCmd.Flags().StringVar(&mode, "mode", "static", "generation mode: 'static' (values baked at build time) or 'getter' (runtime env var overrides)") - watchCmd.Flags().IntVar(&debounce, "debounce", 100, "debounce delay in milliseconds (prevents rapid regeneration)") - - watchCmd.MarkFlagRequired("out") - - // Add subcommands rootCmd.AddCommand(generateCmd) rootCmd.AddCommand(watchCmd) rootCmd.AddCommand(versionCmd) } + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("cfgx %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) + }, +} diff --git a/cmd/cfgx/watch.go b/cmd/cfgx/watch.go new file mode 100644 index 0000000..de90329 --- /dev/null +++ b/cmd/cfgx/watch.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/cobra" + + "github.com/gomantics/cfgx" +) + +var ( + debounce int +) + +var watchCmd = &cobra.Command{ + Use: "watch", + Short: "Watch TOML file and auto-regenerate on changes", + Long: `Watch a TOML configuration file and automatically regenerate Go code when it changes.`, + Example: ` # Watch and auto-regenerate + cfgx watch --in config.toml --out config/config.go + + # With custom debounce (default 100ms) + cfgx watch --in config.toml --out config.go --debounce 200 + + # Watch with custom mode + cfgx watch --in config.toml --out config.go --mode getter`, + RunE: func(cmd *cobra.Command, args []string) error { + // Require -out flag + if outputFile == "" { + return fmt.Errorf("--out flag is required") + } + + // Validate mode + if mode != "static" && mode != "getter" { + return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) + } + + // Parse max file size + maxFileSizeBytes, err := parseFileSize(maxFileSize) + if err != nil { + return fmt.Errorf("invalid --max-file-size: %w", err) + } + + // Get absolute path for watching (fsnotify works better with absolute paths) + absInputFile, err := filepath.Abs(inputFile) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Create generate options + opts := &cfgx.GenerateOptions{ + InputFile: inputFile, + OutputFile: outputFile, + PackageName: packageName, + EnableEnv: !noEnv, + MaxFileSize: maxFileSizeBytes, + Mode: mode, + } + + // Perform initial generation + fmt.Printf("Generating %s...\n", outputFile) + if err := cfgx.GenerateFromFile(opts); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Println("Continuing to watch for changes...") + } else { + fmt.Printf("✓ Generated %s\n", outputFile) + } + + // Create file watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create watcher: %w", err) + } + defer watcher.Close() + + // Add file to watcher + if err := watcher.Add(absInputFile); err != nil { + return fmt.Errorf("failed to watch %s: %w", absInputFile, err) + } + + fmt.Printf("\nWatching %s for changes (Ctrl+C to stop)...\n", inputFile) + + // Setup context for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup signal handler for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigChan) + + // Debounce timer with mutex for thread-safe access + var ( + debounceTimer *time.Timer + timerMu sync.Mutex + ) + debounceDuration := time.Duration(debounce) * time.Millisecond + + // Track if a file re-add goroutine is already running + var readdInProgress atomic.Bool + + // Watch loop + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + // Handle file events (Write, Create, Remove) + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + // Debounce: reset timer on each event + timerMu.Lock() + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(debounceDuration, func() { + fmt.Printf("\n[%s] Change detected, regenerating...\n", time.Now().Format("15:04:05")) + if err := cfgx.GenerateFromFile(opts); err != nil { + fmt.Fprintf(os.Stderr, "✗ Error: %v\n", err) + } else { + fmt.Printf("✓ Generated %s\n", outputFile) + } + }) + timerMu.Unlock() + } else if event.Has(fsnotify.Remove) { + // File was removed - common with some editors (vim, etc.) + // Try to re-add the watcher when file is recreated + fmt.Println("File removed, waiting for recreation...") + // Remove from watcher (it's already gone) + watcher.Remove(absInputFile) + + // Only spawn one re-add goroutine at a time + if readdInProgress.CompareAndSwap(false, true) { + go func() { + defer readdInProgress.Store(false) + + for i := 0; i < 10; i++ { + select { + case <-ctx.Done(): + // Context cancelled, exit gracefully + return + case <-time.After(100 * time.Millisecond): + if err := watcher.Add(absInputFile); err == nil { + fmt.Println("File recreated, watching again...") + return + } + } + } + fmt.Fprintf(os.Stderr, "Warning: Could not re-watch file after removal\n") + }() + } + } + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + fmt.Fprintf(os.Stderr, "Watch error: %v\n", err) + + case <-sigChan: + fmt.Println("\nStopping watch...") + timerMu.Lock() + if debounceTimer != nil { + debounceTimer.Stop() + } + timerMu.Unlock() + return nil + } + } + }, + SilenceUsage: true, +} + +func init() { + // Watch command flags (reuse generate flags) + watchCmd.Flags().StringVarP(&inputFile, "in", "i", "config.toml", "input TOML file") + watchCmd.Flags().StringVarP(&outputFile, "out", "o", "", "output Go file (required)") + watchCmd.Flags().StringVarP(&packageName, "pkg", "p", "", "package name (default: inferred from output path or 'config')") + watchCmd.Flags().BoolVar(&noEnv, "no-env", false, "disable environment variable overrides") + watchCmd.Flags().StringVar(&maxFileSize, "max-file-size", "1MB", "maximum file size for file: references (e.g., 10MB, 1GB, 512KB)") + watchCmd.Flags().StringVar(&mode, "mode", "static", "generation mode: 'static' (values baked at build time) or 'getter' (runtime env var overrides)") + watchCmd.Flags().IntVar(&debounce, "debounce", 100, "debounce delay in milliseconds (prevents rapid regeneration)") + + watchCmd.MarkFlagRequired("out") +} diff --git a/readme.md b/readme.md index a98357c..94295cd 100644 --- a/readme.md +++ b/readme.md @@ -117,6 +117,7 @@ Flags: --mode string Generation mode: 'static' or 'getter' (default "static") --no-env Disable environment variable overrides (static mode only) --max-file-size Maximum file size for file: references (default "1MB") + Supports: KB, MB, GB (e.g., "5MB", "1GB", "512KB") --debounce int Debounce delay in milliseconds (default 100) ``` From e404a0a2768e2171d518e3ea6f26b874e6fb6397 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 29 Oct 2025 00:03:09 +0530 Subject: [PATCH 3/4] Update readme.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 94295cd..de39b09 100644 --- a/readme.md +++ b/readme.md @@ -130,7 +130,7 @@ cfgx watch --in config.toml --out config/config.go # Watch with getter mode cfgx watch --in config.toml --out config/config.go --mode getter -# Adjust debounce for slower editors +# Increase debounce delay to reduce rapid regenerations cfgx watch --in config.toml --out config.go --debounce 200 ``` From 54b400e231ca1f84cdbd5cc7bd77fdd101c4f205 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 29 Oct 2025 00:06:33 +0530 Subject: [PATCH 4/4] nits --- cmd/cfgx/watch.go | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/cmd/cfgx/watch.go b/cmd/cfgx/watch.go index de90329..9fa81cb 100644 --- a/cmd/cfgx/watch.go +++ b/cmd/cfgx/watch.go @@ -34,29 +34,24 @@ var watchCmd = &cobra.Command{ # Watch with custom mode cfgx watch --in config.toml --out config.go --mode getter`, RunE: func(cmd *cobra.Command, args []string) error { - // Require -out flag if outputFile == "" { return fmt.Errorf("--out flag is required") } - // Validate mode if mode != "static" && mode != "getter" { return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) } - // Parse max file size maxFileSizeBytes, err := parseFileSize(maxFileSize) if err != nil { return fmt.Errorf("invalid --max-file-size: %w", err) } - // Get absolute path for watching (fsnotify works better with absolute paths) absInputFile, err := filepath.Abs(inputFile) if err != nil { return fmt.Errorf("failed to get absolute path: %w", err) } - // Create generate options opts := &cfgx.GenerateOptions{ InputFile: inputFile, OutputFile: outputFile, @@ -66,7 +61,6 @@ var watchCmd = &cobra.Command{ Mode: mode, } - // Perform initial generation fmt.Printf("Generating %s...\n", outputFile) if err := cfgx.GenerateFromFile(opts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -75,40 +69,33 @@ var watchCmd = &cobra.Command{ fmt.Printf("✓ Generated %s\n", outputFile) } - // Create file watcher watcher, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf("failed to create watcher: %w", err) } defer watcher.Close() - // Add file to watcher if err := watcher.Add(absInputFile); err != nil { return fmt.Errorf("failed to watch %s: %w", absInputFile, err) } fmt.Printf("\nWatching %s for changes (Ctrl+C to stop)...\n", inputFile) - // Setup context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Setup signal handler for graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigChan) - // Debounce timer with mutex for thread-safe access var ( debounceTimer *time.Timer timerMu sync.Mutex ) debounceDuration := time.Duration(debounce) * time.Millisecond - // Track if a file re-add goroutine is already running var readdInProgress atomic.Bool - // Watch loop for { select { case event, ok := <-watcher.Events: @@ -133,21 +120,18 @@ var watchCmd = &cobra.Command{ }) timerMu.Unlock() } else if event.Has(fsnotify.Remove) { - // File was removed - common with some editors (vim, etc.) - // Try to re-add the watcher when file is recreated fmt.Println("File removed, waiting for recreation...") - // Remove from watcher (it's already gone) - watcher.Remove(absInputFile) + if err := watcher.Remove(absInputFile); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove watcher: %v\n", err) + } - // Only spawn one re-add goroutine at a time if readdInProgress.CompareAndSwap(false, true) { go func() { defer readdInProgress.Store(false) - for i := 0; i < 10; i++ { + for range 10 { select { case <-ctx.Done(): - // Context cancelled, exit gracefully return case <-time.After(100 * time.Millisecond): if err := watcher.Add(absInputFile); err == nil {