diff --git a/README.md b/README.md index bbad8dc..601f11a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,18 @@ watchr "ps aux" # Refresh every 2 seconds watchr -r 2 "docker ps" +# Refresh every 500 milliseconds +watchr -r 500ms "date" + +# Refresh every 1.5 seconds +watchr -r 1.5s "kubectl get pods" + +# Refresh every 5 minutes +watchr -r 5m "df -h" + +# Refresh every hour +watchr -r 1h "curl -s https://api.example.com/status" + # Watch file changes watchr -r 5 "find . -name '*.go' -mmin -1" ``` @@ -90,7 +102,7 @@ Options: -v, --version Show version -c, --config string Load config from specified path -C, --show-config Show loaded configuration and exit - -r, --refresh int Auto-refresh interval in seconds (0 = disabled) + -r, --refresh string Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled) -p, --prompt string Prompt string (default "watchr> ") -s, --shell string Shell to use for executing commands (default "sh") -n, --no-line-numbers Disable line numbers @@ -124,7 +136,7 @@ preview-position: right line-numbers: true line-width: 4 prompt: "> " -refresh: 0 +refresh: 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h" interactive: false ``` @@ -136,7 +148,7 @@ preview-position = "right" line-numbers = true line-width = 4 prompt = "> " -refresh = 0 +refresh = 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h" interactive = false ``` @@ -154,6 +166,10 @@ interactive = false } ``` +The `refresh` option accepts: +- Numbers: `2` or `1.5` (interpreted as seconds) +- Explicit units: `"500ms"`, `"2s"`, `"5m"`, `"1h"` + ### Priority Order Configuration values are applied in this order (later sources override earlier ones): diff --git a/internal/config/config.go b/internal/config/config.go index 10ffe40..08e9257 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,10 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" + "strconv" + "time" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -30,7 +33,7 @@ func setDefaults() { viper.SetDefault(KeyLineNumbers, true) viper.SetDefault(KeyLineWidth, 6) viper.SetDefault(KeyPrompt, "watchr> ") - viper.SetDefault(KeyRefresh, 0) + viper.SetDefault(KeyRefresh, "0") viper.SetDefault(KeyInteractive, false) } @@ -129,7 +132,7 @@ func PrintConfig() { fmt.Printf(" %-20s %v\n", KeyLineNumbers+":", GetBool(KeyLineNumbers)) fmt.Printf(" %-20s %d\n", KeyLineWidth+":", GetInt(KeyLineWidth)) fmt.Printf(" %-20s %q\n", KeyPrompt+":", GetString(KeyPrompt)) - fmt.Printf(" %-20s %d\n", KeyRefresh+":", GetInt(KeyRefresh)) + fmt.Printf(" %-20s %s\n", KeyRefresh+":", GetString(KeyRefresh)) fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive)) } @@ -149,3 +152,57 @@ func getConfigDir() string { return "" } } + +// durationRegex matches duration strings like "1", "1.5", "500ms", "1s", "1.5s", "5m", "1h" +var durationRegex = regexp.MustCompile(`^(\d+(?:\.\d+)?)(ms|s|m|h)?$`) + +// ParseDuration parses a duration string into a time.Duration. +// Supported formats: +// - "1", "1.5" - interpreted as seconds (default unit) +// - "1s", "1.5s" - explicit seconds +// - "500ms", "1500ms" - explicit milliseconds +// - "5m", "1.5m" - explicit minutes +// - "1h", "0.5h" - explicit hours +// +// Returns 0 if the input is empty or "0". +func ParseDuration(s string) (time.Duration, error) { + if s == "" || s == "0" { + return 0, nil + } + + matches := durationRegex.FindStringSubmatch(s) + if matches == nil { + return 0, fmt.Errorf("invalid duration format: %q (expected number, Xms, Xs, Xm, or Xh)", s) + } + + value, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return 0, fmt.Errorf("invalid duration value: %q", matches[1]) + } + + unit := matches[2] + switch unit { + case "ms": + return time.Duration(value * float64(time.Millisecond)), nil + case "s", "": + // Default to seconds + return time.Duration(value * float64(time.Second)), nil + case "m": + return time.Duration(value * float64(time.Minute)), nil + case "h": + return time.Duration(value * float64(time.Hour)), nil + default: + return 0, fmt.Errorf("invalid duration unit: %q", unit) + } +} + +// GetDuration returns a duration config value by parsing the string value. +// Returns 0 if parsing fails or value is empty. +func GetDuration(key string) time.Duration { + s := viper.GetString(key) + d, err := ParseDuration(s) + if err != nil { + return 0 + } + return d +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6f1cf09..2bc72cb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -70,8 +71,8 @@ func TestInit(t *testing.T) { t.Errorf("expected default prompt 'watchr> ', got %q", got) } - if got := viper.GetInt(KeyRefresh); got != 0 { - t.Errorf("expected default refresh 0, got %d", got) + if got := viper.GetString(KeyRefresh); got != "0" { + t.Errorf("expected default refresh '0', got %q", got) } } @@ -131,7 +132,7 @@ func TestBindFlags(t *testing.T) { flags.String("preview-position", "bottom", "") flags.Int("line-width", 6, "") flags.String("prompt", "watchr> ", "") - flags.Int("refresh", 0, "") + flags.String("refresh", "0", "") flags.Bool("no-line-numbers", false, "") // Parse with custom values @@ -204,8 +205,8 @@ refresh: 5 t.Errorf("expected prompt 'test> ' from config file, got %q", got) } - if got := GetInt(KeyRefresh); got != 5 { - t.Errorf("expected refresh 5 from config file, got %d", got) + if got := GetDuration(KeyRefresh); got != 5*time.Second { + t.Errorf("expected refresh 5s from config file, got %v", got) } // ConfigFileUsed should return the path @@ -237,7 +238,7 @@ preview-size: "60%" flags.String("preview-position", "bottom", "") flags.Int("line-width", 6, "") flags.String("prompt", "watchr> ", "") - flags.Int("refresh", 0, "") + flags.String("refresh", "0", "") flags.Bool("no-line-numbers", false, "") // Override shell via flag @@ -322,8 +323,8 @@ refresh: 10 t.Errorf("expected prompt 'custom> ', got %q", got) } - if got := GetInt(KeyRefresh); got != 10 { - t.Errorf("expected refresh 10, got %d", got) + if got := GetDuration(KeyRefresh); got != 10*time.Second { + t.Errorf("expected refresh 10s, got %v", got) } // ConfigFileUsed should return the specified path @@ -434,3 +435,178 @@ func TestInitWithFileDefaults(t *testing.T) { t.Errorf("expected default line-width 6, got %d", got) } } + +func TestParseDuration(t *testing.T) { + tests := []struct { + input string + expected time.Duration + wantErr bool + }{ + // Empty and zero values + {"", 0, false}, + {"0", 0, false}, + + // Plain numbers (default to seconds) + {"1", 1 * time.Second, false}, + {"5", 5 * time.Second, false}, + {"60", 60 * time.Second, false}, + + // Decimal seconds + {"0.5", 500 * time.Millisecond, false}, + {"1.5", 1500 * time.Millisecond, false}, + {"2.25", 2250 * time.Millisecond, false}, + {"0.1", 100 * time.Millisecond, false}, + {"0.001", 1 * time.Millisecond, false}, + + // Explicit seconds suffix + {"1s", 1 * time.Second, false}, + {"5s", 5 * time.Second, false}, + {"0.5s", 500 * time.Millisecond, false}, + {"1.5s", 1500 * time.Millisecond, false}, + + // Milliseconds suffix + {"100ms", 100 * time.Millisecond, false}, + {"500ms", 500 * time.Millisecond, false}, + {"1000ms", 1000 * time.Millisecond, false}, + {"1500ms", 1500 * time.Millisecond, false}, + {"50.5ms", 50500 * time.Microsecond, false}, + + // Minutes suffix + {"1m", 1 * time.Minute, false}, + {"5m", 5 * time.Minute, false}, + {"0.5m", 30 * time.Second, false}, + {"1.5m", 90 * time.Second, false}, + + // Hours suffix + {"1h", 1 * time.Hour, false}, + {"2h", 2 * time.Hour, false}, + {"0.5h", 30 * time.Minute, false}, + {"1.5h", 90 * time.Minute, false}, + + // Invalid formats + {"abc", 0, true}, + {"1d", 0, true}, // days not supported + {"1w", 0, true}, // weeks not supported + {"-1", 0, true}, // negative not supported + {"-1s", 0, true}, // negative not supported + {"1.2.3", 0, true}, + {"s", 0, true}, + {"ms", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseDuration(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("ParseDuration(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Errorf("ParseDuration(%q) unexpected error: %v", tt.input, err) + return + } + if got != tt.expected { + t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestGetDuration(t *testing.T) { + _, cleanup := isolateConfig(t) + defer cleanup() + + Init() + + // Test default value + if got := GetDuration(KeyRefresh); got != 0 { + t.Errorf("expected default refresh 0, got %v", got) + } + + // Test with seconds value + viper.Set(KeyRefresh, "5") + if got := GetDuration(KeyRefresh); got != 5*time.Second { + t.Errorf("expected refresh 5s, got %v", got) + } + + // Test with decimal value + viper.Set(KeyRefresh, "0.5") + if got := GetDuration(KeyRefresh); got != 500*time.Millisecond { + t.Errorf("expected refresh 500ms, got %v", got) + } + + // Test with explicit seconds suffix + viper.Set(KeyRefresh, "2s") + if got := GetDuration(KeyRefresh); got != 2*time.Second { + t.Errorf("expected refresh 2s, got %v", got) + } + + // Test with milliseconds + viper.Set(KeyRefresh, "250ms") + if got := GetDuration(KeyRefresh); got != 250*time.Millisecond { + t.Errorf("expected refresh 250ms, got %v", got) + } + + // Test with invalid value (should return 0) + viper.Set(KeyRefresh, "invalid") + if got := GetDuration(KeyRefresh); got != 0 { + t.Errorf("expected refresh 0 for invalid value, got %v", got) + } +} + +func TestRefreshDurationFromConfigFile(t *testing.T) { + tmpDir, cleanup := isolateConfig(t) + defer cleanup() + + tests := []struct { + name string + yaml string + expected time.Duration + }{ + { + name: "integer seconds", + yaml: "refresh: 5\n", + expected: 5 * time.Second, + }, + { + name: "decimal seconds", + yaml: "refresh: 0.5\n", + expected: 500 * time.Millisecond, + }, + { + name: "string with s suffix", + yaml: "refresh: \"2s\"\n", + expected: 2 * time.Second, + }, + { + name: "string with ms suffix", + yaml: "refresh: \"500ms\"\n", + expected: 500 * time.Millisecond, + }, + { + name: "string decimal with s suffix", + yaml: "refresh: \"1.5s\"\n", + expected: 1500 * time.Millisecond, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetViper() + + configPath := filepath.Join(tmpDir, "watchr.yaml") + if err := os.WriteFile(configPath, []byte(tt.yaml), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + Init() + + got := GetDuration(KeyRefresh) + if got != tt.expected { + t.Errorf("GetDuration(KeyRefresh) = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index bb62ea4..16a7aa3 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -33,7 +33,7 @@ type Config struct { ShowLineNums bool LineNumWidth int Prompt string - RefreshSeconds int + RefreshInterval time.Duration Interactive bool } @@ -213,7 +213,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // If auto-refresh is enabled, schedule the next run - if m.config.RefreshSeconds > 0 { + if m.config.RefreshInterval > 0 { return m, m.tickCmd() } return m, nil @@ -223,7 +223,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.streamTickCmd() case tickMsg: - if m.config.RefreshSeconds > 0 && !m.streaming { + if m.config.RefreshInterval > 0 && !m.streaming { // Restart streaming for refresh cmd := m.startStreaming() return m, tea.Batch(cmd, m.spinnerTickCmd()) @@ -252,7 +252,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) tickCmd() tea.Cmd { - return tea.Tick(time.Duration(m.config.RefreshSeconds)*time.Second, func(t time.Time) tea.Msg { + return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg { return tickMsg(t) }) } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 9309996..8180c04 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -2,6 +2,7 @@ package ui import ( "testing" + "time" "github.com/chenasraf/watchr/internal/runner" ) @@ -16,7 +17,7 @@ func TestConfig(t *testing.T) { ShowLineNums: true, LineNumWidth: 6, Prompt: "watchr> ", - RefreshSeconds: 5, + RefreshInterval: 5 * time.Second, } if cfg.Command != "echo test" { @@ -51,8 +52,8 @@ func TestConfig(t *testing.T) { t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt) } - if cfg.RefreshSeconds != 5 { - t.Errorf("expected refresh seconds 5, got %d", cfg.RefreshSeconds) + if cfg.RefreshInterval != 5*time.Second { + t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval) } } diff --git a/main.go b/main.go index 91f488a..0e2e973 100644 --- a/main.go +++ b/main.go @@ -34,7 +34,7 @@ func main() { flag.IntP("line-width", "w", 6, "Line number width") flag.StringP("prompt", "p", "watchr> ", "Prompt string") flag.StringP("shell", "s", "sh", "Shell to use for executing commands") - flag.IntP("refresh", "r", 0, "Auto-refresh interval in seconds (0 = disabled)") + flag.StringP("refresh", "r", "0", "Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)") flag.BoolP("interactive", "i", false, "Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)") printUsage := func(w *os.File) { @@ -108,7 +108,7 @@ func main() { shell := config.GetString(config.KeyShell) lineNumWidth := config.GetInt(config.KeyLineWidth) prompt := config.GetString(config.KeyPrompt) - refreshSeconds := config.GetInt(config.KeyRefresh) + refreshInterval := config.GetDuration(config.KeyRefresh) showLineNums := config.ShowLineNumbers() interactive := config.GetBool(config.KeyInteractive) @@ -130,7 +130,7 @@ func main() { ShowLineNums: showLineNums, LineNumWidth: lineNumWidth, Prompt: prompt, - RefreshSeconds: refreshSeconds, + RefreshInterval: refreshInterval, Interactive: interactive, }