diff --git a/internal/commands/config/init.go b/internal/commands/config/init.go index de01d69..35fb25d 100644 --- a/internal/commands/config/init.go +++ b/internal/commands/config/init.go @@ -55,6 +55,7 @@ func initConfigAction(cfg *config.Config, t *i18n.Translations) cli.ActionFunc { return func(ctx context.Context, command *cli.Command) error { localCfg, useLocal, err := resolveTargetConfig(command, cfg, t) if err != nil { + ui.PrintError(os.Stdout, err.Error()) return err } diff --git a/internal/commands/config/set.go b/internal/commands/config/set.go index 4c9dd0a..435646c 100644 --- a/internal/commands/config/set.go +++ b/internal/commands/config/set.go @@ -41,6 +41,7 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C targetCfg, useLocal, err := resolveTargetConfig(command, cfg, t) if err != nil { + ui.PrintError(os.Stdout, err.Error()) return err } @@ -49,18 +50,24 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C if isValidLanguage(value) { targetCfg.Language = value } else { - return fmt.Errorf("invalid language: %s", value) + err := fmt.Errorf("invalid language: %s", value) + ui.PrintError(os.Stdout, err.Error()) + return err } case "emoji", "use_emoji": - boolVal, err := strconv.ParseBool(value) - if err != nil { - return fmt.Errorf("invalid boolean value: %s", value) + boolVal, parseErr := strconv.ParseBool(value) + if parseErr != nil { + err := fmt.Errorf("invalid boolean value: %s", value) + ui.PrintError(os.Stdout, err.Error()) + return err } targetCfg.UseEmoji = boolVal case "count", "suggestions_count": - intVal, err := strconv.Atoi(value) - if err != nil || intVal < 1 || intVal > 10 { - return fmt.Errorf("invalid count (must be 1-10): %s", value) + intVal, parseErr := strconv.Atoi(value) + if parseErr != nil || intVal < 1 || intVal > 10 { + err := fmt.Errorf("invalid count (must be 1-10): %s", value) + ui.PrintError(os.Stdout, err.Error()) + return err } targetCfg.SuggestionsCount = intVal case "active-ai", "active_ai": @@ -72,7 +79,9 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C } targetCfg.AIConfig.Models[targetCfg.AIConfig.ActiveAI] = config.Model(value) } else { - return fmt.Errorf("no active AI provider configured") + err := fmt.Errorf("no active AI provider configured") + ui.PrintError(os.Stdout, err.Error()) + return err } case "active-vcs", "active_vcs": targetCfg.ActiveVCSProvider = value @@ -81,7 +90,9 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C case "git.email", "git-email": targetCfg.GitFallback.UserEmail = value default: - return fmt.Errorf("unknown configuration key: %s", key) + err := fmt.Errorf("unknown configuration key: %s", key) + ui.PrintError(os.Stdout, err.Error()) + return err } if useLocal { diff --git a/internal/commands/issues/templates.go b/internal/commands/issues/templates.go index 4052d8e..96049d3 100644 --- a/internal/commands/issues/templates.go +++ b/internal/commands/issues/templates.go @@ -37,7 +37,7 @@ func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *confi ui.PrintInfo(t.GetMessage("issue.template_init_info", 0, nil)) if err := templateService.InitializeTemplates(ctx, force); err != nil { - ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.template_init_error", 0, nil), err)) + ui.HandleAppError(err, t) return err } @@ -54,7 +54,7 @@ func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *confi Action: func(ctx context.Context, cmd *cli.Command) error { templates, err := templateService.ListTemplates(ctx) if err != nil { - ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.template_list_error", 0, nil), err)) + ui.HandleAppError(err, t) return err } diff --git a/internal/commands/stats/stats.go b/internal/commands/stats/stats.go index fc152c4..a0df724 100644 --- a/internal/commands/stats/stats.go +++ b/internal/commands/stats/stats.go @@ -123,7 +123,7 @@ func (c *StatsCommand) showDailyStats(manager *cost.Manager, t *i18n.Translation fmt.Println() fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") _, _ = cyan.Printf("%s: ", t.GetMessage("stats.total_today", 0, nil)) - _, _ = yellow.Println(t.GetMessage("stats.total_today_value", 0, struct{ Total float64 }{total})) + _, _ = yellow.Println(t.GetMessage("stats.total_today_value", 0, struct{ Total string }{fmt.Sprintf("%.4f", total)})) fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println() @@ -203,7 +203,7 @@ func (c *StatsCommand) showMonthlyStats(manager *cost.Manager, t *i18n.Translati fmt.Println() fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") _, _ = cyan.Printf("%s: ", t.GetMessage("stats.total_month", 0, nil)) - _, _ = yellow.Println(t.GetMessage("stats.total_month_value", 0, struct{ Total float64 }{total})) + _, _ = yellow.Println(t.GetMessage("stats.total_month_value", 0, struct{ Total string }{fmt.Sprintf("%.4f", total)})) daysWithActivity := len(dailyTotals) if daysWithActivity > 0 { @@ -221,8 +221,8 @@ func (c *StatsCommand) showMonthlyStats(manager *cost.Manager, t *i18n.Translati Current int Total int }{forecast.DaysElapsed, forecast.DaysInMonth})) - _, _ = dim.Printf(" %s\n", t.GetMessage("stats.forecast_daily_avg_label", 0, struct{ Avg float64 }{forecast.DailyAverage})) - _, _ = yellow.Printf(" %s\n", t.GetMessage("stats.forecast_projected_label", 0, struct{ Amount float64 }{forecast.ProjectedMonthEnd})) + _, _ = dim.Printf(" %s\n", t.GetMessage("stats.forecast_daily_avg_label", 0, struct{ Avg string }{fmt.Sprintf("%.4f", forecast.DailyAverage)})) + _, _ = yellow.Printf(" %s\n", t.GetMessage("stats.forecast_projected_label", 0, struct{ Amount string }{fmt.Sprintf("%.4f", forecast.ProjectedMonthEnd)})) fmt.Println() } } @@ -230,9 +230,9 @@ func (c *StatsCommand) showMonthlyStats(manager *cost.Manager, t *i18n.Translati hitRate, saved, err := manager.GetCacheStats() if err == nil && hitRate > 0 { _, _ = green.Println(t.GetMessage("stats.cache_hit_rate_label", 0, struct { - Rate float64 - Saved float64 - }{hitRate, saved})) + Rate string + Saved string + }{fmt.Sprintf("%.1f", hitRate), fmt.Sprintf("%.4f", saved)})) fmt.Println() } @@ -329,7 +329,7 @@ func (c *StatsCommand) showBreakdown(manager *cost.Manager, t *i18n.Translations for _, stat := range breakdown.ByCommand { if stat.Command == "suggest" && stat.CallCount > 0 { - _, _ = green.Println(t.GetMessage("stats.avg_cost_per_commit_label", 0, struct{ Cost float64 }{stat.AvgCost})) + _, _ = green.Println(t.GetMessage("stats.avg_cost_per_commit_label", 0, struct{ Cost string }{fmt.Sprintf("%.4f", stat.AvgCost)})) fmt.Println() break } diff --git a/internal/commands/stats/stats_test.go b/internal/commands/stats/stats_test.go index c5a9cb0..f2b5b51 100644 --- a/internal/commands/stats/stats_test.go +++ b/internal/commands/stats/stats_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/fatih/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/thomas-vilte/matecommit/internal/cost" @@ -122,6 +123,44 @@ func TestShowDailyStats_WithActivity(t *testing.T) { assert.Equal(t, 0.0100, total, "the calculated total should be 0.0100") } +func TestShowDailyStats_TotalIsFormattedCleanly(t *testing.T) { + // 0.1 + 0.2 is the classic IEEE-754 case that doesn't sum to a clean + // decimal (0.30000000000000004) — passing the raw float straight into + // the i18n template (instead of pre-formatting it) leaks that noise + // into the "Total Today" line shown to the user. + tempDir := t.TempDir() + now := time.Now() + + records := []cost.ActivityRecord{ + {Timestamp: now, Command: "suggest", CostUSD: 0.1}, + {Timestamp: now, Command: "summarize-pr", CostUSD: 0.2}, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + oldColorOutput := color.Output + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w // fatih/color caches os.Stdout at package init, so cyan/yellow.Print* calls need the redirect spelled out separately + + err := cmd.showDailyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + color.Output = oldColorOutput + _, _ = io.Copy(&buf, r) + + outputStr := buf.String() + + assert.NoError(t, err) + assert.Contains(t, outputStr, "$0.3000 USD", "the total should be rounded to 4 decimals, not the raw float") + assert.NotContains(t, outputStr, "0.30000000000000004", "raw float noise must not leak into the output") +} + func TestShowDailyStats_WithCacheHits(t *testing.T) { // Arrange tempDir := t.TempDir() @@ -268,6 +307,42 @@ func TestShowMonthlyStats_WithActivity(t *testing.T) { assert.Equal(t, 0.0050, total, "the monthly total should be 0.0050") } +func TestShowMonthlyStats_TotalIsFormattedCleanly(t *testing.T) { + // Same IEEE-754 case as TestShowDailyStats_TotalIsFormattedCleanly, + // but for the "Total This Month" / "Average per day" lines. + tempDir := t.TempDir() + now := time.Now() + + records := []cost.ActivityRecord{ + {Timestamp: time.Date(now.Year(), now.Month(), 1, 10, 0, 0, 0, time.Local), Command: "suggest", CostUSD: 0.1}, + {Timestamp: time.Date(now.Year(), now.Month(), 2, 10, 0, 0, 0, time.Local), Command: "suggest", CostUSD: 0.2}, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + oldColorOutput := color.Output + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w // fatih/color caches os.Stdout at package init, so cyan/yellow.Print* calls need the redirect spelled out separately + + err := cmd.showMonthlyStats(manager, trans, false) + + _ = w.Close() + os.Stdout = oldStdout + color.Output = oldColorOutput + _, _ = io.Copy(&buf, r) + + outputStr := buf.String() + + assert.NoError(t, err) + assert.Contains(t, outputStr, "$0.3000 USD", "the monthly total should be rounded to 4 decimals, not the raw float") + assert.NotContains(t, outputStr, "0.30000000000000004", "raw float noise must not leak into the output") +} + func TestShowMonthlyStats_GroupsByDay(t *testing.T) { // Arrange tempDir := t.TempDir() diff --git a/internal/services/issue_template_service.go b/internal/services/issue_template_service.go index cbd93be..294ba66 100644 --- a/internal/services/issue_template_service.go +++ b/internal/services/issue_template_service.go @@ -211,7 +211,8 @@ func (s *IssueTemplateService) InitializeTemplates(ctx context.Context, force bo logger.Info(ctx, "template initialization complete", "created", created, "skipped", skipped) if created == 0 && skipped > 0 { - return domainErrors.NewAppError(domainErrors.TypeConfiguration, "templates_already_exist", nil) + return domainErrors.NewAppError(domainErrors.TypeConfiguration, "issue templates already exist", nil). + WithSuggestion("Use --force to overwrite the existing templates") } return nil } diff --git a/internal/services/issue_template_service_test.go b/internal/services/issue_template_service_test.go index ac108f9..93f64b7 100644 --- a/internal/services/issue_template_service_test.go +++ b/internal/services/issue_template_service_test.go @@ -137,7 +137,7 @@ func TestIssueTemplateService_FilesystemOps(t *testing.T) { t.Run("InitializeTemplates - Already exists", func(t *testing.T) { err := service.InitializeTemplates(context.Background(), false) assert.Error(t, err) - assert.Contains(t, err.Error(), "CONFIGURATION: templates_already_exist") + assert.Contains(t, err.Error(), "CONFIGURATION: issue templates already exist") err = service.InitializeTemplates(context.Background(), true) assert.NoError(t, err)