Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/commands/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
29 changes: 20 additions & 9 deletions internal/commands/config/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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":
Expand All @@ -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
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/issues/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
16 changes: 8 additions & 8 deletions internal/commands/stats/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand All @@ -221,18 +221,18 @@ 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()
}
}

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()
}

Expand Down Expand Up @@ -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
}
Expand Down
75 changes: 75 additions & 0 deletions internal/commands/stats/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion internal/services/issue_template_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/services/issue_template_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading