From f67c80e39653e81206a0d143c25ef0a8dfa19f51 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 20 Jul 2026 11:33:51 -0600 Subject: [PATCH 1/3] Implement custom error handling for CLI --- cmd/root.go | 10 +++++++--- internal/ui/ui.go | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index b882975..6277ab0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,17 +22,21 @@ func RootCmd() *cobra.Command { A minimal, developer-friendly time tracking tool that lives in your terminal. Track time effortlessly with automatic project detection and simple commands.`, - Run: func(cmd *cobra.Command, args []string) { + PersistentPreRun: func(cmd *cobra.Command, args []string) { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + }, + RunE: func(cmd *cobra.Command, args []string) error { // Check if version flag was set versionFlag, _ := cmd.Flags().GetBool("version") if versionFlag { utilities.DisplayVersionWithUpdateCheck() - return + return nil } // Otherwise show help - cmd.Help() + return cmd.Help() }, } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index a6e4085..8189890 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "fmt" "os" "strings" @@ -9,6 +10,10 @@ import ( "github.com/DylanDevelops/tmpo/internal/shell" ) +// ErrHandled signals that a command has already reported its failure to the +// user through the Print helpers in this package. +var ErrHandled = errors.New("command failed") + // ANSI Color Constants const ( ColorReset = "\033[0m" From ba222d8fdb25c62322c9cab73c01de0704908019 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 20 Jul 2026 12:48:21 -0600 Subject: [PATCH 2/3] Use RunE and return errors instead of os.Exit in init cmd --- cmd/root_test.go | 47 ++++++++++++++++++++++++++++++++++ cmd/setup/init.go | 58 +++++++++++++++++++++++++++--------------- cmd/setup/init_test.go | 3 ++- 3 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 cmd/root_test.go diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..e5a06c0 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// walkCommands visits root and every command registered beneath it. +func walkCommands(root *cobra.Command, visit func(*cobra.Command)) { + visit(root) + for _, sub := range root.Commands() { + walkCommands(sub, visit) + } +} + +func TestAllCommandsUseRunEInsteadOfRun(t *testing.T) { + var checked int + + walkCommands(RootCmd(), func(cmd *cobra.Command) { + checked++ + assert.Nilf(t, cmd.Run, + "%q uses Run; use RunE so deferred cleanup runs on error paths", cmd.CommandPath()) + }) + + // Guard against the walk silently visiting nothing and passing vacuously. + assert.Greater(t, checked, 20, "expected the command tree to be walked") +} + +func TestRunnableCommandsDeclareRunE(t *testing.T) { + walkCommands(RootCmd(), func(cmd *cobra.Command) { + if !cmd.Runnable() { + return + } + + assert.NotNilf(t, cmd.RunE, "%q is runnable but declares no RunE", cmd.CommandPath()) + }) +} + +func TestRootDoesNotSilenceCobraBeforeRun(t *testing.T) { + root := RootCmd() + + assert.False(t, root.SilenceErrors, "silencing errors on the root hides unknown-flag messages") + assert.False(t, root.SilenceUsage, "silencing usage on the root hides usage for flag errors") + assert.NotNil(t, root.PersistentPreRun, "expected PersistentPreRun to silence Cobra for runtime errors") +} diff --git a/cmd/setup/init.go b/cmd/setup/init.go index 4f45edb..ad39cfa 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -24,22 +24,28 @@ func InitCmd() *cobra.Command { Use: "init", Short: "Initialize a project configuration", Long: `Create a project configuration using an interactive form. By default, creates a .tmporc file in the current directory.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() // accept all is incompatible with initialization of global project if acceptDefaults && globalProject { ui.PrintError(ui.EmojiError, "Cannot use --accept-defaults with --global. Global projects require an explicit project configuration.") - os.Exit(1) + return ui.ErrHandled } + var err error if globalProject { - initGlobalProject() + err = initGlobalProject() } else { - initLocalProject() + err = initLocalProject() + } + if err != nil { + return err } ui.NewlineBelow() + + return nil }, } @@ -49,20 +55,23 @@ func InitCmd() *cobra.Command { return cmd } -func initLocalProject() { +func initLocalProject() error { if _, err := os.Stat(".tmporc"); err == nil { ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") - os.Exit(1) + return ui.ErrHandled } defaultName := detectDefaultProjectName() - name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Initialize Project Configuration") + name, hourlyRate, description, exportPath, err := getProjectDetails(defaultName, "Initialize Project Configuration") + if err != nil { + return err + } // create a .tmporc file - err := settings.CreateWithTemplate(name, hourlyRate, description, exportPath) + err = settings.CreateWithTemplate(name, hourlyRate, description, exportPath) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } fmt.Println() @@ -72,21 +81,26 @@ func initLocalProject() { fmt.Println() ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") ui.PrintMuted(0, "Use 'tmpo config' to set global preferences like currency and time formats.") + + return nil } -func initGlobalProject() { +func initGlobalProject() error { registry, err := settings.LoadProjects() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to load projects registry: %v", err)) - os.Exit(1) + return err } // global projects require project name type in - name, hourlyRate, description, exportPath := getProjectDetails("", "Initialize Global Project") + name, hourlyRate, description, exportPath, err := getProjectDetails("", "Initialize Global Project") + if err != nil { + return err + } if registry.Exists(name) { ui.PrintError(ui.EmojiError, fmt.Sprintf("global project '%s' already exists", name)) - os.Exit(1) + return ui.ErrHandled } // create the project @@ -105,13 +119,13 @@ func initGlobalProject() { err = registry.AddProject(newProject) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to add project: %v", err)) - os.Exit(1) + return err } err = registry.Save() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to save projects registry: %v", err)) - os.Exit(1) + return err } fmt.Println() @@ -123,9 +137,11 @@ func initGlobalProject() { ui.PrintMuted(0, fmt.Sprintf(" tmpo start --project \"%s\"", name)) ui.PrintMuted(0, "") ui.PrintMuted(0, "Use 'tmpo config' to set global preferences like currency and time formats.") + + return nil } -func getProjectDetails(defaultName, title string) (name string, hourlyRate float64, description, exportPath string) { +func getProjectDetails(defaultName, title string) (name string, hourlyRate float64, description, exportPath string, err error) { if acceptDefaults { name = defaultName hourlyRate = 0 @@ -161,7 +177,7 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float nameInput, err := namePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return } name = strings.TrimSpace(nameInput) @@ -178,7 +194,7 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float rateInput, err := ratePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return } rateInput = strings.TrimSpace(rateInput) @@ -186,7 +202,7 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float hourlyRate, err = strconv.ParseFloat(rateInput, 64) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) - os.Exit(1) + return } } @@ -198,7 +214,7 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float descInput, err := descPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return } description = strings.TrimSpace(descInput) @@ -211,7 +227,7 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float exportPathInput, err := exportPathPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return } exportPath = strings.TrimSpace(exportPathInput) diff --git a/cmd/setup/init_test.go b/cmd/setup/init_test.go index c0a8f72..5433d34 100644 --- a/cmd/setup/init_test.go +++ b/cmd/setup/init_test.go @@ -128,8 +128,9 @@ func TestGetProjectDetails(t *testing.T) { acceptDefaults = true defaultName := "test-project" - name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Test Title") + name, hourlyRate, description, exportPath, err := getProjectDetails(defaultName, "Test Title") + assert.NoError(t, err) assert.Equal(t, defaultName, name) assert.Equal(t, float64(0), hourlyRate) assert.Empty(t, description) From 1fe61dcfbe5c694e527513018b9d117f603c3f83 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 20 Jul 2026 12:51:36 -0600 Subject: [PATCH 3/3] Refactor commands to use RunE for error handling --- cmd/backups/create.go | 13 +++++----- cmd/backups/delete.go | 18 +++++++------ cmd/backups/list.go | 9 ++++--- cmd/backups/restore.go | 19 +++++++------- cmd/config/config.go | 19 +++++++------- cmd/entries/delete.go | 27 ++++++++++---------- cmd/entries/edit.go | 55 ++++++++++++++++++++-------------------- cmd/entries/manual.go | 39 ++++++++++++++-------------- cmd/history/export.go | 18 +++++++------ cmd/history/log.go | 15 ++++++----- cmd/history/stats.go | 15 ++++++----- cmd/milestones/finish.go | 19 +++++++------- cmd/milestones/list.go | 15 ++++++----- cmd/milestones/start.go | 19 +++++++------- cmd/milestones/status.go | 15 ++++++----- cmd/tracking/cancel.go | 13 +++++----- cmd/tracking/pause.go | 13 +++++----- cmd/tracking/resume.go | 19 +++++++------- cmd/tracking/start.go | 15 ++++++----- cmd/tracking/status.go | 11 ++++---- cmd/tracking/stop.go | 13 +++++----- cmd/utilities/undo.go | 15 ++++++----- cmd/utilities/version.go | 4 ++- 23 files changed, 222 insertions(+), 196 deletions(-) diff --git a/cmd/backups/create.go b/cmd/backups/create.go index d25b5d6..8357702 100644 --- a/cmd/backups/create.go +++ b/cmd/backups/create.go @@ -2,7 +2,6 @@ package backups import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" @@ -14,14 +13,14 @@ func CreateCmd() *cobra.Command { Use: "create", Short: "Create a new backup", Long: `Create a new backup of your entire database to save all your data to be restored from later.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } defer db.Close() @@ -29,19 +28,19 @@ func CreateCmd() *cobra.Command { if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("checking for active timer: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } if running != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf(`timer is running for %s — stop it before creating a backup`, ui.Bold(running.ProjectName))) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } backup, err := db.CreateBackup() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("creating backup: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } ui.PrintSuccess(ui.EmojiBackup, "Backup created successfully") @@ -50,6 +49,8 @@ func CreateCmd() *cobra.Command { ui.PrintInfo(2, "Path", backup.Path) ui.PrintInfo(2, "Size", ui.FormatFileSize(backup.Size)) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/backups/delete.go b/cmd/backups/delete.go index dbe8d07..73c4a33 100644 --- a/cmd/backups/delete.go +++ b/cmd/backups/delete.go @@ -21,21 +21,21 @@ func DeleteCmd() *cobra.Command { Use: "delete", Short: "Delete a backup", Long: `Permanently delete an existing backup. This action cannot be undone.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() backups, err := storage.ListBackups() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } if len(backups) == 0 { ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "") ui.PrintMuted(2, "Run 'tmpo backup create' to create one.") ui.NewlineBelow() - return + return nil } var selected *storage.BackupInfo @@ -51,7 +51,7 @@ func DeleteCmd() *cobra.Command { if selected == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with ID %d", id)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } } else { for i := range backups { @@ -63,7 +63,7 @@ func DeleteCmd() *cobra.Command { if selected == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with filename %q", deleteIDFlag)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } } } else { @@ -89,7 +89,7 @@ func DeleteCmd() *cobra.Command { idx, _, err := prompt.Run() if err != nil { ui.NewlineBelow() - return + return nil } selected = &backups[idx] @@ -103,17 +103,19 @@ func DeleteCmd() *cobra.Command { if _, err := confirmPrompt.Run(); err != nil { ui.PrintInfo(0, ui.EmojiInfo+" Deletion cancelled", "") ui.NewlineBelow() - return + return nil } if err := os.Remove(selected.Path); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("deleting backup: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Deleted %s", selected.Filename)) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/backups/list.go b/cmd/backups/list.go index 56424f9..1e1a682 100644 --- a/cmd/backups/list.go +++ b/cmd/backups/list.go @@ -2,7 +2,6 @@ package backups import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/settings" "github.com/DylanDevelops/tmpo/internal/storage" @@ -15,21 +14,21 @@ func ListCmd() *cobra.Command { Use: "list", Short: "Lists all existing backups", Long: `Lists all existing backups which can be used to restore.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() backups, err := storage.ListBackups() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } if len(backups) == 0 { ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "") ui.PrintMuted(2, "Run 'tmpo backup create' to create one.") ui.NewlineBelow() - return + return nil } fmt.Printf(" %s%-4s %-28s %-8s %s%s\n", @@ -59,6 +58,8 @@ func ListCmd() *cobra.Command { } ui.NewlineBelow() + + return nil }, } diff --git a/cmd/backups/restore.go b/cmd/backups/restore.go index b4fa77c..b6117b1 100644 --- a/cmd/backups/restore.go +++ b/cmd/backups/restore.go @@ -2,7 +2,6 @@ package backups import ( "fmt" - "os" "strconv" "github.com/DylanDevelops/tmpo/internal/settings" @@ -21,21 +20,21 @@ func RestoreCmd() *cobra.Command { Use: "restore", Short: "Restore from a backup", Long: `Restore your database from an existing backup.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() backups, err := storage.ListBackups() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } if len(backups) == 0 { ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "") ui.PrintMuted(2, "Run 'tmpo backup create' to create one.") ui.NewlineBelow() - return + return nil } var selected *storage.BackupInfo @@ -51,7 +50,7 @@ func RestoreCmd() *cobra.Command { if selected == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with ID %d", id)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } } else { for i := range backups { @@ -63,7 +62,7 @@ func RestoreCmd() *cobra.Command { if selected == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with filename %q", restoreIDFlag)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } } } else { @@ -89,7 +88,7 @@ func RestoreCmd() *cobra.Command { idx, _, err := prompt.Run() if err != nil { ui.NewlineBelow() - return + return nil } selected = &backups[idx] @@ -110,17 +109,19 @@ func RestoreCmd() *cobra.Command { if _, err := confirmPrompt.Run(); err != nil { ui.PrintInfo(0, ui.EmojiInfo+" Restore cancelled", "") ui.NewlineBelow() - return + return nil } if err := storage.RestoreBackup(selected.Path); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("restoring backup: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } ui.PrintSuccess(ui.EmojiBackup, fmt.Sprintf("Restored from %s", selected.Filename)) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/config/config.go b/cmd/config/config.go index 28d2b5e..4bc3d90 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "os" "strings" "github.com/DylanDevelops/tmpo/internal/settings" @@ -17,14 +16,14 @@ func ConfigCmd() *cobra.Command { Aliases: []string{"settings", "preferences"}, Short: "Configure global tmpo settings", Long: `Set up global configuration for tmpo including currency, date/time format, and timezone.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() // Load current global config currentConfig, err := settings.LoadGlobalConfig() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("Failed to load config: %v", err)) - os.Exit(1) + return err } // Display header @@ -69,7 +68,7 @@ func ConfigCmd() *cobra.Command { currencyInput, err := currencyPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } currencyCode := strings.ToUpper(strings.TrimSpace(currencyInput)) @@ -88,7 +87,7 @@ func ConfigCmd() *cobra.Command { _, dateFormat, err := dateFormatSelect.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // Keep current format if selected @@ -107,7 +106,7 @@ func ConfigCmd() *cobra.Command { _, timeFormat, err := timeFormatSelect.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // Keep current format if selected @@ -127,7 +126,7 @@ func ConfigCmd() *cobra.Command { timezoneInput, err := timezonePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } timezone := strings.TrimSpace(timezoneInput) @@ -146,7 +145,7 @@ func ConfigCmd() *cobra.Command { exportPathInput, err := exportPathPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } exportPath := strings.TrimSpace(exportPathInput) @@ -169,7 +168,7 @@ func ConfigCmd() *cobra.Command { // Save the config if err := newConfig.Save(); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("Failed to save config: %v", err)) - os.Exit(1) + return err } // Display success message @@ -197,6 +196,8 @@ func ConfigCmd() *cobra.Command { ui.PrintInfo(4, ui.Bold("Export path"), exportPathDisplay) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/entries/delete.go b/cmd/entries/delete.go index 34acd00..56ba884 100644 --- a/cmd/entries/delete.go +++ b/cmd/entries/delete.go @@ -2,7 +2,6 @@ package entries import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/settings" @@ -19,7 +18,7 @@ func DeleteCmd() *cobra.Command { Use: "delete", Short: "Delete a time entry", Long: `Delete a time entry using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() ui.PrintSuccess("🗑️", "Delete Time Entry") fmt.Println() @@ -27,7 +26,7 @@ func DeleteCmd() *cobra.Command { db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -39,13 +38,13 @@ func DeleteCmd() *cobra.Command { projects, err := db.GetAllProjects() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if len(projects) == 0 { ui.PrintError(ui.EmojiError, "No time entries found") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } projectPrompt := promptui.Select{ @@ -56,7 +55,7 @@ func DeleteCmd() *cobra.Command { _, selectedProject, err := projectPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } projectName = selectedProject @@ -65,7 +64,7 @@ func DeleteCmd() *cobra.Command { detectedProject, err := project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } projectName = detectedProject } @@ -74,7 +73,7 @@ func DeleteCmd() *cobra.Command { entries, err = db.GetEntriesByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if len(entries) == 0 { @@ -83,7 +82,7 @@ func DeleteCmd() *cobra.Command { ui.PrintMuted(0, "Use 'tmpo delete --show-all-projects' to see entries from all projects") } ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } // Format entries for selection @@ -114,7 +113,7 @@ func DeleteCmd() *cobra.Command { idx, _, err := entryPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } selectedEntry := items[idx].Entry @@ -146,19 +145,19 @@ func DeleteCmd() *cobra.Command { _, result, err := confirmPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if result == "No" { ui.PrintWarning(ui.EmojiWarning, "Deletion cancelled") ui.NewlineBelow() - os.Exit(0) + return nil } // Delete from database if err := db.DeleteTimeEntry(selectedEntry.ID); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{Type: storage.ActionDelete, ProjectName: selectedEntry.ProjectName, Entry: selectedEntry}) @@ -166,6 +165,8 @@ func DeleteCmd() *cobra.Command { fmt.Println() ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully") ui.NewlineBelow() + + return nil }, } diff --git a/cmd/entries/edit.go b/cmd/entries/edit.go index 4f7c492..daf1b3b 100644 --- a/cmd/entries/edit.go +++ b/cmd/entries/edit.go @@ -2,7 +2,6 @@ package entries import ( "fmt" - "os" "strings" "time" @@ -24,7 +23,7 @@ func EditCmd() *cobra.Command { Use: "edit", Short: "Edit an existing time entry", Long: `Edit an existing time entry using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() ui.PrintSuccess("✏️", "Edit Time Entry") fmt.Println() @@ -33,7 +32,7 @@ func EditCmd() *cobra.Command { globalCfg, err := settings.LoadGlobalConfig() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("loading config: %v", err)) - os.Exit(1) + return err } // Get date format for prompts and validation @@ -42,7 +41,7 @@ func EditCmd() *cobra.Command { db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -54,13 +53,13 @@ func EditCmd() *cobra.Command { projects, err := db.GetProjectsWithCompletedEntries() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if len(projects) == 0 { ui.PrintError(ui.EmojiError, "No completed time entries found") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } projectPrompt := promptui.Select{ @@ -71,7 +70,7 @@ func EditCmd() *cobra.Command { _, selectedProject, err := projectPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } projectName = selectedProject @@ -80,7 +79,7 @@ func EditCmd() *cobra.Command { detectedProject, err := project.DetectConfiguredProjectWithOverride(editProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } projectName = detectedProject } @@ -89,7 +88,7 @@ func EditCmd() *cobra.Command { entries, err = db.GetCompletedEntriesByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if len(entries) == 0 { @@ -98,7 +97,7 @@ func EditCmd() *cobra.Command { ui.PrintMuted(0, "Use 'tmpo edit --show-all-projects' to see entries from all projects") } ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } // Format entries for selection @@ -129,7 +128,7 @@ func EditCmd() *cobra.Command { idx, _, err := entryPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } selectedEntry := items[idx].Entry @@ -155,7 +154,7 @@ func EditCmd() *cobra.Command { startDateInput, err := startDatePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } startDateInput = strings.TrimSpace(startDateInput) @@ -174,7 +173,7 @@ func EditCmd() *cobra.Command { startTimeInput, err := startTimePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } startTimeInput = strings.TrimSpace(startTimeInput) @@ -193,7 +192,7 @@ func EditCmd() *cobra.Command { endDateInput, err := endDatePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } endDateInput = strings.TrimSpace(endDateInput) @@ -212,7 +211,7 @@ func EditCmd() *cobra.Command { endTimeInput, err := endTimePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } endTimeInput = strings.TrimSpace(endTimeInput) @@ -223,7 +222,7 @@ func EditCmd() *cobra.Command { // Validate that end is after start if err := validateEndDateTime(startDateInput, startTimeInput, endDateInput, endTimeInput, dateFormatLayout); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // Edit description @@ -240,7 +239,7 @@ func EditCmd() *cobra.Command { descriptionInput, err := descriptionPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } descriptionInput = ui.SanitizeSingleLine(descriptionInput) @@ -252,7 +251,7 @@ func EditCmd() *cobra.Command { milestones, err := db.GetMilestonesByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } var newMilestoneName *string @@ -282,7 +281,7 @@ func EditCmd() *cobra.Command { milestoneIdx, _, err := milestonePrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // if not its not empty set the milestone @@ -296,13 +295,13 @@ func EditCmd() *cobra.Command { newStartTime, err := parseDateTime(startDateInput, startTimeInput, dateFormatLayout) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) - os.Exit(1) + return err } newEndTime, err := parseDateTime(endDateInput, endTimeInput, dateFormatLayout) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) - os.Exit(1) + return err } editedEntry.StartTime = newStartTime @@ -344,13 +343,13 @@ func EditCmd() *cobra.Command { _, result, err := confirmPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if result == "No" { ui.PrintWarning(ui.EmojiWarning, "Milestone assignment cancelled") ui.NewlineBelow() - os.Exit(0) + return nil } } } @@ -405,7 +404,7 @@ func EditCmd() *cobra.Command { if !hasChanges { ui.PrintWarning(ui.EmojiWarning, "No changes detected") ui.NewlineBelow() - os.Exit(0) + return nil } fmt.Println() @@ -418,19 +417,19 @@ func EditCmd() *cobra.Command { _, result, err := confirmPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if result == "No" { ui.PrintWarning(ui.EmojiWarning, "Changes discarded") ui.NewlineBelow() - os.Exit(0) + return nil } // Save to database if err := db.UpdateTimeEntry(editedEntry.ID, editedEntry); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{ @@ -443,6 +442,8 @@ func EditCmd() *cobra.Command { fmt.Println() ui.PrintSuccess(ui.EmojiSuccess, "Entry updated successfully") ui.NewlineBelow() + + return nil }, } diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 5ca5d5f..a880453 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -2,7 +2,6 @@ package entries import ( "fmt" - "os" "strings" "time" @@ -37,7 +36,7 @@ func ManualCmd() *cobra.Command { Use: "manual", Short: "Create a manual time entry", Long: `Create a completed time entry by specifying start and end times using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") fmt.Println() @@ -45,7 +44,7 @@ func ManualCmd() *cobra.Command { globalCfg, err := settings.LoadGlobalConfig() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("loading config: %v", err)) - os.Exit(1) + return err } dateFormatDisplay, dateFormatLayout := getDateFormatInfo(globalCfg.DateFormat) @@ -54,7 +53,7 @@ func ManualCmd() *cobra.Command { db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -96,7 +95,7 @@ func ManualCmd() *cobra.Command { projectInput, promptErr := projectPrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } projectInput = ui.SanitizeSingleLine(projectInput) @@ -108,7 +107,7 @@ func ManualCmd() *cobra.Command { if projectName == "" { ui.PrintError(ui.EmojiError, "project name cannot be empty") - os.Exit(1) + return ui.ErrHandled } hourlyRate = nil @@ -136,7 +135,7 @@ func ManualCmd() *cobra.Command { startDateVal, promptErr := startDatePrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } startDateVal = strings.TrimSpace(startDateVal) @@ -164,7 +163,7 @@ func ManualCmd() *cobra.Command { startTimeVal, promptErr := startTimePrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } startTimeVal = strings.TrimSpace(startTimeVal) @@ -186,7 +185,7 @@ func ManualCmd() *cobra.Command { endDateVal, promptErr := endDatePrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } endDateVal = strings.TrimSpace(endDateVal) @@ -198,7 +197,7 @@ func ManualCmd() *cobra.Command { if err := validateDate(endDateInput, dateFormatLayout, dateFormatDisplay); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // end time @@ -219,7 +218,7 @@ func ManualCmd() *cobra.Command { endTimeVal, promptErr := endTimePrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } endTimeVal = strings.TrimSpace(endTimeVal) @@ -229,7 +228,7 @@ func ManualCmd() *cobra.Command { if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr, dateFormatLayout); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // description @@ -248,7 +247,7 @@ func ManualCmd() *cobra.Command { descVal, promptErr := descriptionPrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } descVal = ui.SanitizeSingleLine(descVal) @@ -259,14 +258,14 @@ func ManualCmd() *cobra.Command { parsedStart, parseErr := parseDateTime(startDateInput, startTimeStr, dateFormatLayout) if parseErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", parseErr)) - os.Exit(1) + return parseErr } startTime = parsedStart parsedEnd, parseErr := parseDateTime(endDateInput, endTimeStr, dateFormatLayout) if parseErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", parseErr)) - os.Exit(1) + return parseErr } endTime = parsedEnd @@ -291,7 +290,7 @@ func ManualCmd() *cobra.Command { milestoneIdx, _, promptErr := milestonePrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } if milestoneIdx > 0 { @@ -341,7 +340,7 @@ func ManualCmd() *cobra.Command { _, result, promptErr := confirmPrompt.Run() if promptErr != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) - os.Exit(1) + return promptErr } if result == "Confirm" { @@ -350,7 +349,7 @@ func ManualCmd() *cobra.Command { fmt.Println() ui.PrintWarning(ui.EmojiWarning, "Entry creation cancelled") ui.NewlineBelow() - os.Exit(0) + return nil } fmt.Println() @@ -359,7 +358,7 @@ func ManualCmd() *cobra.Command { entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate, milestoneName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{Type: storage.ActionManual, EntryID: entry.ID, ProjectName: entry.ProjectName}) @@ -386,6 +385,8 @@ func ManualCmd() *cobra.Command { } ui.NewlineBelow() + + return nil }, } diff --git a/cmd/history/export.go b/cmd/history/export.go index 197df70..862430a 100644 --- a/cmd/history/export.go +++ b/cmd/history/export.go @@ -29,13 +29,13 @@ func ExportCmd() *cobra.Command { Use: "export", Short: "Export time entries", Long: `Export time tracking data to different formats.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -49,7 +49,7 @@ func ExportCmd() *cobra.Command { detectedProject, err := project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } projectName = detectedProject } @@ -95,13 +95,13 @@ func ExportCmd() *cobra.Command { if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if len(entries) == 0 { ui.PrintWarning(ui.EmojiWarning, "No entries to export.") ui.NewlineBelow() - os.Exit(0) + return nil } var exportPath string @@ -132,7 +132,7 @@ func ExportCmd() *cobra.Command { // make sure that that the path is valid if err := os.MkdirAll(exportPath, 0755); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("Failed to create export directory: %v", err)) - os.Exit(1) + return err } } @@ -166,17 +166,19 @@ func ExportCmd() *cobra.Command { err = export.ToJson(entries, filename, exportUtc) default: ui.PrintError(ui.EmojiError, fmt.Sprintf("Unknown format '%s'. Use 'csv' or 'json'", exportFormat)) - os.Exit(1) + return ui.ErrHandled } if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } ui.PrintSuccess(ui.EmojiExport, fmt.Sprintf("Exported %s to %s", ui.Bold(fmt.Sprintf("%d entries", len(entries))), ui.Bold(filename))) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/history/log.go b/cmd/history/log.go index 10be013..c7e7715 100644 --- a/cmd/history/log.go +++ b/cmd/history/log.go @@ -2,7 +2,6 @@ package history import ( "fmt" - "os" "slices" "time" @@ -27,14 +26,14 @@ func LogCmd() *cobra.Command { Use: "log", Short: "View time tracking history", Long: `Display past time tracking entries with optional filtering.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -48,7 +47,7 @@ func LogCmd() *cobra.Command { detectedProject, err := project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } projectName = detectedProject } @@ -58,7 +57,7 @@ func LogCmd() *cobra.Command { if err != nil { ui.PrintError(ui.EmojiError, err.Error()) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } start := time.Date(parsedDate.Year(), parsedDate.Month(), parsedDate.Day(), 0, 0, 0, 0, time.Local) end := start.Add(24 * time.Hour) @@ -87,13 +86,13 @@ func LogCmd() *cobra.Command { if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if len(entries) == 0 { ui.PrintWarning(ui.EmojiWarning, "No time entries found.") ui.NewlineBelow() - return + return nil } ui.PrintSuccess(ui.EmojiLog, fmt.Sprintf("Time Entries (%d total)", len(entries))) @@ -141,6 +140,8 @@ func LogCmd() *cobra.Command { fmt.Printf("%s %s\n", ui.BoldInfo("Total Time:"), ui.Bold(ui.FormatDuration(totalDuration))) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/history/stats.go b/cmd/history/stats.go index d15f895..709a02e 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -2,7 +2,6 @@ package history import ( "fmt" - "os" "sort" "time" @@ -25,13 +24,13 @@ func StatsCmd() *cobra.Command { Use: "stats", Short: "Show time tracking statistics", Long: `Display statistics and summaries of your time tracking data.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -44,7 +43,7 @@ func StatsCmd() *cobra.Command { if err != nil { ui.PrintError(ui.EmojiError, err.Error()) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } start = time.Date(parsedDate.Year(), parsedDate.Month(), parsedDate.Day(), 0, 0, 0, 0, parsedDate.Location()).UTC() end = start.Add(24 * time.Hour) @@ -73,20 +72,22 @@ func StatsCmd() *cobra.Command { entries, err := db.GetEntries(0) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } ShowAllTimeStats(entries, db) - return + return nil } entries, err := db.GetEntriesByDateRange(start, end) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } ShowPeriodStats(entries, periodName) + + return nil }, } diff --git a/cmd/milestones/finish.go b/cmd/milestones/finish.go index 2ade0ed..cee4412 100644 --- a/cmd/milestones/finish.go +++ b/cmd/milestones/finish.go @@ -2,7 +2,6 @@ package milestones import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" @@ -19,61 +18,63 @@ func FinishCmd() *cobra.Command { Use: "finish", Short: "Finish the active milestone", Long: `Finish the currently active milestone for the current project, or the one specified. This marks the milestone as completed and stops auto-tagging new time entries with it.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() projectName, err := project.DetectConfiguredProjectWithOverride(finishMilestoneProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } // Get active milestone activeMilestone, err := db.GetActiveMilestoneForProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if activeMilestone == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("No active milestone found for %s", projectName)) ui.PrintMuted(0, "Use 'tmpo milestone start' to start a new milestone.") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } // Get entries for this milestone to show count entries, err := db.GetEntriesByMilestone(projectName, activeMilestone.Name) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // Finish the milestone err = db.FinishMilestone(activeMilestone.ID) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // Get updated milestone to show duration finishedMilestone, err := db.GetMilestone(activeMilestone.ID) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } ui.PrintSuccess(ui.EmojiMilestone, fmt.Sprintf("Finished milestone %s", ui.Bold(finishedMilestone.Name))) ui.PrintInfo(4, "Duration", ui.FormatDuration(finishedMilestone.Duration())) ui.PrintInfo(4, "Entries", fmt.Sprintf("%d", len(entries))) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/milestones/list.go b/cmd/milestones/list.go index 6002806..3517a9d 100644 --- a/cmd/milestones/list.go +++ b/cmd/milestones/list.go @@ -2,7 +2,6 @@ package milestones import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/settings" @@ -21,13 +20,13 @@ func ListCmd() *cobra.Command { Use: "list", Short: "List milestones", Long: `List milestones for the current project. Use --all to list milestones from all projects.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -39,7 +38,7 @@ func ListCmd() *cobra.Command { milestones, err = db.GetAllMilestones() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } } else { if listProject != "" { @@ -48,21 +47,21 @@ func ListCmd() *cobra.Command { projectName, err = project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } } milestones, err = db.GetMilestonesByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } } if len(milestones) == 0 { ui.PrintWarning(ui.EmojiWarning, "No milestones found") ui.NewlineBelow() - return + return nil } // Print header @@ -129,6 +128,8 @@ func ListCmd() *cobra.Command { } ui.NewlineBelow() + + return nil }, } diff --git a/cmd/milestones/start.go b/cmd/milestones/start.go index 3bad29d..84ade8e 100644 --- a/cmd/milestones/start.go +++ b/cmd/milestones/start.go @@ -2,7 +2,6 @@ package milestones import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" @@ -20,20 +19,20 @@ func StartCmd() *cobra.Command { Short: "Start a new milestone", Long: `Start a new milestone for the current project, or the one specified. Time entries created after starting a milestone will be automatically tagged with it.`, Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() projectName, err := project.DetectConfiguredProjectWithOverride(startMilestoneProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } milestoneName := ui.SanitizeSingleLine(args[0]) @@ -42,21 +41,21 @@ func StartCmd() *cobra.Command { activeMilestone, err := db.GetActiveMilestoneForProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if activeMilestone != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("Milestone '%s' is already active for %s", activeMilestone.Name, projectName)) ui.PrintMuted(0, "Use 'tmpo milestone finish' to finish it first.") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } // check if this name is already in use currently or in the past existingMilestone, err := db.GetMilestoneByName(projectName, milestoneName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if existingMilestone != nil { @@ -67,19 +66,21 @@ func StartCmd() *cobra.Command { ui.PrintMuted(0, "This milestone has already been finished. Use a different name for the new milestone.") } ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } // Create the milestone milestone, err := db.CreateMilestone(projectName, milestoneName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to create milestone: %v", err)) - os.Exit(1) + return err } ui.PrintSuccess(ui.EmojiMilestone, fmt.Sprintf("Started milestone %s for %s", ui.Bold(milestone.Name), ui.Bold(projectName))) ui.PrintMuted(4, "└─ New time entries will be automatically tagged") ui.NewlineBelow() + + return nil }, } diff --git a/cmd/milestones/status.go b/cmd/milestones/status.go index 0166171..16e4120 100644 --- a/cmd/milestones/status.go +++ b/cmd/milestones/status.go @@ -2,7 +2,6 @@ package milestones import ( "fmt" - "os" "time" "github.com/DylanDevelops/tmpo/internal/project" @@ -21,41 +20,41 @@ func StatusCmd() *cobra.Command { Use: "status", Short: "Show active milestone status", Long: `Display information about the currently active milestone for the current project, or the one specified.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() projectName, err := project.DetectConfiguredProjectWithOverride(statusMilestoneProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } // Get active milestone activeMilestone, err := db.GetActiveMilestoneForProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if activeMilestone == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("No active milestone found for %s", projectName)) ui.PrintMuted(0, "Use 'tmpo milestone start' to start a new milestone.") ui.NewlineBelow() - return + return nil } // Get entries for this milestone entries, err := db.GetEntriesByMilestone(projectName, activeMilestone.Name) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } // Calculate total time tracked @@ -73,6 +72,8 @@ func StatusCmd() *cobra.Command { ui.PrintInfo(4, "Entries", fmt.Sprintf("%d", len(entries))) ui.PrintInfo(4, "Total Time", ui.FormatDuration(totalTime)) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/tracking/cancel.go b/cmd/tracking/cancel.go index 333867e..7388354 100644 --- a/cmd/tracking/cancel.go +++ b/cmd/tracking/cancel.go @@ -2,7 +2,6 @@ package tracking import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" @@ -14,13 +13,13 @@ func CancelCmd() *cobra.Command { Use: "cancel", Short: "Cancel the running time entry", Long: "Stops and cancels the running time tracking session.", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -28,19 +27,19 @@ func CancelCmd() *cobra.Command { running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if running == nil { ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") ui.NewlineBelow() - os.Exit(0) + return nil } err = db.CancelEntry(running.ID) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{ @@ -53,6 +52,8 @@ func CancelCmd() *cobra.Command { ui.PrintMuted(4, "If this was a mistake, run `tmpo undo`.") ui.NewlineBelow() + + return nil }, } diff --git a/cmd/tracking/pause.go b/cmd/tracking/pause.go index f84bb60..61b1bb4 100644 --- a/cmd/tracking/pause.go +++ b/cmd/tracking/pause.go @@ -2,7 +2,6 @@ package tracking import ( "fmt" - "os" "time" "github.com/DylanDevelops/tmpo/internal/storage" @@ -15,13 +14,13 @@ func PauseCmd() *cobra.Command { Use: "pause", Short: "Pause time tracking", Long: `Pause the currently running time tracking session. Use 'tmpo resume' to continue tracking.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -29,19 +28,19 @@ func PauseCmd() *cobra.Command { running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if running == nil { ui.PrintWarning(ui.EmojiWarning, "No active time tracking session to pause.") ui.NewlineBelow() - os.Exit(0) + return nil } err = db.StopEntry(running.ID) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{Type: storage.ActionPause, EntryID: running.ID, ProjectName: running.ProjectName}) @@ -53,6 +52,8 @@ func PauseCmd() *cobra.Command { ui.PrintMuted(4, "Use 'tmpo resume' to continue tracking") ui.NewlineBelow() + + return nil }, } diff --git a/cmd/tracking/resume.go b/cmd/tracking/resume.go index c7d5fd9..ae359ef 100644 --- a/cmd/tracking/resume.go +++ b/cmd/tracking/resume.go @@ -2,7 +2,6 @@ package tracking import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" @@ -19,13 +18,13 @@ func ResumeCmd() *cobra.Command { Use: "resume", Short: "Resume time tracking", Long: `Resume time tracking by starting a new session with the same project and description as the last stopped session for the current project.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -33,39 +32,39 @@ func ResumeCmd() *cobra.Command { running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if running != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } projectName, err := project.DetectConfiguredProjectWithOverride(resumeProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } lastStopped, err := db.GetLastStoppedEntryByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if lastStopped == nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("No previous session found for project '%s' to resume.", projectName)) ui.PrintMuted(0, "Use 'tmpo start' to begin a new session.") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } entry, err := db.CreateEntry(lastStopped.ProjectName, lastStopped.Description, lastStopped.HourlyRate, lastStopped.MilestoneName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{Type: storage.ActionResume, EntryID: entry.ID, ProjectName: entry.ProjectName}) @@ -81,6 +80,8 @@ func ResumeCmd() *cobra.Command { } ui.NewlineBelow() + + return nil }, } diff --git a/cmd/tracking/start.go b/cmd/tracking/start.go index 23eb7f8..aab6c5b 100644 --- a/cmd/tracking/start.go +++ b/cmd/tracking/start.go @@ -2,7 +2,6 @@ package tracking import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/settings" @@ -20,13 +19,13 @@ func StartCmd() *cobra.Command { Use: "start [description]", Short: "Start tracking time", Long: `Start a new time tracking session for the current project.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -34,20 +33,20 @@ func StartCmd() *cobra.Command { running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if running != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } projectName, err := project.DetectConfiguredProjectWithOverride(startProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + return err } description := "" @@ -71,7 +70,7 @@ func StartCmd() *cobra.Command { entry, err := db.CreateEntry(projectName, description, hourlyRate, milestoneName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{Type: storage.ActionStart, EntryID: entry.ID, ProjectName: entry.ProjectName}) @@ -98,6 +97,8 @@ func StartCmd() *cobra.Command { } ui.NewlineBelow() + + return nil }, } diff --git a/cmd/tracking/status.go b/cmd/tracking/status.go index 0ca7359..dd2aa95 100644 --- a/cmd/tracking/status.go +++ b/cmd/tracking/status.go @@ -2,7 +2,6 @@ package tracking import ( "fmt" - "os" "time" "github.com/DylanDevelops/tmpo/internal/settings" @@ -17,13 +16,13 @@ func StatusCmd() *cobra.Command { Short: "Show current tracking status", Long: `Display information about the currently running time tracking session.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -31,7 +30,7 @@ func StatusCmd() *cobra.Command { running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if running == nil { @@ -39,7 +38,7 @@ func StatusCmd() *cobra.Command { ui.NewlineBelow() ui.PrintMuted(0, "Use 'tmpo start' to begin tracking") ui.NewlineBelow() - return + return nil } duration := time.Since(running.StartTime) @@ -57,6 +56,8 @@ func StatusCmd() *cobra.Command { } ui.NewlineBelow() + + return nil }, } diff --git a/cmd/tracking/stop.go b/cmd/tracking/stop.go index a6876bd..95858a2 100644 --- a/cmd/tracking/stop.go +++ b/cmd/tracking/stop.go @@ -2,7 +2,6 @@ package tracking import ( "fmt" - "os" "time" "github.com/DylanDevelops/tmpo/internal/storage" @@ -15,13 +14,13 @@ func StopCmd() *cobra.Command { Use: "stop", Short: "Stop tracking time", Long: `Stop the currently running time tracking session.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() @@ -29,19 +28,19 @@ func StopCmd() *cobra.Command { running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if running == nil { ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") ui.NewlineBelow() - os.Exit(0) + return nil } err = db.StopEntry(running.ID) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } db.SaveLastAction(storage.UndoAction{Type: storage.ActionStop, EntryID: running.ID, ProjectName: running.ProjectName}) @@ -52,6 +51,8 @@ func StopCmd() *cobra.Command { ui.PrintInfo(4, ui.Bold("Total Duration"), ui.FormatDuration(duration)) ui.NewlineBelow() + + return nil }, } diff --git a/cmd/utilities/undo.go b/cmd/utilities/undo.go index b1176b0..e36da18 100644 --- a/cmd/utilities/undo.go +++ b/cmd/utilities/undo.go @@ -2,7 +2,6 @@ package utilities import ( "fmt" - "os" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" @@ -26,26 +25,26 @@ func UndoCmd() *cobra.Command { Use: "undo", Short: "Undo the previous action", Long: `Undo the previous action in case of a mistake or in need of a rollback.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ui.NewlineAbove() db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } defer db.Close() action, err := db.GetLastAction() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + return err } if action == nil { ui.PrintWarning(ui.EmojiWarning, "Nothing to undo.") ui.NewlineBelow() - return + return nil } ui.PrintInfo(0, ui.EmojiUndo+" Last action", undoActionDescription(action)) @@ -58,13 +57,13 @@ func UndoCmd() *cobra.Command { if _, err := confirmPrompt.Run(); err != nil { ui.PrintWarning(ui.EmojiWarning, "Undo cancelled.") ui.NewlineBelow() - return + return nil } if err := applyUndo(db, action); err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("undo failed: %v", err)) ui.NewlineBelow() - os.Exit(1) + return ui.ErrHandled } // not fatal if fails @@ -72,6 +71,8 @@ func UndoCmd() *cobra.Command { ui.PrintSuccess(ui.EmojiUndo, "Undo successful.") ui.NewlineBelow() + + return nil }, } diff --git a/cmd/utilities/version.go b/cmd/utilities/version.go index 25c0b07..b62655c 100644 --- a/cmd/utilities/version.go +++ b/cmd/utilities/version.go @@ -26,8 +26,10 @@ func VersionCmd() *cobra.Command { Short: "Show version information", Long: "Display the current version information including date and release URL.", Hidden: true, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { DisplayVersionWithUpdateCheck() + + return nil }, }