diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6674fef..9a81e71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -176,11 +176,13 @@ All user data is stored locally in: ```text ~/.tmpo/ # Production (default) ├── tmpo.db # SQLite database - └── config.yaml # Global configuration (optional) + ├── config.yaml # Global configuration (optional) + └── projects.yaml # Global projects registry (optional) ~/.tmpo-dev/ # Development (when TMPO_DEV=1) ├── tmpo.db # SQLite database - └── config.yaml # Global configuration (optional) + ├── config.yaml # Global configuration (optional) + └── projects.yaml # Global projects registry (optional) ``` The database schema includes: @@ -200,9 +202,14 @@ The database schema includes: When a user runs `tmpo start`, the project name is detected in this priority order: -1. **`.tmporc` file** - Searches current directory and all parent directories -2. **Git repository** - Uses `git rev-parse --show-toplevel` to find repo root -3. **Directory name** - Falls back to current directory name +1. **`--project` flag** - Explicitly specified global project (e.g., `tmpo start --project "My Project"`) +2. **`.tmporc` file** - Searches current directory and all parent directories +3. **Git repository** - Uses `git rev-parse --show-toplevel` to find repo root +4. **Directory name** - Falls back to current directory name + +**Global Projects:** + +Users can create global projects with `tmpo init --global`, which stores project configurations in `~/.tmpo/projects.yaml`. These projects can be tracked from any directory using the `--project` flag. This logic lives in `internal/project/detect.go`. diff --git a/README.md b/README.md index c00cab5..a72e986 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - **🚀 Fast & Lightweight** - Built in Go, tmpo starts instantly and uses minimal resources - **🎯 Automatic Project Detection** - Detects project names from Git repos or `.tmporc` configuration files +- **🌐 Global Projects** - Track time for any project from any directory without configuration files - **🎯 Milestone Tracking** - Organize time entries into sprints, releases, or project phases - **💾 Local & Private Storage** - All data stored locally in SQLite - your time tracking stays private - **📊 Rich Reporting** - View stats, export to CSV/JSON, and track hourly rates @@ -58,6 +59,23 @@ tmpo stats tmpo milestone start "Sprint 1" ``` +### Track Projects From Anywhere + +Create global projects to track time from any directory: + +```bash +# Create a global project +tmpo init --global + +# Track time from anywhere on your system +cd /tmp +tmpo start --project "Client Work" "Implementing new feature" +tmpo stop + +# View entries from anywhere +tmpo log --project "Client Work" +``` + For detailed usage and all commands, see the [Usage Guide](docs/usage.md). ## Configuration @@ -80,16 +98,26 @@ This opens an interactive wizard to configure: ### Per-Project Settings -Optionally create a `.tmporc` file in your project to customize settings: +**Local Projects (directory-specific):** ```bash -# Interactive form (prompts for name, rate, description) +# Create a .tmporc file in your project directory tmpo init # Or skip prompts and use defaults tmpo init --accept-defaults ``` +**Global Projects (track from anywhere):** + +```bash +# Create a global project you can use from any directory +tmpo init --global + +# Now track time from anywhere +tmpo start --project "Project Name" +``` + See the [Configuration Guide](docs/configuration.md) for details. ## Feedback diff --git a/cmd/entries/edit.go b/cmd/entries/edit.go index eead463..b314ff8 100644 --- a/cmd/entries/edit.go +++ b/cmd/entries/edit.go @@ -14,7 +14,10 @@ import ( "github.com/spf13/cobra" ) -var showAllProjects bool +var ( + showAllProjects bool + editProjectFlag string +) func EditCmd() *cobra.Command { cmd := &cobra.Command{ @@ -73,8 +76,8 @@ func EditCmd() *cobra.Command { projectName = selectedProject } else { - // Use current project - detectedProject, err := project.DetectConfiguredProject() + // use current project or explicit project using --project flag + detectedProject, err := project.DetectConfiguredProjectWithOverride(editProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) @@ -437,6 +440,7 @@ func EditCmd() *cobra.Command { } cmd.Flags().BoolVar(&showAllProjects, "show-all-projects", false, "Show project selection before entry selection") + cmd.Flags().StringVarP(&editProjectFlag, "project", "p", "", "Edit entries for a specific global project") return cmd } diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 323de46..54aa037 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -15,6 +15,10 @@ import ( "github.com/spf13/cobra" ) +var ( + manualProjectFlag string +) + func getDateFormatInfo(configFormat string) (displayFormat, layout string) { switch configFormat { case "MM/DD/YYYY": @@ -24,7 +28,6 @@ func getDateFormatInfo(configFormat string) (displayFormat, layout string) { case "YYYY-MM-DD": return "YYYY-MM-DD", "2006-01-02" default: - // Default to MM-DD-YYYY return "MM-DD-YYYY", "01-02-2006" } } @@ -49,7 +52,7 @@ func ManualCmd() *cobra.Command { // Get date format for prompts and validation dateFormatDisplay, dateFormatLayout := getDateFormatInfo(globalCfg.DateFormat) - defaultProject := detectProjectNameWithSource() + defaultProject := detectProjectNameWithSource(manualProjectFlag) var projectLabel string if defaultProject != "" { @@ -243,6 +246,8 @@ func ManualCmd() *cobra.Command { }, } + cmd.Flags().StringVarP(&manualProjectFlag, "project", "p", "", "Create entry for a specific global project") + return cmd } @@ -323,12 +328,8 @@ func normalizeAMPM(input string) string { return strings.ToUpper(input) } -func detectProjectNameWithSource() (string) { - if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil && cfg.ProjectName != "" { - return cfg.ProjectName - } - - projectName, err := project.DetectProject() +func detectProjectNameWithSource(explicitProject string) string { + projectName, err := project.DetectConfiguredProjectWithOverride(explicitProject) if err != nil { return "" } diff --git a/cmd/history/export.go b/cmd/history/export.go index a7efb8d..ff8298c 100644 --- a/cmd/history/export.go +++ b/cmd/history/export.go @@ -42,10 +42,15 @@ func ExportCmd() *cobra.Command { var entries []*storage.TimeEntry if exportMilestone != "" { - projectName, err := project.DetectConfiguredProject() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + // if --project flag is used, ensure global project config is used + projectName := exportProject + if projectName == "" { + detectedProject, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + projectName = detectedProject } entries, err = db.GetEntriesByMilestone(projectName, exportMilestone) } else if exportToday { diff --git a/cmd/history/log.go b/cmd/history/log.go index f38f458..0a8085a 100644 --- a/cmd/history/log.go +++ b/cmd/history/log.go @@ -13,11 +13,11 @@ import ( ) var ( - logLimit int - logProject string - logMilestone string - logToday bool - logWeek bool + logLimit int + logProject string + logMilestone string + logToday bool + logWeek bool ) func LogCmd() *cobra.Command { @@ -40,10 +40,15 @@ func LogCmd() *cobra.Command { var entries []*storage.TimeEntry if logMilestone != "" { - projectName, err := project.DetectConfiguredProject() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) + // if --project flag is used, ensure global project config is used + projectName := logProject + if projectName == "" { + detectedProject, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + projectName = detectedProject } entries, err = db.GetEntriesByMilestone(projectName, logMilestone) } else if logToday { diff --git a/cmd/setup/init.go b/cmd/setup/init.go index 0806f69..4f45edb 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -16,135 +16,221 @@ import ( var ( acceptDefaults bool + globalProject bool ) func InitCmd() *cobra.Command { cmd := &cobra.Command{ Use: "init", - Short: "Initialize a .tmporc config file", - Long: `Create a .tmporc configuration file in the current directory using an interactive form.`, + 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) { ui.NewlineAbove() - if _, err := os.Stat(".tmporc"); err == nil { - ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") - ui.NewlineBelow() + // 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) } - defaultName := detectDefaultProjectName() + if globalProject { + initGlobalProject() + } else { + initLocalProject() + } - var name string - var hourlyRate float64 - var description string - var exportPath string + ui.NewlineBelow() + }, + } - if acceptDefaults { - // Use all defaults without prompting - name = defaultName - hourlyRate = 0 - description = "" - exportPath = "" - } else { - // Interactive form - ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") - fmt.Println() - - // Project Name prompt - namePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Project name (%s)", defaultName), - AllowEdit: true, - } + cmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") + cmd.Flags().BoolVarP(&globalProject, "global", "g", false, "Create a global project that can be tracked from any directory") - nameInput, err := namePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + return cmd +} - name = strings.TrimSpace(nameInput) - if name == "" { - name = defaultName - } +func initLocalProject() { + if _, err := os.Stat(".tmporc"); err == nil { + ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") + os.Exit(1) + } - // Hourly Rate prompt - ratePrompt := promptui.Prompt{ - Label: "Hourly rate (press Enter to skip)", - Validate: validateHourlyRate, - } + defaultName := detectDefaultProjectName() + name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Initialize Project Configuration") - rateInput, err := ratePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + // create a .tmporc file + err := settings.CreateWithTemplate(name, hourlyRate, description, exportPath) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - rateInput = strings.TrimSpace(rateInput) - if rateInput != "" { - hourlyRate, err = strconv.ParseFloat(rateInput, 64) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) - os.Exit(1) - } - } + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) + printProjectDetails(hourlyRate, description, exportPath) - // Description prompt - descPrompt := promptui.Prompt{ - Label: "Description (press Enter to skip)", - } + 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.") +} - descInput, err := descPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } +func initGlobalProject() { + registry, err := settings.LoadProjects() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to load projects registry: %v", err)) + os.Exit(1) + } - description = strings.TrimSpace(descInput) + // global projects require project name type in + name, hourlyRate, description, exportPath := getProjectDetails("", "Initialize Global Project") - // Export path prompt - exportPathPrompt := promptui.Prompt{ - Label: "Export path (press Enter to skip)", - } + if registry.Exists(name) { + ui.PrintError(ui.EmojiError, fmt.Sprintf("global project '%s' already exists", name)) + os.Exit(1) + } + + // create the project + var hourlyRatePtr *float64 + if hourlyRate > 0 { + hourlyRatePtr = &hourlyRate + } + + newProject := settings.GlobalProject{ + Name: name, + HourlyRate: hourlyRatePtr, + Description: description, + ExportPath: exportPath, + } + + err = registry.AddProject(newProject) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to add project: %v", err)) + os.Exit(1) + } + + err = registry.Save() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("failed to save projects registry: %v", err)) + os.Exit(1) + } + + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created global project %s", ui.Bold(name))) + printProjectDetails(hourlyRate, description, exportPath) + + fmt.Println() + ui.PrintMuted(0, "You can now track time for this project from any directory:") + 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.") +} + +func getProjectDetails(defaultName, title string) (name string, hourlyRate float64, description, exportPath string) { + if acceptDefaults { + name = defaultName + hourlyRate = 0 + description = "" + exportPath = "" + return + } - exportPathInput, err := exportPathPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) + ui.PrintSuccess(ui.EmojiInit, title) + fmt.Println() + + // project name prompt + var namePrompt promptui.Prompt + if defaultName != "" { + // local project + namePrompt = promptui.Prompt{ + Label: fmt.Sprintf("Project name (%s)", defaultName), + AllowEdit: true, + } + } else { + // global project + namePrompt = promptui.Prompt{ + Label: "Project name", + Validate: func(input string) error { + if strings.TrimSpace(input) == "" { + return fmt.Errorf("project name is required") } + return nil + }, + } + } - exportPath = strings.TrimSpace(exportPathInput) - } + nameInput, err := namePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - // Create the .tmporc file - err := settings.CreateWithTemplate(name, hourlyRate, description, exportPath) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + name = strings.TrimSpace(nameInput) + if name == "" && defaultName != "" { + name = defaultName + } - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) - if hourlyRate > 0 { - ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("%.2f", hourlyRate)) - } - if description != "" { - ui.PrintInfo(4, ui.Bold("Description"), description) - } - if exportPath != "" { - ui.PrintInfo(4, ui.Bold("Export path"), exportPath) - } + // hourly Rate prompt + ratePrompt := promptui.Prompt{ + Label: "Hourly rate (press Enter to skip)", + Validate: validateHourlyRate, + } - 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.") + rateInput, err := ratePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - ui.NewlineBelow() - }, + rateInput = strings.TrimSpace(rateInput) + if rateInput != "" { + hourlyRate, err = strconv.ParseFloat(rateInput, 64) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) + os.Exit(1) + } } - cmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") + // description prompt + descPrompt := promptui.Prompt{ + Label: "Description (press Enter to skip)", + } - return cmd + descInput, err := descPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + description = strings.TrimSpace(descInput) + + // export path prompt + exportPathPrompt := promptui.Prompt{ + Label: "Export path (press Enter to skip)", + } + + exportPathInput, err := exportPathPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + exportPath = strings.TrimSpace(exportPathInput) + + return +} + +func printProjectDetails(hourlyRate float64, description, exportPath string) { + if hourlyRate > 0 { + ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("%.2f", hourlyRate)) + } + + if description != "" { + ui.PrintInfo(4, ui.Bold("Description"), description) + } + + if exportPath != "" { + ui.PrintInfo(4, ui.Bold("Export path"), exportPath) + } } func detectDefaultProjectName() string { @@ -171,7 +257,7 @@ func detectDefaultProjectName() string { func validateHourlyRate(input string) error { input = strings.TrimSpace(input) if input == "" { - return nil // Allow empty for optional field + return nil // optional field } rate, err := strconv.ParseFloat(input, 64) @@ -189,15 +275,14 @@ func validateHourlyRate(input string) error { func validateCurrency(input string) error { input = strings.TrimSpace(input) if input == "" { - return nil // Allow empty for default + return nil // empty for default } - // Currency codes should be 3 letters + // check formatting for currency codes if len(input) != 3 { return fmt.Errorf("currency code must be 3 letters (e.g., USD, EUR, GBP)") } - // Check that it's all letters for _, char := range input { if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') { return fmt.Errorf("currency code must contain only letters") diff --git a/cmd/setup/init_test.go b/cmd/setup/init_test.go index d9edf62..c0a8f72 100644 --- a/cmd/setup/init_test.go +++ b/cmd/setup/init_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/DylanDevelops/tmpo/internal/settings" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -117,3 +118,144 @@ func TestValidateHourlyRate(t *testing.T) { }) } } + +func TestGetProjectDetails(t *testing.T) { + // Save original acceptDefaults value and reset after test + originalAcceptDefaults := acceptDefaults + defer func() { acceptDefaults = originalAcceptDefaults }() + + t.Run("uses defaults when acceptDefaults is true", func(t *testing.T) { + acceptDefaults = true + defaultName := "test-project" + + name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Test Title") + + assert.Equal(t, defaultName, name) + assert.Equal(t, float64(0), hourlyRate) + assert.Empty(t, description) + assert.Empty(t, exportPath) + }) +} + +func TestPrintProjectDetails(t *testing.T) { + // This is primarily a display function, so we just test it doesn't panic + t.Run("handles all fields present", func(t *testing.T) { + assert.NotPanics(t, func() { + printProjectDetails(100.0, "Test description", "/tmp/export") + }) + }) + + t.Run("handles empty fields", func(t *testing.T) { + assert.NotPanics(t, func() { + printProjectDetails(0, "", "") + }) + }) + + t.Run("handles partial fields", func(t *testing.T) { + assert.NotPanics(t, func() { + printProjectDetails(50.5, "Description only", "") + }) + }) +} + +func TestInitGlobalProject_Integration(t *testing.T) { + // Set up test environment + tmpDir := t.TempDir() + + t.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows + t.Setenv("TMPO_DEV", "1") + + t.Run("creates global project directly via registry", func(t *testing.T) { + // Instead of testing initGlobalProject() which requires interactive input, + // test the registry operations directly + registry, err := settings.LoadProjects() + require.NoError(t, err) + + // Add a test project + rate := 100.0 + newProject := settings.GlobalProject{ + Name: "test-project", + HourlyRate: &rate, + Description: "Test description", + ExportPath: "/tmp/test", + } + + err = registry.AddProject(newProject) + require.NoError(t, err) + + err = registry.Save() + require.NoError(t, err) + + // Verify project was added to registry + reloadedRegistry, err := settings.LoadProjects() + require.NoError(t, err) + assert.True(t, reloadedRegistry.Exists("test-project")) + + // Verify project details + project, err := reloadedRegistry.GetProject("test-project") + require.NoError(t, err) + assert.Equal(t, "test-project", project.Name) + assert.Equal(t, &rate, project.HourlyRate) + assert.Equal(t, "Test description", project.Description) + assert.Equal(t, "/tmp/test", project.ExportPath) + }) +} + +func TestInitLocalProject_Integration(t *testing.T) { + // Save original flags and restore after test + originalAcceptDefaults := acceptDefaults + originalGlobalProject := globalProject + defer func() { + acceptDefaults = originalAcceptDefaults + globalProject = originalGlobalProject + }() + + acceptDefaults = true + globalProject = false + + t.Run("creates local .tmporc file", func(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Run init + assert.NotPanics(t, func() { + initLocalProject() + }) + + // Verify .tmporc was created + tmporc := filepath.Join(tmpDir, ".tmporc") + _, err = os.Stat(tmporc) + assert.NoError(t, err) + + // Verify content can be loaded + cfg, err := settings.Load(tmporc) + require.NoError(t, err) + assert.Equal(t, filepath.Base(tmpDir), cfg.ProjectName) + }) + + t.Run("prevents duplicate .tmporc creation", func(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Create .tmporc + tmporc := filepath.Join(tmpDir, ".tmporc") + err = os.WriteFile(tmporc, []byte("project_name: existing\n"), 0644) + require.NoError(t, err) + + // Attempt to run init again should exit + // We can't easily test os.Exit(), so we'll just verify the check logic + _, err = os.Stat(".tmporc") + assert.NoError(t, err) // File exists, so initLocalProject would fail + }) +} diff --git a/cmd/tracking/resume.go b/cmd/tracking/resume.go index 201cb67..ea2fe9c 100644 --- a/cmd/tracking/resume.go +++ b/cmd/tracking/resume.go @@ -10,6 +10,10 @@ import ( "github.com/spf13/cobra" ) +var ( + resumeProjectFlag string +) + func ResumeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "resume", @@ -39,7 +43,7 @@ func ResumeCmd() *cobra.Command { os.Exit(1) } - projectName, err := project.DetectConfiguredProject() + projectName, err := project.DetectConfiguredProjectWithOverride(resumeProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) @@ -78,5 +82,7 @@ func ResumeCmd() *cobra.Command { }, } + cmd.Flags().StringVarP(&resumeProjectFlag, "project", "p", "", "Resume tracking for a specific global project") + return cmd } diff --git a/cmd/tracking/start.go b/cmd/tracking/start.go index e68ca61..5efee1b 100644 --- a/cmd/tracking/start.go +++ b/cmd/tracking/start.go @@ -11,6 +11,10 @@ import ( "github.com/spf13/cobra" ) +var ( + startProjectFlag string +) + func StartCmd() *cobra.Command { cmd := &cobra.Command{ Use: "start [description]", @@ -40,7 +44,7 @@ func StartCmd() *cobra.Command { os.Exit(1) } - projectName, err := project.DetectConfiguredProject() + projectName, err := project.DetectConfiguredProjectWithOverride(startProjectFlag) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) @@ -52,8 +56,9 @@ func StartCmd() *cobra.Command { } var hourlyRate *float64 - if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { - hourlyRate = &cfg.HourlyRate + configRate, _, err := project.GetProjectConfig(projectName) + if err == nil && configRate != nil { + hourlyRate = configRate } var milestoneName *string @@ -71,7 +76,10 @@ func StartCmd() *cobra.Command { ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName))) - if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil { + // communicate config source to user + if startProjectFlag != "" { + ui.PrintMuted(4, "└─ Config Source: global project") + } else if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil { ui.PrintMuted(4, "└─ Config Source: .tmporc") } else if project.IsInGitRepo() { ui.PrintMuted(4, "└─ Config Source: git repository") @@ -91,5 +99,7 @@ func StartCmd() *cobra.Command { }, } + cmd.Flags().StringVarP(&startProjectFlag, "project", "p", "", "Track time for a specific global project") + return cmd } diff --git a/docs/configuration.md b/docs/configuration.md index 1886e9f..425ab1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,10 +9,11 @@ All time tracking data and configuration is stored locally on your machine: ```text ~/.tmpo/ ├── tmpo.db # SQLite database with time entries - └── config.yaml # Global configuration (optional) + ├── config.yaml # Global configuration (optional) + └── projects.yaml # Global projects registry (optional) ``` -Your data never leaves your machine. Both files can be backed up, copied, or version controlled if desired. +Your data never leaves your machine. All files can be backed up, copied, or version controlled if desired. > [!NOTE] > **Contributors**, when developing tmpo with `TMPO_DEV=1` or `TMPO_DEV=true`, both files are stored in `~/.tmpo-dev/` instead to keep development work separate from your production data. @@ -133,6 +134,139 @@ export_path: /Users/dylan/Dropbox/timesheets export_path: "" ``` +## Global Projects + +### What Are Global Projects? + +Global projects allow you to track time for any project from any directory without needing `.tmporc` files or Git repositories. They're perfect for: + +- **Consulting work** - Track multiple clients without directory structure +- **Non-code projects** - Meetings, research, administrative tasks +- **Flexible workflows** - Switch projects without changing directories +- **Project portfolios** - Manage many small projects easily + +### Creating Global Projects + +Use `tmpo init --global` to create a global project: + +```bash +tmpo init --global +# [tmpo] Initialize Global Project +# Project name: Client Consulting +# Hourly rate (press Enter to skip): 175 +# Description (press Enter to skip): Hourly consulting for Acme Corp +# Export path (press Enter to skip): ~/Documents/acme-timesheets +``` + +Or use `--accept-defaults` for quick setup: + +```bash +tmpo init --global --accept-defaults +# Uses current directory name as project name with default values +``` + +### Using Global Projects + +Once created, track global projects from anywhere: + +```bash +# Track from any directory +cd /tmp +tmpo start --project "Client Consulting" "Architecture review" + +# Resume from anywhere +cd / +tmpo resume --project "Client Consulting" + +# View logs from anywhere +tmpo log --project "Client Consulting" + +# Export from anywhere +tmpo export --project "Client Consulting" --format csv +``` + +### The `projects.yaml` File + +Global projects are stored in `~/.tmpo/projects.yaml`: + +```yaml +projects: + - name: "Client Consulting" + hourly_rate: 175.0 + description: "Hourly consulting for Acme Corp" + export_path: "~/Documents/acme-timesheets" + - name: "Side Project" + hourly_rate: 50.0 + description: "Personal side project" + - name: "Research" + description: "General research and learning" +``` + +### Configuration Fields + +#### `name` (required) + +The project name used when tracking time with the `--project` flag. Must be unique. + +```yaml +name: "Client Work - Q1 2024" +``` + +#### `hourly_rate` (optional) + +Your billing rate per hour for this project. The currency symbol is determined by your global currency setting (`tmpo config`). + +```yaml +hourly_rate: 150.00 +``` + +Omit or set to `0` to disable rate tracking: + +```yaml +# No hourly rate +projects: + - name: "Personal Project" + description: "My side project" +``` + +#### `description` (optional) + +Notes or details about the project for your reference. + +```yaml +description: "Web development for Acme Corp. Contact: john@acme.com" +``` + +#### `export_path` (optional) + +Default export directory for this project's data. + +```yaml +export_path: "~/Documents/client-exports" +``` + +### Managing Global Projects + +You can manually edit `~/.tmpo/projects.yaml` to: + +- **Add projects** - Add a new entry to the `projects` list +- **Update projects** - Modify name, rate, description, or export path +- **Remove projects** - Delete an entry from the list + +**Example manual edit:** + +```yaml +projects: + - name: "Old Project Name" + hourly_rate: 100.0 + - name: "New Project" # Added manually + hourly_rate: 125.0 + description: "Newly added project" +``` + +> [!NOTE] +> After manually editing, validate the YAML syntax. Invalid YAML will cause errors when loading projects. + ## Project Configuration ### The `.tmporc` File @@ -254,25 +388,29 @@ export_path: "" When you run `tmpo start`, the project name is determined in this order: -1. **`.tmporc` file** - If present in current directory or any parent directory -2. **Git repository name** - The name of the git repository root folder -3. **Current directory name** - The name of your current working directory +1. **`--project` flag** - Explicitly specified global project (highest priority) +2. **`.tmporc` file** - If present in current directory or any parent directory +3. **Git repository name** - The name of the git repository root folder +4. **Current directory name** - The name of your current working directory (fallback) -This means you can override automatic detection by adding a `.tmporc` file. +This means you can: + +- Use `--project` to explicitly track a global project from anywhere +- Override automatic detection by adding a `.tmporc` file +- Let tmpo auto-detect from Git or directory name ### Example Scenarios -#### **Scenario 1:** Git repo with custom name +#### **Scenario 1:** Explicit global project (highest priority) ```bash -# Directory: ~/code/website-2024/ -# Git repo name: website-2024 -# No .tmporc file -tmpo start -# → Tracks to project "website-2024" +# Directory: /tmp (any directory) +# Global project "Client Work" exists in projects.yaml +tmpo start --project "Client Work" +# → Tracks to global project "Client Work" ``` -#### **Scenario 2:** With .tmporc override +#### **Scenario 2:** With .tmporc file ```bash # Directory: ~/code/website-2024/ @@ -281,7 +419,17 @@ tmpo start # → Tracks to project "Acme Website" ``` -#### **Scenario 3:** Subdirectory detection +#### **Scenario 3:** Git repo name + +```bash +# Directory: ~/code/website-2024/ +# Git repo name: website-2024 +# No .tmporc file, no --project flag +tmpo start +# → Tracks to project "website-2024" +``` + +#### **Scenario 4:** Subdirectory detection ```bash # Directory: ~/code/my-project/src/components/ @@ -290,9 +438,58 @@ tmpo start # → Uses .tmporc from project root ``` +#### **Scenario 5:** Override local with global + +```bash +# Directory: ~/code/website-2024/ +# .tmporc contains: project_name: "Website" +# But you want to track to a global project instead +tmpo start --project "Client Work" +# → Tracks to global project "Client Work" (--project overrides .tmporc) +``` + ## Multi-Project Setup -### Separate Projects with Different Rates +### Choosing Your Approach + +You have three options for managing multiple projects: + +1. **Global Projects** - Track projects from any directory (best for consulting, non-code work) +2. **Local .tmporc Files** - Directory-based tracking (best for code projects) +3. **Mix Both** - Use global for flexible work, local for specific codebases + +### Option 1: Global Projects + +Create global projects once, use them anywhere: + +```bash +# Create global projects +tmpo init --global +# Project name: Client A Consulting +# Hourly rate: 150 + +tmpo init --global +# Project name: Client B Development +# Hourly rate: 175 + +tmpo init --global +# Project name: Internal Projects +# Hourly rate: 100 + +# Track from anywhere +cd /tmp +tmpo start --project "Client A Consulting" "Architecture review" +tmpo start --project "Client B Development" "Feature implementation" +``` + +**Best for:** + +- Consulting and freelance work +- Multiple small projects +- Non-code tasks (meetings, research, admin) +- Working across many directories + +### Option 2: Local .tmporc Files Create a `.tmporc` in each project directory using `tmpo init`: @@ -333,6 +530,61 @@ hourly_rate: 150.00 EOF ``` +**Best for:** + +- Code projects in specific directories +- Team projects with shared .tmporc files +- Projects with consistent directory structure + +### Option 3: Mix Global and Local + +Combine both approaches for maximum flexibility: + +```bash +# Global projects for flexible work +tmpo init --global +# Project name: Consulting Calls +# Hourly rate: 200 + +tmpo init --global +# Project name: Research & Planning +# Hourly rate: 0 + +# Local .tmporc for main code projects +cd ~/projects/client-website +tmpo init +# Project name: Client Website +# Hourly rate: 150 + +cd ~/projects/internal-tool +tmpo init +# Project name: Internal Dashboard +# Hourly rate: 100 +``` + +**Usage example:** + +```bash +# Morning: consulting call (global project, from anywhere) +tmpo start --project "Consulting Calls" "Client strategy session" +tmpo stop + +# Afternoon: code work (local .tmporc, auto-detected) +cd ~/projects/client-website +tmpo start "Implementing new feature" +tmpo stop + +# Evening: research (global project, from anywhere) +cd ~/Downloads +tmpo start --project "Research & Planning" "Exploring new frameworks" +``` + +**Best for:** + +- Mixed work types (code + consulting + meetings) +- Flexibility without losing structure +- Large project portfolios + ### Monorepo with Sub-Projects In a monorepo, you can track different sub-projects separately: @@ -389,21 +641,26 @@ git config --global core.excludesfile ~/.gitignore_global # Create a backup of your time tracking database cp ~/.tmpo/tmpo.db ~/backups/tmpo-backup-$(date +%Y%m%d).db -# Optionally backup your global config too +# Backup your global config cp ~/.tmpo/config.yaml ~/backups/tmpo-config-backup-$(date +%Y%m%d).yaml + +# Backup your global projects registry (if you have global projects) +cp ~/.tmpo/projects.yaml ~/backups/tmpo-projects-backup-$(date +%Y%m%d).yaml ``` ### Moving to a New Machine ```bash -# On old machine - backup both database and config +# On old machine - backup all files cp ~/.tmpo/tmpo.db ~/tmpo-export.db cp ~/.tmpo/config.yaml ~/tmpo-config.yaml +cp ~/.tmpo/projects.yaml ~/tmpo-projects.yaml # If you have global projects # Transfer files to new machine, then: mkdir -p ~/.tmpo cp ~/tmpo-export.db ~/.tmpo/tmpo.db cp ~/tmpo-config.yaml ~/.tmpo/config.yaml +cp ~/tmpo-projects.yaml ~/.tmpo/projects.yaml # If you have global projects ``` ### Exporting for External Tools diff --git a/docs/usage.md b/docs/usage.md index 43a5026..c3c6519 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -8,15 +8,22 @@ Complete reference for all tmpo commands and features. Start tracking time for the current project. Automatically detects the project name from: -1. `.tmporc` configuration file (if present) -2. Git repository name -3. Current directory name +1. `--project` flag (global project) +2. `.tmporc` configuration file (if present) +3. Git repository name +4. Current directory name + +**Options:** + +- `--project NAME` / `-p NAME` - Track time for a specific global project **Examples:** ```bash -tmpo start # Start tracking +tmpo start # Start tracking (auto-detect) tmpo start "Fix authentication bug" # Start with description +tmpo start --project "Client Work" # Track a global project from anywhere +tmpo start -p "Consulting" "Code review" # Short flag with description ``` ### `tmpo stop` @@ -49,10 +56,17 @@ tmpo pause Resume time tracking by starting a new session with the same project and description as the last paused (or stopped) session. +**Options:** + +- `--project NAME` / `-p NAME` - Resume tracking for a specific global project + +**Examples:** + ```bash -tmpo resume +tmpo resume # Resume current project +tmpo resume --project "Client Work" # Resume a global project from anywhere # Output: -# [tmpo] Resumed tracking time for my-project +# [tmpo] Resumed tracking time for Client Work # Description: Implementing feature ``` @@ -61,6 +75,7 @@ tmpo resume - Continue work after a break - Resume after accidentally stopping the timer - Quickly restart the same task +- Resume a global project from any directory ### `tmpo status` @@ -92,6 +107,7 @@ View your time tracking history. ```bash tmpo log # Show recent entries tmpo log --limit 50 # Show more entries +tmpo log --project "Client Work" # Filter by global project tmpo log --milestone "Sprint 1" # Filter by milestone tmpo log --today # Show today's entries tmpo log --week # Show this week's entries @@ -154,39 +170,61 @@ See [Configuration Guide](configuration.md#global-configuration) for more detail ### `tmpo init` -Create a `.tmporc` configuration file for the current project using an interactive form. You'll be prompted to enter: +Create a project configuration using an interactive form. + +**Options:** + +- `--global` / `-g` - Create a global project (track from any directory) +- `--accept-defaults` / `-a` - Skip prompts and use defaults + +**Types of Projects:** + +1. **Local Projects** (default) - Creates a `.tmporc` file in current directory +2. **Global Projects** (with `--global`) - Can be tracked from any directory + +You'll be prompted to enter: - **Project name** - Defaults to auto-detected name from Git repo or directory - **Hourly rate** - Optional billing rate (press Enter to skip) - **Description** - Optional project description (press Enter to skip) - **Export path** - Optional default export directory (press Enter to skip) -**Interactive Mode (default):** +**Examples:** ```bash +# Create a local project (.tmporc in current directory) tmpo init # [tmpo] Initialize Project Configuration # Project name (my-project): [Enter custom name or press Enter for default] # Hourly rate (press Enter to skip): 150 # Description (press Enter to skip): Client website redesign # Export path (press Enter to skip): ~/Documents/client-exports + +# Create a global project (track from anywhere) +tmpo init --global +# [tmpo] Initialize Global Project +# Project name: Client Work +# Hourly rate: 150 +# Description: Consulting work +# Export path: ~/exports/client + +# Quick setup with defaults +tmpo init --accept-defaults # Local with defaults +tmpo init --global --accept-defaults # Global with defaults ``` -**Quick Mode:** +**Using Global Projects:** -Use the `--accept-defaults` flag to skip all prompts and use auto-detected defaults: +Once created, track global projects from any directory: ```bash -tmpo init --accept-defaults # Creates .tmporc with defaults, no prompts +cd /tmp +tmpo start --project "Client Work" "Working on feature" +tmpo log --project "Client Work" +tmpo resume --project "Client Work" ``` -This creates a `.tmporc` file with: - -- Project name from Git repo or directory name -- Hourly rate of 0 (disabled) -- Empty description - -See [Configuration Guide](configuration.md) for details on the `.tmporc` file format and manual editing. +See [Configuration Guide](configuration.md) for details on project configuration. ## Milestone Management @@ -274,10 +312,17 @@ tmpo milestone list --all # List all milestones Create manual time entries for past work using an interactive prompt. +**Options:** + +- `--project NAME` / `-p NAME` - Create entry for a specific global project + +**Examples:** + ```bash -tmpo manual +tmpo manual # Create entry for current project +tmpo manual --project "Client Work" # Create entry for global project # Prompts for: -# - Project name +# - Project name (pre-filled if --project is used) # - Start date and time (date format follows your config setting) # - End date and time (date format follows your config setting) # - Description @@ -300,13 +345,15 @@ Edit an existing time entry using an interactive menu. Select an entry and modif **Options:** +- `--project NAME` / `-p NAME` - Edit entries for a specific global project - `--show-all-projects` - Show project selection before entry selection **Examples:** ```bash -tmpo edit # Edit entries from current project -tmpo edit --show-all-projects # Select project first, then entry +tmpo edit # Edit entries from current project +tmpo edit --project "Client Work" # Edit entries for global project +tmpo edit --show-all-projects # Select project first, then entry ``` **Interactive Flow:** @@ -387,11 +434,12 @@ Export your time tracking data to CSV or JSON. ```bash tmpo export # Export all as CSV tmpo export --format json # Export as JSON -tmpo export --project "My Project" # Filter by project +tmpo export --project "Client Work" # Filter by global project tmpo export --milestone "Sprint 1" # Filter by milestone tmpo export --today # Export today's entries tmpo export --week # Export this week tmpo export --output timesheet.csv # Specify output file +tmpo export --project "Consulting" --format json # Global project to JSON ``` **CSV Format:** @@ -451,6 +499,8 @@ tmpo export --week --output timesheet-$(date +%Y-%m-%d).csv ### Multi-Project Workflow +**Option 1: Local .tmporc Files (directory-based)** + Create a `.tmporc` file in each project directory with different hourly rates: ```bash @@ -473,6 +523,55 @@ tmpo init Now `tmpo start` will automatically track to the correct project when you're in each directory. +**Option 2: Global Projects (track from anywhere)** + +Create global projects once, then track from any directory: + +```bash +# Create global projects +tmpo init --global +# Project name: Client A +# Hourly rate: 150 + +tmpo init --global +# Project name: Client B +# Hourly rate: 175 + +tmpo init --global +# Project name: Personal Projects +# Hourly rate: 0 + +# Now track from anywhere +cd /tmp +tmpo start --project "Client A" "Working on feature X" +# ... work ... +tmpo stop + +tmpo start --project "Client B" "Code review" +# ... work ... +tmpo stop + +# Switch projects without changing directories +tmpo start --project "Personal Projects" "Weekend coding" +``` + +**Option 3: Mix Both** + +Use global projects for non-directory work (consulting, meetings) and local `.tmporc` files for code projects: + +```bash +# Global projects for general work +tmpo init --global +# Project name: Consulting +# Hourly rate: 200 + +# Local .tmporc for specific codebases +cd ~/projects/client-website +tmpo init +# Project name: Client Website +# Hourly rate: 150 +``` + ### Tracking Without Descriptions You can always start tracking immediately and add context later by checking your git commits: diff --git a/internal/project/detect.go b/internal/project/detect.go index 530ef4a..786cd4b 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -33,12 +33,39 @@ func DetectProject() (string, error) { func DetectConfiguredProject() (string, error) { + return DetectConfiguredProjectWithOverride("") +} + +// DetectConfiguredProjectWithOverride detects the project (priority: specific project name > .tmporc > git repo > directory name) +func DetectConfiguredProjectWithOverride(explicitProject string) (string, error) { + // first priority: --project flag + if explicitProject != "" { + registry, err := settings.LoadProjects() + if err != nil { + return "", fmt.Errorf("failed to load projects registry: %w", err) + } + + if !registry.Exists(explicitProject) { + // check if this project name exists in a local .tmporc to provide a helpful hint + if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil { + if strings.EqualFold(cfg.ProjectName, explicitProject) { + return "", fmt.Errorf("project '%s' not found in global registry.\n\nHowever, a local .tmporc file has a project named '%s'.\nTo use the local project, run the command without --project:\n tmpo start (or the command you're trying to run)\n\nTo create a global project with this name:\n tmpo init --global", explicitProject, cfg.ProjectName) + } + } + return "", fmt.Errorf("project '%s' not found in global registry.\n\nTo create this global project:\n tmpo init --global", explicitProject) + } + + return explicitProject, nil + } + + // second priority: .tmporc configuration if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil { if cfg.ProjectName != "" { return cfg.ProjectName, nil } } + // third priority: directory-based detection return DetectProject() } @@ -93,3 +120,30 @@ func GetGitRoot() (string, error) { return strings.TrimSpace(string(output)), nil } + +// GetProjectConfig retrieves project configuration for a given project name. +// Returns hourly rate and export path if configured +func GetProjectConfig(projectName string) (*float64, string, error) { + // check if global project + registry, err := settings.LoadProjects() + if err == nil && registry.Exists(projectName) { + project, err := registry.GetProject(projectName) + if err == nil { + return project.HourlyRate, project.ExportPath, nil + } + } + + // fall back to .tmporc + cfg, _, err := settings.FindAndLoad() + if err == nil && cfg != nil && cfg.ProjectName == projectName { + var hourlyRate *float64 + if cfg.HourlyRate > 0 { + rate := cfg.HourlyRate + hourlyRate = &rate + } + return hourlyRate, cfg.ExportPath, nil + } + + // no configuration exists + return nil, "", nil +} diff --git a/internal/project/detect_test.go b/internal/project/detect_test.go index c62b442..655552f 100644 --- a/internal/project/detect_test.go +++ b/internal/project/detect_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/DylanDevelops/tmpo/internal/settings" "github.com/stretchr/testify/assert" ) @@ -198,3 +199,207 @@ func TestDetectProject(t *testing.T) { assert.Equal(t, "fallback-project", name) }) } + +func TestDetectConfiguredProjectWithOverride(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + os.Setenv("HOME", tmpDir) + os.Setenv("TMPO_DEV", "1") + + t.Run("returns explicit project when provided and exists", func(t *testing.T) { + // Create global project registry + registry := &settings.ProjectsRegistry{ + Projects: []settings.GlobalProject{ + {Name: "Global Project"}, + }, + } + err := registry.Save() + assert.NoError(t, err) + + name, err := DetectConfiguredProjectWithOverride("Global Project") + assert.NoError(t, err) + assert.Equal(t, "Global Project", name) + }) + + t.Run("returns error when explicit project doesn't exist", func(t *testing.T) { + // Empty registry + registry := &settings.ProjectsRegistry{Projects: []settings.GlobalProject{}} + err := registry.Save() + assert.NoError(t, err) + + _, err = DetectConfiguredProjectWithOverride("Non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in global registry") + }) + + t.Run("falls back to tmporc when no explicit project", func(t *testing.T) { + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + projectDir := t.TempDir() + tmporc := filepath.Join(projectDir, ".tmporc") + err = os.WriteFile(tmporc, []byte("project_name: Local Project\n"), 0644) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + name, err := DetectConfiguredProjectWithOverride("") + assert.NoError(t, err) + assert.Equal(t, "Local Project", name) + }) + + t.Run("explicit project takes priority over tmporc", func(t *testing.T) { + // Create global project + registry := &settings.ProjectsRegistry{ + Projects: []settings.GlobalProject{ + {Name: "Global Project"}, + }, + } + err := registry.Save() + assert.NoError(t, err) + + // Create .tmporc + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + projectDir := t.TempDir() + tmporc := filepath.Join(projectDir, ".tmporc") + err = os.WriteFile(tmporc, []byte("project_name: Local Project\n"), 0644) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + // Explicit project should override tmporc + name, err := DetectConfiguredProjectWithOverride("Global Project") + assert.NoError(t, err) + assert.Equal(t, "Global Project", name) + }) +} + +func TestGetProjectConfig(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + os.Setenv("HOME", tmpDir) + os.Setenv("TMPO_DEV", "1") + + t.Run("retrieves config from global project", func(t *testing.T) { + rate := 125.0 + registry := &settings.ProjectsRegistry{ + Projects: []settings.GlobalProject{ + { + Name: "Global Project", + HourlyRate: &rate, + ExportPath: "/tmp/global", + }, + }, + } + err := registry.Save() + assert.NoError(t, err) + + hourlyRate, exportPath, err := GetProjectConfig("Global Project") + assert.NoError(t, err) + assert.NotNil(t, hourlyRate) + assert.Equal(t, 125.0, *hourlyRate) + assert.Equal(t, "/tmp/global", exportPath) + }) + + t.Run("retrieves config from tmporc", func(t *testing.T) { + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + projectDir := t.TempDir() + tmporc := filepath.Join(projectDir, ".tmporc") + content := `project_name: Local Project +hourly_rate: 100.0 +export_path: /tmp/local +` + err = os.WriteFile(tmporc, []byte(content), 0644) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + hourlyRate, exportPath, err := GetProjectConfig("Local Project") + assert.NoError(t, err) + assert.NotNil(t, hourlyRate) + assert.Equal(t, 100.0, *hourlyRate) + assert.Equal(t, "/tmp/local", exportPath) + }) + + t.Run("returns nil for project without config", func(t *testing.T) { + hourlyRate, exportPath, err := GetProjectConfig("Non-existent Project") + assert.NoError(t, err) + assert.Nil(t, hourlyRate) + assert.Empty(t, exportPath) + }) + + t.Run("returns nil hourly rate when not set in tmporc", func(t *testing.T) { + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + projectDir := t.TempDir() + tmporc := filepath.Join(projectDir, ".tmporc") + content := `project_name: Minimal Project +` + err = os.WriteFile(tmporc, []byte(content), 0644) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + hourlyRate, exportPath, err := GetProjectConfig("Minimal Project") + assert.NoError(t, err) + assert.Nil(t, hourlyRate) + assert.Empty(t, exportPath) + }) + + t.Run("prioritizes global project over tmporc with same name", func(t *testing.T) { + // Create global project + rate := 200.0 + registry := &settings.ProjectsRegistry{ + Projects: []settings.GlobalProject{ + { + Name: "Shared Name", + HourlyRate: &rate, + ExportPath: "/tmp/global", + }, + }, + } + err := registry.Save() + assert.NoError(t, err) + + // Create .tmporc with same name + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + projectDir := t.TempDir() + tmporc := filepath.Join(projectDir, ".tmporc") + content := `project_name: Shared Name +hourly_rate: 100.0 +export_path: /tmp/local +` + err = os.WriteFile(tmporc, []byte(content), 0644) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + // Should get global project config + hourlyRate, exportPath, err := GetProjectConfig("Shared Name") + assert.NoError(t, err) + assert.NotNil(t, hourlyRate) + assert.Equal(t, 200.0, *hourlyRate) + assert.Equal(t, "/tmp/global", exportPath) + }) +} diff --git a/internal/settings/projects.go b/internal/settings/projects.go new file mode 100644 index 0000000..9d66771 --- /dev/null +++ b/internal/settings/projects.go @@ -0,0 +1,176 @@ +package settings + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "go.yaml.in/yaml/v3" +) + +// GlobalProject represents a global project configuration +type GlobalProject struct { + Name string `yaml:"name"` + HourlyRate *float64 `yaml:"hourly_rate,omitempty"` + Description string `yaml:"description,omitempty"` + ExportPath string `yaml:"export_path,omitempty"` +} + +// ProjectsRegistry holds all global projects +type ProjectsRegistry struct { + Projects []GlobalProject `yaml:"projects"` +} + +// GetProjectsPath returns the path to the global projects registry file +func GetProjectsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + tmpoDir := filepath.Join(home, ".tmpo") + if devMode := os.Getenv("TMPO_DEV"); devMode == "1" || devMode == "true" { + tmpoDir = filepath.Join(home, ".tmpo-dev") + } + + return filepath.Join(tmpoDir, "projects.yaml"), nil +} + +// LoadProjects loads the global projects registry +func LoadProjects() (*ProjectsRegistry, error) { + projectsPath, err := GetProjectsPath() + if err != nil { + return &ProjectsRegistry{Projects: []GlobalProject{}}, nil + } + + // if does not exist return empty registry list + if _, err := os.Stat(projectsPath); os.IsNotExist(err) { + return &ProjectsRegistry{Projects: []GlobalProject{}}, nil + } + + data, err := os.ReadFile(projectsPath) + if err != nil { + return nil, fmt.Errorf("failed to read projects registry: %w", err) + } + + var registry ProjectsRegistry + if err := yaml.Unmarshal(data, ®istry); err != nil { + return nil, fmt.Errorf("failed to parse projects registry at %s: %w (check file syntax)", projectsPath, err) + } + + if registry.Projects == nil { + registry.Projects = []GlobalProject{} + } + + return ®istry, nil +} + +// Save saves the projects registry to disk +func (pr *ProjectsRegistry) Save() error { + projectsPath, err := GetProjectsPath() + if err != nil { + return err + } + + tmpoDir := filepath.Dir(projectsPath) + if err := os.MkdirAll(tmpoDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := yaml.Marshal(pr) + if err != nil { + return fmt.Errorf("failed to marshal projects registry: %w", err) + } + + if err := os.WriteFile(projectsPath, data, 0644); err != nil { + return fmt.Errorf("failed to write projects registry: %w", err) + } + + return nil +} + +// GetProject retrieves a project by name +func (pr *ProjectsRegistry) GetProject(name string) (*GlobalProject, error) { + normalizedName := strings.TrimSpace(name) + if normalizedName == "" { + return nil, fmt.Errorf("project name cannot be empty") + } + + for i := range pr.Projects { + if strings.EqualFold(pr.Projects[i].Name, normalizedName) { + return &pr.Projects[i], nil + } + } + + return nil, fmt.Errorf("project '%s' not found in global registry", name) +} + +// AddProject adds a new project to the registry +func (pr *ProjectsRegistry) AddProject(project GlobalProject) error { + normalizedName := strings.TrimSpace(project.Name) + if normalizedName == "" { + return fmt.Errorf("project name cannot be empty") + } + + // does project exist + if _, err := pr.GetProject(normalizedName); err == nil { + return fmt.Errorf("project '%s' already exists", normalizedName) + } + + // Normalize the name in the project + project.Name = normalizedName + + pr.Projects = append(pr.Projects, project) + + return nil +} + +// UpdateProject updates an existing project in the registry +func (pr *ProjectsRegistry) UpdateProject(name string, updatedProject GlobalProject) error { + normalizedName := strings.TrimSpace(name) + if normalizedName == "" { + return fmt.Errorf("project name cannot be empty") + } + + for i := range pr.Projects { + if strings.EqualFold(pr.Projects[i].Name, normalizedName) { + // preserve original name + if updatedProject.Name == "" { + updatedProject.Name = pr.Projects[i].Name + } + pr.Projects[i] = updatedProject + return nil + } + } + + return fmt.Errorf("project '%s' not found", name) +} + +// DeleteProject removes a project from the registry +func (pr *ProjectsRegistry) DeleteProject(name string) error { + normalizedName := strings.TrimSpace(name) + if normalizedName == "" { + return fmt.Errorf("project name cannot be empty") + } + + for i := range pr.Projects { + if strings.EqualFold(pr.Projects[i].Name, normalizedName) { + pr.Projects = append(pr.Projects[:i], pr.Projects[i+1:]...) + return nil + } + } + + return fmt.Errorf("project '%s' not found", name) +} + +// ListProjects returns all projects in the registry +func (pr *ProjectsRegistry) ListProjects() []GlobalProject { + return pr.Projects +} + +// Exists checks if a project exists in the registry (case-insensitive) +func (pr *ProjectsRegistry) Exists(name string) bool { + _, err := pr.GetProject(name) + return err == nil +} diff --git a/internal/settings/projects_test.go b/internal/settings/projects_test.go new file mode 100644 index 0000000..980f58b --- /dev/null +++ b/internal/settings/projects_test.go @@ -0,0 +1,447 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadProjects(t *testing.T) { + tmpDir := t.TempDir() + + // Set temporary home directory for cross-platform compatibility + t.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows + + t.Run("loads empty registry when file doesn't exist", func(t *testing.T) { + registry, err := LoadProjects() + assert.NoError(t, err) + assert.NotNil(t, registry) + assert.Empty(t, registry.Projects) + }) + + t.Run("loads valid projects registry", func(t *testing.T) { + // Create .tmpo directory + tmpoDir := filepath.Join(tmpDir, ".tmpo") + err := os.MkdirAll(tmpoDir, 0755) + assert.NoError(t, err) + + projectsPath := filepath.Join(tmpoDir, "projects.yaml") + rate1 := 100.0 + rate2 := 150.5 + content := `projects: + - name: "Project Alpha" + hourly_rate: 100.0 + description: "First project" + export_path: "/tmp/alpha" + - name: "Project Beta" + hourly_rate: 150.5 + description: "Second project" +` + err = os.WriteFile(projectsPath, []byte(content), 0644) + assert.NoError(t, err) + + registry, err := LoadProjects() + assert.NoError(t, err) + assert.NotNil(t, registry) + assert.Len(t, registry.Projects, 2) + assert.Equal(t, "Project Alpha", registry.Projects[0].Name) + assert.Equal(t, &rate1, registry.Projects[0].HourlyRate) + assert.Equal(t, "First project", registry.Projects[0].Description) + assert.Equal(t, "/tmp/alpha", registry.Projects[0].ExportPath) + assert.Equal(t, "Project Beta", registry.Projects[1].Name) + assert.Equal(t, &rate2, registry.Projects[1].HourlyRate) + }) + + t.Run("handles projects without optional fields", func(t *testing.T) { + // Create .tmpo directory + tmpoDir := filepath.Join(tmpDir, ".tmpo") + err := os.MkdirAll(tmpoDir, 0755) + assert.NoError(t, err) + + projectsPath := filepath.Join(tmpoDir, "projects.yaml") + content := `projects: + - name: "Minimal Project" +` + err = os.WriteFile(projectsPath, []byte(content), 0644) + assert.NoError(t, err) + + registry, err := LoadProjects() + assert.NoError(t, err) + assert.Len(t, registry.Projects, 1) + assert.Equal(t, "Minimal Project", registry.Projects[0].Name) + assert.Nil(t, registry.Projects[0].HourlyRate) + assert.Empty(t, registry.Projects[0].Description) + assert.Empty(t, registry.Projects[0].ExportPath) + }) + + t.Run("returns error for invalid YAML", func(t *testing.T) { + tmpoDir := filepath.Join(tmpDir, ".tmpo") + err := os.MkdirAll(tmpoDir, 0755) + assert.NoError(t, err) + + projectsPath := filepath.Join(tmpoDir, "projects.yaml") + content := `projects: [ invalid yaml +` + err = os.WriteFile(projectsPath, []byte(content), 0644) + assert.NoError(t, err) + + _, err = LoadProjects() + assert.Error(t, err) + }) +} + +func TestProjectsRegistrySave(t *testing.T) { + tmpDir := t.TempDir() + + t.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows + + t.Run("saves registry successfully", func(t *testing.T) { + rate := 125.0 + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + { + Name: "Test Project", + HourlyRate: &rate, + Description: "Test description", + ExportPath: "/tmp/test", + }, + }, + } + + err := registry.Save() + assert.NoError(t, err) + + // Verify file was created + projectsPath, _ := GetProjectsPath() + _, err = os.Stat(projectsPath) + assert.NoError(t, err) + + // Verify content can be loaded + loaded, err := LoadProjects() + assert.NoError(t, err) + assert.Len(t, loaded.Projects, 1) + assert.Equal(t, "Test Project", loaded.Projects[0].Name) + assert.Equal(t, &rate, loaded.Projects[0].HourlyRate) + assert.Equal(t, "Test description", loaded.Projects[0].Description) + assert.Equal(t, "/tmp/test", loaded.Projects[0].ExportPath) + }) + + t.Run("creates directory if it doesn't exist", func(t *testing.T) { + // Remove .tmpo directory if it exists + tmpoDir := filepath.Join(tmpDir, ".tmpo") + os.RemoveAll(tmpoDir) + + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "New Project"}, + }, + } + + err := registry.Save() + assert.NoError(t, err) + + // Verify directory was created + _, err = os.Stat(tmpoDir) + assert.NoError(t, err) + }) +} + +func TestGetProject(t *testing.T) { + rate1 := 100.0 + rate2 := 150.0 + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Project Alpha", HourlyRate: &rate1}, + {Name: "Project Beta", HourlyRate: &rate2}, + {Name: "lowercase", HourlyRate: nil}, + }, + } + + t.Run("finds project by exact name", func(t *testing.T) { + project, err := registry.GetProject("Project Alpha") + assert.NoError(t, err) + assert.NotNil(t, project) + assert.Equal(t, "Project Alpha", project.Name) + assert.Equal(t, &rate1, project.HourlyRate) + }) + + t.Run("finds project case-insensitively", func(t *testing.T) { + project, err := registry.GetProject("project beta") + assert.NoError(t, err) + assert.NotNil(t, project) + assert.Equal(t, "Project Beta", project.Name) + }) + + t.Run("finds project with different casing", func(t *testing.T) { + project, err := registry.GetProject("LOWERCASE") + assert.NoError(t, err) + assert.NotNil(t, project) + assert.Equal(t, "lowercase", project.Name) + }) + + t.Run("handles whitespace in name", func(t *testing.T) { + project, err := registry.GetProject(" Project Alpha ") + assert.NoError(t, err) + assert.NotNil(t, project) + assert.Equal(t, "Project Alpha", project.Name) + }) + + t.Run("returns error for non-existent project", func(t *testing.T) { + _, err := registry.GetProject("Non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("returns error for empty name", func(t *testing.T) { + _, err := registry.GetProject("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + }) +} + +func TestAddProject(t *testing.T) { + t.Run("adds new project successfully", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + rate := 125.0 + newProject := GlobalProject{ + Name: "New Project", + HourlyRate: &rate, + Description: "Description", + } + + err := registry.AddProject(newProject) + assert.NoError(t, err) + assert.Len(t, registry.Projects, 1) + assert.Equal(t, "New Project", registry.Projects[0].Name) + assert.Equal(t, &rate, registry.Projects[0].HourlyRate) + }) + + t.Run("normalizes project name", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + newProject := GlobalProject{Name: " Spaced Name "} + + err := registry.AddProject(newProject) + assert.NoError(t, err) + assert.Equal(t, "Spaced Name", registry.Projects[0].Name) + }) + + t.Run("returns error for duplicate project", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Existing Project"}, + }, + } + + newProject := GlobalProject{Name: "Existing Project"} + err := registry.AddProject(newProject) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) + + t.Run("detects duplicates case-insensitively", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Existing Project"}, + }, + } + + newProject := GlobalProject{Name: "existing project"} + err := registry.AddProject(newProject) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) + + t.Run("returns error for empty name", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + newProject := GlobalProject{Name: ""} + + err := registry.AddProject(newProject) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + }) + + t.Run("returns error for whitespace-only name", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + newProject := GlobalProject{Name: " "} + + err := registry.AddProject(newProject) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + }) +} + +func TestUpdateProject(t *testing.T) { + rate1 := 100.0 + rate2 := 200.0 + + t.Run("updates existing project", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Original", HourlyRate: &rate1}, + }, + } + + updatedProject := GlobalProject{ + Name: "Original", + HourlyRate: &rate2, + Description: "Updated description", + } + + err := registry.UpdateProject("Original", updatedProject) + assert.NoError(t, err) + assert.Len(t, registry.Projects, 1) + assert.Equal(t, "Original", registry.Projects[0].Name) + assert.Equal(t, &rate2, registry.Projects[0].HourlyRate) + assert.Equal(t, "Updated description", registry.Projects[0].Description) + }) + + t.Run("finds project case-insensitively", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "CamelCase", HourlyRate: &rate1}, + }, + } + + updatedProject := GlobalProject{Name: "CamelCase", HourlyRate: &rate2} + err := registry.UpdateProject("camelcase", updatedProject) + assert.NoError(t, err) + assert.Equal(t, &rate2, registry.Projects[0].HourlyRate) + }) + + t.Run("returns error for non-existent project", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + updatedProject := GlobalProject{Name: "NonExistent"} + + err := registry.UpdateProject("NonExistent", updatedProject) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("returns error for empty name", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + updatedProject := GlobalProject{Name: "Test"} + + err := registry.UpdateProject("", updatedProject) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + }) +} + +func TestDeleteProject(t *testing.T) { + rate1 := 100.0 + rate2 := 150.0 + + t.Run("deletes existing project", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Project 1", HourlyRate: &rate1}, + {Name: "Project 2", HourlyRate: &rate2}, + }, + } + + err := registry.DeleteProject("Project 1") + assert.NoError(t, err) + assert.Len(t, registry.Projects, 1) + assert.Equal(t, "Project 2", registry.Projects[0].Name) + }) + + t.Run("finds project case-insensitively", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "DeleteMe", HourlyRate: &rate1}, + }, + } + + err := registry.DeleteProject("deleteme") + assert.NoError(t, err) + assert.Empty(t, registry.Projects) + }) + + t.Run("returns error for non-existent project", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + + err := registry.DeleteProject("NonExistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("returns error for empty name", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + + err := registry.DeleteProject("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be empty") + }) +} + +func TestListProjects(t *testing.T) { + rate1 := 100.0 + rate2 := 150.0 + + t.Run("lists all projects", func(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Project 1", HourlyRate: &rate1}, + {Name: "Project 2", HourlyRate: &rate2}, + }, + } + + projects := registry.ListProjects() + assert.Len(t, projects, 2) + assert.Equal(t, "Project 1", projects[0].Name) + assert.Equal(t, "Project 2", projects[1].Name) + }) + + t.Run("returns empty list for empty registry", func(t *testing.T) { + registry := &ProjectsRegistry{Projects: []GlobalProject{}} + + projects := registry.ListProjects() + assert.Empty(t, projects) + }) +} + +func TestExists(t *testing.T) { + registry := &ProjectsRegistry{ + Projects: []GlobalProject{ + {Name: "Existing Project"}, + }, + } + + t.Run("returns true for existing project", func(t *testing.T) { + exists := registry.Exists("Existing Project") + assert.True(t, exists) + }) + + t.Run("returns true case-insensitively", func(t *testing.T) { + exists := registry.Exists("existing project") + assert.True(t, exists) + }) + + t.Run("returns false for non-existent project", func(t *testing.T) { + exists := registry.Exists("Non-existent") + assert.False(t, exists) + }) +} + +func TestGetProjectsPath(t *testing.T) { + tmpDir := t.TempDir() + + t.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows + + t.Run("returns correct path", func(t *testing.T) { + path, err := GetProjectsPath() + assert.NoError(t, err) + assert.Contains(t, path, tmpDir) + }) + + t.Run("path ends with projects.yaml", func(t *testing.T) { + path, err := GetProjectsPath() + assert.NoError(t, err) + assert.Equal(t, "projects.yaml", filepath.Base(path)) + }) +}