diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 22:39:16 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 22:39:16 +0200 |
| commit | ac0a71de2a4a8894de849d10bbc4de962a5c0c2b (patch) | |
| tree | c0ddc25df27c7518ed8df08f8e9e16b4ccfdb6c8 | |
| parent | c3c347d6faed97d9cc02bf326e2a74786b0bde99 (diff) | |
Task 355: add cobra root command with config preload
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 10 | ||||
| -rw-r--r-- | internal/cli/root.go | 59 | ||||
| -rw-r--r-- | internal/cli/root_test.go | 128 |
4 files changed, 200 insertions, 0 deletions
@@ -16,6 +16,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -24,6 +25,8 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect @@ -14,8 +14,11 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= @@ -35,8 +38,14 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= @@ -47,3 +56,4 @@ golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..6029e81 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,59 @@ +package cli + +import ( + "fmt" + + timr "codeberg.org/snonux/timr/internal" + "codeberg.org/snonux/timr/internal/config" + "github.com/spf13/cobra" +) + +var loadedConfig = config.Default() + +// Execute runs the root command. +func Execute() error { + return NewRootCmd().Execute() +} + +// NewRootCmd creates the Cobra root command. +func NewRootCmd() *cobra.Command { + var configPath string + var showVersion bool + + cmd := &cobra.Command{ + Use: "timr", + Short: "Track time from your terminal", + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if showVersion { + return nil + } + + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + loadedConfig = cfg + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if showVersion { + _, err := fmt.Fprintln(cmd.OutOrStdout(), timr.Version) + return err + } + + return cmd.Help() + }, + } + + cmd.Flags().BoolVar(&showVersion, "version", false, "Print version and exit") + cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file") + + return cmd +} + +// CurrentConfig returns the config loaded in PersistentPreRunE. +func CurrentConfig() config.Config { + return loadedConfig +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..c900027 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + timr "codeberg.org/snonux/timr/internal" +) + +func TestRootVersionFlag(t *testing.T) { + loadedConfig = CurrentConfig() + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--version"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if strings.TrimSpace(out.String()) != timr.Version { + t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timr.Version) + } +} + +func TestRootLoadsConfigInPersistentPreRun(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + cfgPath := filepath.Join(t.TempDir(), "config.json") + content := `{ + "hostname": "from-config", + "worktime_db_dir": "~/custom-db" +}` + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cmd := NewRootCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--config", cfgPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + cfg := CurrentConfig() + if cfg.Hostname != "from-config" { + t.Fatalf("Hostname = %q, want %q", cfg.Hostname, "from-config") + } + + wantDir := filepath.Join(tempHome, "custom-db") + if cfg.WorktimeDBDir != wantDir { + t.Fatalf("WorktimeDBDir = %q, want %q", cfg.WorktimeDBDir, wantDir) + } +} + +func TestRootInvalidConfigFileReturnsError(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, []byte(`{"hostname":`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cmd := NewRootCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--config", cfgPath}) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want config parse error") + } + if !strings.Contains(err.Error(), "load config") { + t.Fatalf("Execute() error = %v, want load config context", err) + } +} + +func TestVersionSkipsConfigLoading(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, []byte(`{"hostname":`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--config", cfgPath, "--version"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if strings.TrimSpace(out.String()) != timr.Version { + t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timr.Version) + } +} + +func TestRootUsesDefaultConfigWhenNoFileExists(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tempHome, ".config")) + + cmd := NewRootCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + cfg := CurrentConfig() + if cfg.WeekWorkHours != 40 { + t.Fatalf("WeekWorkHours = %v, want 40", cfg.WeekWorkHours) + } + + wantDir := filepath.Join(tempHome, "git", "worktime") + if cfg.WorktimeDBDir != wantDir { + t.Fatalf("WorktimeDBDir = %q, want %q", cfg.WorktimeDBDir, wantDir) + } +} |
