diff options
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/root.go | 59 | ||||
| -rw-r--r-- | internal/cli/root_test.go | 122 | ||||
| -rw-r--r-- | internal/cli/timer.go | 28 | ||||
| -rw-r--r-- | internal/cli/timer_test.go | 2 | ||||
| -rw-r--r-- | internal/cli/tui.go | 11 | ||||
| -rw-r--r-- | internal/cli/tui_test.go | 5 | ||||
| -rw-r--r-- | internal/cli/work.go | 46 | ||||
| -rw-r--r-- | internal/cli/work_test.go | 44 |
8 files changed, 281 insertions, 36 deletions
diff --git a/internal/cli/root.go b/internal/cli/root.go index faba592..8b5ad09 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,10 +2,14 @@ package cli import ( "context" + "errors" "fmt" + "strings" + "time" - timr "codeberg.org/snonux/timr/internal" - "codeberg.org/snonux/timr/internal/config" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" "github.com/spf13/cobra" ) @@ -20,9 +24,10 @@ func Execute() error { func NewRootCmd() *cobra.Command { var configPath string var showVersion bool + var checkDBIntegrity bool cmd := &cobra.Command{ - Use: "timr", + Use: "timesamurai", Short: "Track time from your terminal", SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -40,15 +45,19 @@ func NewRootCmd() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { if showVersion { - _, err := fmt.Fprintln(cmd.OutOrStdout(), timr.Version) + _, err := fmt.Fprintln(cmd.OutOrStdout(), timesamurai.Version) return err } + if checkDBIntegrity { + return runDBIntegrityCheck(cmd) + } return cmd.Help() }, } cmd.Flags().BoolVar(&showVersion, "version", false, "Print version and exit") + cmd.Flags().BoolVar(&checkDBIntegrity, "check-db-integrity", false, "Validate worktime database integrity and exit") cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file") cmd.AddCommand(newTimerCmd()) cmd.AddCommand(newWorkCmd()) @@ -81,3 +90,45 @@ func currentConfig(cmd *cobra.Command) config.Config { } return cfg } + +func runDBIntegrityCheck(cmd *cobra.Command) error { + cfg := currentConfig(cmd) + entries, err := worktime.LoadAll(cfg.WorktimeDBDir) + if err != nil { + return err + } + + issues := worktime.CheckEntriesIntegrity(entries, worktime.DefaultMaxSessionSpan) + openSessions := worktime.OpenSessions(entries) + + lines := make([]string, 0, len(issues)+len(openSessions)+2) + if len(issues) == 0 { + lines = append(lines, "Database integrity check passed.") + } else { + lines = append(lines, fmt.Sprintf("Database integrity check found %d issue(s):", len(issues))) + for idx, issue := range issues { + lines = append(lines, fmt.Sprintf("%d. %s", idx+1, issue.String())) + } + } + + if len(openSessions) > 0 { + lines = append(lines, fmt.Sprintf("Warning: currently logged in (%d open session(s)):", len(openSessions))) + for _, session := range openSessions { + lines = append(lines, fmt.Sprintf( + "- category=%s source=%s since=%s", + session.Category, + session.Login.Source, + time.Unix(session.Login.Epoch, 0).Format("2006-01-02 15:04:05"), + )) + } + } + + _, printErr := fmt.Fprintln(cmd.OutOrStdout(), strings.Join(lines, "\n")) + if printErr != nil { + return printErr + } + if len(issues) > 0 { + return errors.New("database integrity check failed") + } + return nil +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 2b12514..e1f74b1 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -7,8 +7,10 @@ import ( "path/filepath" "strings" "testing" + "time" - timr "codeberg.org/snonux/timr/internal" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestRootVersionFlag(t *testing.T) { @@ -22,8 +24,8 @@ func TestRootVersionFlag(t *testing.T) { 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) + if strings.TrimSpace(out.String()) != timesamurai.Version { + t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timesamurai.Version) } } @@ -96,8 +98,8 @@ func TestVersionSkipsConfigLoading(t *testing.T) { 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) + if strings.TrimSpace(out.String()) != timesamurai.Version { + t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timesamurai.Version) } } @@ -124,3 +126,113 @@ func TestRootUsesDefaultConfigWhenNoFileExists(t *testing.T) { t.Fatalf("WorktimeDBDir = %q, want %q", cfg.WorktimeDBDir, wantDir) } } + +func TestRootCheckDBIntegrityPasses(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := worktime.Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil { + t.Fatalf("Login() error = %v", err) + } + if _, err := worktime.Logout(dbDir, host, "work", time.Unix(200, 0), ""); err != nil { + t.Fatalf("Logout() error = %v", err) + } + + cfgPath := writeRootConfig(t, dbDir, host) + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v (output: %q)", err, out.String()) + } + if !strings.Contains(out.String(), "Database integrity check passed.") { + t.Fatalf("unexpected output: %q", out.String()) + } +} + +func TestRootCheckDBIntegrityFailsOnIssues(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + db := worktime.Database{ + Entries: map[string][]worktime.Entry{ + host: { + { + Action: "logout", + What: "work", + Epoch: 100, + Source: host, + Human: time.Unix(100, 0).Format("Mon 02.01.2006 15:04:05"), + }, + }, + }, + } + if err := worktime.SaveHost(dbDir, host, db); err != nil { + t.Fatalf("SaveHost() error = %v", err) + } + + cfgPath := writeRootConfig(t, dbDir, host) + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want integrity failure") + } + if !strings.Contains(err.Error(), "database integrity check failed") { + t.Fatalf("Execute() error = %v, want integrity failure", err) + } + if !strings.Contains(out.String(), "Database integrity check found") { + t.Fatalf("unexpected output: %q", out.String()) + } +} + +func TestRootCheckDBIntegrityWarnsForOpenSession(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := worktime.Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil { + t.Fatalf("Login() error = %v", err) + } + + cfgPath := writeRootConfig(t, dbDir, host) + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v (output: %q)", err, out.String()) + } + if !strings.Contains(out.String(), "Database integrity check passed.") { + t.Fatalf("unexpected output: %q", out.String()) + } + if !strings.Contains(out.String(), "Warning: currently logged in") { + t.Fatalf("expected open-session warning in output: %q", out.String()) + } +} + +func writeRootConfig(t *testing.T, dbDir, host string) string { + t.Helper() + + content := `{ + "worktime_db_dir": "` + dbDir + `", + "hostname": "` + host + `" +} +` + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config file: %v", err) + } + return path +} diff --git a/internal/cli/timer.go b/internal/cli/timer.go index 37e50d0..e9889c4 100644 --- a/internal/cli/timer.go +++ b/internal/cli/timer.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/ascii" - timrTimer "codeberg.org/snonux/timr/internal/timer" - tuiapp "codeberg.org/snonux/timr/internal/tui" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/ascii" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + tuiapp "codeberg.org/snonux/timesamurai/internal/tui" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -44,7 +44,7 @@ func newTimerStartCmd() *cobra.Command { return err } - output, err := timrTimer.StartTimer(hasElapsed) + output, err := timesamuraiTimer.StartTimer(hasElapsed) if err != nil { return err } @@ -61,7 +61,7 @@ func newTimerStopCmd() *cobra.Command { Use: "stop", Short: "Stop the timer", RunE: func(cmd *cobra.Command, args []string) error { - output, err := timrTimer.StopTimer() + output, err := timesamuraiTimer.StopTimer() if err != nil { return fmt.Errorf("stop timer: %w", err) } @@ -85,7 +85,7 @@ func newTimerContinueCmd() *cobra.Command { output := "Timer is at 0, cannot continue." if hasElapsed { - output, err = timrTimer.StartTimer(true) + output, err = timesamuraiTimer.StartTimer(true) if err != nil { return err } @@ -101,7 +101,7 @@ func newTimerResetCmd() *cobra.Command { Use: "reset", Short: "Reset the timer", RunE: func(cmd *cobra.Command, args []string) error { - output, err := timrTimer.ResetTimer() + output, err := timesamuraiTimer.ResetTimer() if err != nil { return fmt.Errorf("reset timer: %w", err) } @@ -129,11 +129,11 @@ func newTimerStatusCmd() *cobra.Command { switch { case raw: - output, err = timrTimer.GetRawStatus() + output, err = timesamuraiTimer.GetRawStatus() case rawMinutes: - output, err = timrTimer.GetRawMinutesStatus() + output, err = timesamuraiTimer.GetRawMinutesStatus() default: - output, err = timrTimer.GetStatus() + output, err = timesamuraiTimer.GetStatus() } if err != nil { return err @@ -153,7 +153,7 @@ func newTimerPromptCmd() *cobra.Command { Use: "prompt", Short: "Show prompt-friendly timer status", RunE: func(cmd *cobra.Command, args []string) error { - output, err := timrTimer.GetPromptStatus() + output, err := timesamuraiTimer.GetPromptStatus() if err != nil { return fmt.Errorf("get prompt timer status: %w", err) } @@ -169,7 +169,7 @@ func newTimerTrackCmd() *cobra.Command { Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { description := strings.Join(args, " ") - output, err := timrTimer.TrackTime(description) + output, err := timesamuraiTimer.TrackTime(description) if err != nil { return fmt.Errorf("track timer entry %q: %w", description, err) } @@ -242,7 +242,7 @@ func syncWorktimeWithTimer(cmd *cobra.Command, start bool) error { } func timerHasElapsed() (bool, error) { - rawStatus, err := timrTimer.GetRawStatus() + rawStatus, err := timesamuraiTimer.GetRawStatus() if err != nil { return false, fmt.Errorf("get raw timer status: %w", err) } diff --git a/internal/cli/timer_test.go b/internal/cli/timer_test.go index 572d5a0..be4391e 100644 --- a/internal/cli/timer_test.go +++ b/internal/cli/timer_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestTimerStartAndStopCommands(t *testing.T) { diff --git a/internal/cli/tui.go b/internal/cli/tui.go index ff4f2aa..474ffc2 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -1,17 +1,19 @@ package cli import ( - tuiapp "codeberg.org/snonux/timr/internal/tui" + tuiapp "codeberg.org/snonux/timesamurai/internal/tui" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) func newTUICmd() *cobra.Command { - return &cobra.Command{ + var disco bool + + cmd := &cobra.Command{ Use: "tui", Short: "Launch full-screen TUI", RunE: func(cmd *cobra.Command, args []string) error { - model, err := tuiapp.NewModelWithConfig(currentConfig(cmd)) + model, err := tuiapp.NewModelWithConfigAndDisco(currentConfig(cmd), disco) if err != nil { return err } @@ -19,4 +21,7 @@ func newTUICmd() *cobra.Command { return program.Start() }, } + + cmd.Flags().BoolVar(&disco, "disco", false, "Enable disco mode (random theme changes)") + return cmd } diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 9d8f481..8ec4afd 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -25,4 +25,9 @@ func TestNewTUICmdMetadata(t *testing.T) { if cmd.Short == "" { t.Fatal("Short description should not be empty") } + + flag := cmd.Flags().Lookup("disco") + if flag == nil { + t.Fatal("expected --disco flag to be defined") + } } diff --git a/internal/cli/work.go b/internal/cli/work.go index 22f0406..d6b989e 100644 --- a/internal/cli/work.go +++ b/internal/cli/work.go @@ -10,10 +10,10 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/duration" - "codeberg.org/snonux/timr/internal/timefmt" - timrTimer "codeberg.org/snonux/timr/internal/timer" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/duration" + "codeberg.org/snonux/timesamurai/internal/timefmt" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + "codeberg.org/snonux/timesamurai/internal/worktime" "github.com/spf13/cobra" ) @@ -32,6 +32,7 @@ func newWorkCmd() *cobra.Command { cmd.AddCommand(newWorkLogoutCmd()) cmd.AddCommand(newWorkAddCmd()) cmd.AddCommand(newWorkSubCmd()) + cmd.AddCommand(newWorkDayOffCmd()) cmd.AddCommand(newWorkUseBufferCmd()) cmd.AddCommand(newWorkReportCmd()) cmd.AddCommand(newWorkStatusCmd()) @@ -211,6 +212,39 @@ func newWorkSubCmd() *cobra.Command { return cmd } +func newWorkDayOffCmd() *cobra.Command { + var at string + var descr string + + cmd := &cobra.Command{ + Use: "day-off", + Short: "Add an 8-hour day-off entry", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := resolveWorkContext(cmd) + if err != nil { + return err + } + + day, err := parseAtOrNow(at) + if err != nil { + return err + } + + entry, err := worktime.AddDayOff(ctx.dbDir, ctx.host, day, descr) + if err != nil { + return err + } + + dayLabel := time.Unix(entry.Epoch, 0).Format("2006-01-02") + return printOutput(cmd, fmt.Sprintf("Added day off: 8h on %s", dayLabel)) + }, + } + + cmd.Flags().StringVar(&at, "at", "", "Date/time override (unix, ISO, today, yesterday)") + cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description") + return cmd +} + func newWorkUseBufferCmd() *cobra.Command { var at string var descr string @@ -407,9 +441,9 @@ func startTimerFromWorkCommand() (string, error) { if err != nil { return "", err } - return timrTimer.StartTimer(hasElapsed) + return timesamuraiTimer.StartTimer(hasElapsed) } func stopTimerFromWorkCommand() (string, error) { - return timrTimer.StopTimer() + return timesamuraiTimer.StopTimer() } diff --git a/internal/cli/work_test.go b/internal/cli/work_test.go index 9621bfb..04a1643 100644 --- a/internal/cli/work_test.go +++ b/internal/cli/work_test.go @@ -7,8 +7,10 @@ import ( "strconv" "strings" "testing" + "time" - timrTimer "codeberg.org/snonux/timr/internal/timer" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestWorkLoginStatusLogoutFlow(t *testing.T) { @@ -87,7 +89,7 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { if err != nil { t.Fatalf("work login --start-timer error = %v (output: %q)", err, out) } - state, err := timrTimer.LoadState() + state, err := timesamuraiTimer.LoadState() if err != nil { t.Fatalf("LoadState() error = %v", err) } @@ -99,7 +101,7 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { if err != nil { t.Fatalf("work logout --stop-timer error = %v (output: %q)", err, out) } - state, err = timrTimer.LoadState() + state, err = timesamuraiTimer.LoadState() if err != nil { t.Fatalf("LoadState() error = %v", err) } @@ -108,6 +110,42 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { } } +func TestWorkDayOffCommand(t *testing.T) { + dbDir := t.TempDir() + host := "host-day-off" + cfgPath := writeWorkConfig(t, dbDir, host) + + out, err := runRootCommand("--config", cfgPath, "work", "day-off", "--at", "2026-02-17", "--descr", "vacation") + if err != nil { + t.Fatalf("work day-off error = %v (output: %q)", err, out) + } + if !strings.Contains(out, "Added day off: 8h on 2026-02-17") { + t.Fatalf("unexpected day-off output: %q", out) + } + + db, err := worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries := db.Entries[host] + if len(entries) != 1 { + t.Fatalf("entries len = %d, want 1", len(entries)) + } + + entry := entries[0] + wantEpoch := time.Date(2026, 2, 17, 0, 0, 0, 0, time.Local).Unix() + if entry.Action != "add" || entry.What != "off" { + t.Fatalf("unexpected day-off entry: %+v", entry) + } + if entry.Value != 8*3600 { + t.Fatalf("day-off value = %d, want 28800", entry.Value) + } + if entry.Epoch != wantEpoch { + t.Fatalf("day-off epoch = %d, want %d", entry.Epoch, wantEpoch) + } +} + func writeWorkConfig(t *testing.T, dbDir, host string) string { return writeWorkConfigWithAuto(t, dbDir, host, false) } |
