summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:39:16 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:39:16 +0200
commitac0a71de2a4a8894de849d10bbc4de962a5c0c2b (patch)
treec0ddc25df27c7518ed8df08f8e9e16b4ccfdb6c8
parentc3c347d6faed97d9cc02bf326e2a74786b0bde99 (diff)
Task 355: add cobra root command with config preload
-rw-r--r--go.mod3
-rw-r--r--go.sum10
-rw-r--r--internal/cli/root.go59
-rw-r--r--internal/cli/root_test.go128
4 files changed, 200 insertions, 0 deletions
diff --git a/go.mod b/go.mod
index 82c8652..ad9840b 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 6a79bf7..5471ff1 100644
--- a/go.sum
+++ b/go.sum
@@ -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)
+ }
+}