From 89448b874a9067c21e2dd907c73d2fd2b09e5ca5 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Fri, 30 Jan 2026 13:54:10 -0800 Subject: [PATCH 1/6] Add global project registry and support for global projects Introduces a global projects registry in internal/settings/projects.go with full CRUD operations and tests. Updates the setup init command to support a --global flag for creating global projects, and refactors project detection logic to prioritize explicit project selection, .tmporc, and directory-based detection. Adds integration and unit tests for new behaviors and project config retrieval. --- cmd/setup/init.go | 292 +++++++++++------- cmd/setup/init_test.go | 147 +++++++++ internal/project/detect.go | 48 +++ internal/project/detect_test.go | 205 +++++++++++++ internal/settings/projects.go | 176 +++++++++++ internal/settings/projects_test.go | 461 +++++++++++++++++++++++++++++ 6 files changed, 1215 insertions(+), 114 deletions(-) create mode 100644 internal/settings/projects.go create mode 100644 internal/settings/projects_test.go diff --git a/cmd/setup/init.go b/cmd/setup/init.go index 0806f69..07760a8 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -16,135 +16,200 @@ 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. Use --global to create a global project that can be tracked from any 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() - os.Exit(1) + if globalProject { + initGlobalProject() + } else { + initLocalProject() } - defaultName := detectDefaultProjectName() + ui.NewlineBelow() + }, + } - var name string - var hourlyRate float64 - var description string - var exportPath string + 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") - 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, - } - - nameInput, err := namePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - name = strings.TrimSpace(nameInput) - if name == "" { - name = defaultName - } - - // Hourly Rate prompt - ratePrompt := promptui.Prompt{ - Label: "Hourly rate (press Enter to skip)", - Validate: validateHourlyRate, - } - - rateInput, err := ratePrompt.Run() - 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) - } - } - - // Description prompt - descPrompt := promptui.Prompt{ - Label: "Description (press Enter to skip)", - } - - 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 cmd +} - // 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) - } +func initLocalProject() { + if _, err := os.Stat(".tmporc"); err == nil { + ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") + os.Exit(1) + } - 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) - } + defaultName := detectDefaultProjectName() + name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Initialize Project Configuration") + + // 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) + } - 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.") + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) + printProjectDetails(hourlyRate, description, exportPath) - ui.NewlineBelow() - }, + 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.") +} + +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) } - cmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") + defaultName := detectDefaultProjectName() + name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Initialize Global Project") - return cmd + 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 + } + + ui.PrintSuccess(ui.EmojiInit, title) + fmt.Println() + + // project Name prompt + namePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Project name (%s)", defaultName), + AllowEdit: true, + } + + nameInput, err := namePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + name = strings.TrimSpace(nameInput) + if name == "" { + name = defaultName + } + + // hourly Rate prompt + ratePrompt := promptui.Prompt{ + Label: "Hourly rate (press Enter to skip)", + Validate: validateHourlyRate, + } + + rateInput, err := ratePrompt.Run() + 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) + } + } + + // description prompt + descPrompt := promptui.Prompt{ + Label: "Description (press Enter to skip)", + } + + 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 +236,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 +254,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..ff7d112 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,149 @@ 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() + originalHome := os.Getenv("HOME") + originalAcceptDefaults := acceptDefaults + originalGlobalProject := globalProject + defer func() { + os.Setenv("HOME", originalHome) + acceptDefaults = originalAcceptDefaults + globalProject = originalGlobalProject + }() + + os.Setenv("HOME", tmpDir) + os.Setenv("TMPO_DEV", "1") + acceptDefaults = true + globalProject = true + + t.Run("creates global project with defaults", func(t *testing.T) { + // Change to a test directory + projectDir := filepath.Join(tmpDir, "test-project") + err := os.MkdirAll(projectDir, 0755) + require.NoError(t, err) + + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(projectDir) + require.NoError(t, err) + + // Run init + assert.NotPanics(t, func() { + initGlobalProject() + }) + + // Verify project was added to registry + registry, err := settings.LoadProjects() + require.NoError(t, err) + assert.True(t, registry.Exists("test-project")) + + // Verify project details + project, err := registry.GetProject("test-project") + require.NoError(t, err) + assert.Equal(t, "test-project", project.Name) + assert.Nil(t, project.HourlyRate) + assert.Empty(t, project.Description) + }) +} + +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/internal/project/detect.go b/internal/project/detect.go index 530ef4a..fb8c032 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -33,12 +33,33 @@ 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) { + return "", fmt.Errorf("project '%s' not found in global registry", 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 +114,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..ed08f9e --- /dev/null +++ b/internal/settings/projects_test.go @@ -0,0 +1,461 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadProjects(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + // Set temporary home directory + os.Setenv("HOME", tmpDir) + os.Setenv("TMPO_DEV", "1") // Use dev mode to avoid interfering with real data + + 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-dev directory + tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + 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-dev directory + tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + 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-dev") + 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() + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + os.Setenv("HOME", tmpDir) + os.Setenv("TMPO_DEV", "1") + + 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-dev directory if it exists + tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + 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() + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + os.Setenv("HOME", tmpDir) + + t.Run("uses .tmpo directory in normal mode", func(t *testing.T) { + os.Setenv("TMPO_DEV", "0") + path, err := GetProjectsPath() + assert.NoError(t, err) + assert.Contains(t, path, ".tmpo") + assert.NotContains(t, path, ".tmpo-dev") + }) + + t.Run("uses .tmpo-dev directory in dev mode", func(t *testing.T) { + os.Setenv("TMPO_DEV", "1") + path, err := GetProjectsPath() + assert.NoError(t, err) + assert.Contains(t, path, ".tmpo-dev") + }) + + 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)) + }) +} From 6e38aafa39d3102fa2e3adab9cf58ffbcddc965b Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Fri, 30 Jan 2026 14:12:26 -0800 Subject: [PATCH 2/6] Add --project flag to entry and tracking commands Introduces a --project (-p) flag to edit, manual, resume, and start commands, allowing users to specify a global project explicitly. Updates project detection logic to use the override when provided, and adjusts config source messaging accordingly. Also updates history export and log commands to respect the project flag when filtering by milestone. --- cmd/entries/edit.go | 10 +++++++--- cmd/entries/manual.go | 17 +++++++++-------- cmd/history/export.go | 13 +++++++++---- cmd/history/log.go | 23 ++++++++++++++--------- cmd/tracking/resume.go | 8 +++++++- cmd/tracking/start.go | 18 ++++++++++++++---- 6 files changed, 60 insertions(+), 29 deletions(-) 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/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 } From d5e6d873592ec5d985403320634c21656cb85bb2 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Fri, 30 Jan 2026 14:27:42 -0800 Subject: [PATCH 3/6] docs: updated docs to reflect new feature --- CONTRIBUTING.md | 17 ++- README.md | 32 ++++- docs/configuration.md | 291 +++++++++++++++++++++++++++++++++++++++--- docs/usage.md | 145 +++++++++++++++++---- 4 files changed, 438 insertions(+), 47 deletions(-) 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/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: From aface034ebe5be79fac5e5d1334a0a8efa79628a Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Fri, 30 Jan 2026 14:38:56 -0800 Subject: [PATCH 4/6] add warning for user clarity --- internal/project/detect.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/project/detect.go b/internal/project/detect.go index fb8c032..786cd4b 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -46,7 +46,13 @@ func DetectConfiguredProjectWithOverride(explicitProject string) (string, error) } if !registry.Exists(explicitProject) { - return "", fmt.Errorf("project '%s' not found in global registry", 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 From a377a033c0d59f39dc8bb578f567b3a16fb6ea96 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Fri, 30 Jan 2026 14:51:05 -0800 Subject: [PATCH 5/6] Refactor tests for cross-platform compatibility Updated environment variable setup in tests to use t.Setenv for both HOME and USERPROFILE, ensuring compatibility across Unix/macOS and Windows. Standardized test directories to use .tmpo instead of .tmpo-dev and removed dev mode logic from tests. --- internal/settings/projects_test.go | 46 +++++++++++------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/internal/settings/projects_test.go b/internal/settings/projects_test.go index ed08f9e..980f58b 100644 --- a/internal/settings/projects_test.go +++ b/internal/settings/projects_test.go @@ -10,12 +10,10 @@ import ( func TestLoadProjects(t *testing.T) { tmpDir := t.TempDir() - originalHome := os.Getenv("HOME") - defer os.Setenv("HOME", originalHome) - // Set temporary home directory - os.Setenv("HOME", tmpDir) - os.Setenv("TMPO_DEV", "1") // Use dev mode to avoid interfering with real data + // 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() @@ -25,8 +23,8 @@ func TestLoadProjects(t *testing.T) { }) t.Run("loads valid projects registry", func(t *testing.T) { - // Create .tmpo-dev directory - tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + // Create .tmpo directory + tmpoDir := filepath.Join(tmpDir, ".tmpo") err := os.MkdirAll(tmpoDir, 0755) assert.NoError(t, err) @@ -58,8 +56,8 @@ func TestLoadProjects(t *testing.T) { }) t.Run("handles projects without optional fields", func(t *testing.T) { - // Create .tmpo-dev directory - tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + // Create .tmpo directory + tmpoDir := filepath.Join(tmpDir, ".tmpo") err := os.MkdirAll(tmpoDir, 0755) assert.NoError(t, err) @@ -80,7 +78,7 @@ func TestLoadProjects(t *testing.T) { }) t.Run("returns error for invalid YAML", func(t *testing.T) { - tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + tmpoDir := filepath.Join(tmpDir, ".tmpo") err := os.MkdirAll(tmpoDir, 0755) assert.NoError(t, err) @@ -97,11 +95,9 @@ func TestLoadProjects(t *testing.T) { func TestProjectsRegistrySave(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.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows t.Run("saves registry successfully", func(t *testing.T) { rate := 125.0 @@ -135,8 +131,8 @@ func TestProjectsRegistrySave(t *testing.T) { }) t.Run("creates directory if it doesn't exist", func(t *testing.T) { - // Remove .tmpo-dev directory if it exists - tmpoDir := filepath.Join(tmpDir, ".tmpo-dev") + // Remove .tmpo directory if it exists + tmpoDir := filepath.Join(tmpDir, ".tmpo") os.RemoveAll(tmpoDir) registry := &ProjectsRegistry{ @@ -433,24 +429,14 @@ func TestExists(t *testing.T) { func TestGetProjectsPath(t *testing.T) { tmpDir := t.TempDir() - originalHome := os.Getenv("HOME") - defer os.Setenv("HOME", originalHome) - os.Setenv("HOME", tmpDir) + t.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows - t.Run("uses .tmpo directory in normal mode", func(t *testing.T) { - os.Setenv("TMPO_DEV", "0") + t.Run("returns correct path", func(t *testing.T) { path, err := GetProjectsPath() assert.NoError(t, err) - assert.Contains(t, path, ".tmpo") - assert.NotContains(t, path, ".tmpo-dev") - }) - - t.Run("uses .tmpo-dev directory in dev mode", func(t *testing.T) { - os.Setenv("TMPO_DEV", "1") - path, err := GetProjectsPath() - assert.NoError(t, err) - assert.Contains(t, path, ".tmpo-dev") + assert.Contains(t, path, tmpDir) }) t.Run("path ends with projects.yaml", func(t *testing.T) { From a89007375c591f07187bcfd9b0e9a8f84e57a2b5 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sat, 31 Jan 2026 13:58:48 -0800 Subject: [PATCH 6/6] Enforce explicit config for global project init Disallows using --accept-defaults with --global to require explicit configuration for global projects. Updates prompts to require a project name for global projects and adjusts tests to directly verify registry operations instead of interactive input. --- cmd/setup/init.go | 37 ++++++++++++++++++++++------- cmd/setup/init_test.go | 53 +++++++++++++++++++----------------------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/cmd/setup/init.go b/cmd/setup/init.go index 07760a8..4f45edb 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -23,10 +23,16 @@ func InitCmd() *cobra.Command { cmd := &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. Use --global to create a global project that can be tracked from any directory.`, + 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() + // 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) + } + if globalProject { initGlobalProject() } else { @@ -75,8 +81,8 @@ func initGlobalProject() { os.Exit(1) } - defaultName := detectDefaultProjectName() - name, hourlyRate, description, exportPath := getProjectDetails(defaultName, "Initialize Global Project") + // global projects require project name type in + name, hourlyRate, description, exportPath := getProjectDetails("", "Initialize Global Project") if registry.Exists(name) { ui.PrintError(ui.EmojiError, fmt.Sprintf("global project '%s' already exists", name)) @@ -131,10 +137,25 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float ui.PrintSuccess(ui.EmojiInit, title) fmt.Println() - // project Name prompt - namePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Project name (%s)", defaultName), - AllowEdit: true, + // 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 + }, + } } nameInput, err := namePrompt.Run() @@ -144,7 +165,7 @@ func getProjectDetails(defaultName, title string) (name string, hourlyRate float } name = strings.TrimSpace(nameInput) - if name == "" { + if name == "" && defaultName != "" { name = defaultName } diff --git a/cmd/setup/init_test.go b/cmd/setup/init_test.go index ff7d112..c0a8f72 100644 --- a/cmd/setup/init_test.go +++ b/cmd/setup/init_test.go @@ -161,49 +161,44 @@ func TestPrintProjectDetails(t *testing.T) { func TestInitGlobalProject_Integration(t *testing.T) { // Set up test environment tmpDir := t.TempDir() - originalHome := os.Getenv("HOME") - originalAcceptDefaults := acceptDefaults - originalGlobalProject := globalProject - defer func() { - os.Setenv("HOME", originalHome) - acceptDefaults = originalAcceptDefaults - globalProject = originalGlobalProject - }() - os.Setenv("HOME", tmpDir) - os.Setenv("TMPO_DEV", "1") - acceptDefaults = true - globalProject = true + t.Setenv("HOME", tmpDir) // Unix/macOS + t.Setenv("USERPROFILE", tmpDir) // Windows + t.Setenv("TMPO_DEV", "1") - t.Run("creates global project with defaults", func(t *testing.T) { - // Change to a test directory - projectDir := filepath.Join(tmpDir, "test-project") - err := os.MkdirAll(projectDir, 0755) + 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) - origDir, err := os.Getwd() - require.NoError(t, err) - defer os.Chdir(origDir) + // Add a test project + rate := 100.0 + newProject := settings.GlobalProject{ + Name: "test-project", + HourlyRate: &rate, + Description: "Test description", + ExportPath: "/tmp/test", + } - err = os.Chdir(projectDir) + err = registry.AddProject(newProject) require.NoError(t, err) - // Run init - assert.NotPanics(t, func() { - initGlobalProject() - }) + err = registry.Save() + require.NoError(t, err) // Verify project was added to registry - registry, err := settings.LoadProjects() + reloadedRegistry, err := settings.LoadProjects() require.NoError(t, err) - assert.True(t, registry.Exists("test-project")) + assert.True(t, reloadedRegistry.Exists("test-project")) // Verify project details - project, err := registry.GetProject("test-project") + project, err := reloadedRegistry.GetProject("test-project") require.NoError(t, err) assert.Equal(t, "test-project", project.Name) - assert.Nil(t, project.HourlyRate) - assert.Empty(t, project.Description) + assert.Equal(t, &rate, project.HourlyRate) + assert.Equal(t, "Test description", project.Description) + assert.Equal(t, "/tmp/test", project.ExportPath) }) }