diff --git a/api/v1/handlers_impl.go b/api/v1/handlers_impl.go index d772f5f..98c42c9 100644 --- a/api/v1/handlers_impl.go +++ b/api/v1/handlers_impl.go @@ -3,6 +3,7 @@ package v1 import ( "encoding/json" "errors" + "fmt" "html/template" "io" "net/http" @@ -259,9 +260,9 @@ func (mlh *MindloopHandler) HandleHabitView(w http.ResponseWriter, r *http.Reque momentum, _ := mlh.habit.CalculateMomentum(h) mlh.renderTemplate(w, "habit_view.html", map[string]interface{}{ - "Title": "Habit: " + h.Title, - "Habit": h, - "Heatmap": heatmap, + "Title": "Habit: " + h.Title, + "Habit": h, + "Heatmap": heatmap, "Momentum": momentum, }) } @@ -820,16 +821,16 @@ func (mlh *MindloopHandler) HandleCleanSlate(w http.ResponseWriter, r *http.Requ log.Error().Msg("Error in clean slate all") } else { // Also reset user config (Name and FeatureFlags), but keep DB config - uc := config.UserConfig{} - if readErr := uc.ReadFromYAML(); readErr == nil { + if configErr := config.UpdateUserConfig(func(uc *config.UserConfig) error { uc.Name = "" uc.FeatureFlags = config.FeatureFlags{} // Reset all flags to false - uc.WriteToYAML() - + return nil + }); configErr != nil { + err = fmt.Errorf("failed to reset user config: %w", configErr) + log.Error().Err(configErr).Msg("Error resetting user config") + } else if mlh.config != nil { // Update in-memory config - if mlh.config != nil { - mlh.config.UserName = "" - } + mlh.config.UserName = "" } } case "journal": @@ -1148,19 +1149,20 @@ func (mlh *MindloopHandler) HandleSettingsUpdate(w http.ResponseWriter, r *http. ptsSubTask, _ := strconv.Atoi(r.FormValue("pts_subtask")) ptsMilestoneInterval, _ := strconv.Atoi(r.FormValue("pts_milestone_interval")) - uc := config.UserConfig{ - Name: name, - Mode: mode, - EditorWideWidth: r.FormValue("editor_wide_width") == "on", - FeatureFlags: config.FeatureFlags{ + if err := config.UpdateUserConfig(func(uc *config.UserConfig) error { + uc.Name = name + uc.Mode = mode + // EditorWideWidth is intentionally preserved: it is updated by its own + // endpoint and is not part of this form. + uc.FeatureFlags = config.FeatureFlags{ FocusCloud: r.FormValue("focus_cloud") == "on", HabitCloud: r.FormValue("habit_cloud") == "on", IntentCloud: r.FormValue("intent_cloud") == "on", JournalCloud: r.FormValue("journal_cloud") == "on", NoteCloud: r.FormValue("note_cloud") == "on", Gamification: r.FormValue("gamification") == "on", - }, - PointsConfig: config.PointsConfig{ + } + uc.PointsConfig = config.PointsConfig{ Focus: ptsFocus, Habit: ptsHabit, Intent: ptsIntent, @@ -1169,23 +1171,24 @@ func (mlh *MindloopHandler) HandleSettingsUpdate(w http.ResponseWriter, r *http. Task: ptsTask, SubTask: ptsSubTask, MilestoneInterval: ptsMilestoneInterval, - }, - } - - if mode == "byodb" { - uc.DbConfig = config.DBConfig{ - Host: r.FormValue("db_host"), - Port: r.FormValue("db_port"), - User: r.FormValue("db_user"), - Password: r.FormValue("db_pass"), - Name: r.FormValue("db_name"), } + if mode == "byodb" { + uc.DbConfig = config.DBConfig{ + Host: r.FormValue("db_host"), + Port: r.FormValue("db_port"), + User: r.FormValue("db_user"), + Password: r.FormValue("db_pass"), + Name: r.FormValue("db_name"), + } + } + uc.PointsConfig.MilestoneInterval = points.NormalizeMilestoneInterval(uc.PointsConfig.MilestoneInterval) + points.SetMilestoneInterval(uc.PointsConfig.MilestoneInterval) + return nil + }); err != nil { + log.Error().Err(err).Msg("Error writing user settings") + http.Error(w, "failed to write user settings", http.StatusInternalServerError) + return } - - uc.PointsConfig.MilestoneInterval = points.NormalizeMilestoneInterval(uc.PointsConfig.MilestoneInterval) - uc.WriteToYAML() - points.SetMilestoneInterval(uc.PointsConfig.MilestoneInterval) - // Update in-memory config to reflect changes immediately if mlh.config != nil { mlh.config.UserName = name @@ -1203,10 +1206,14 @@ func (mlh *MindloopHandler) HandleSettingsUpdateWidth(w http.ResponseWriter, r * isWide := r.FormValue("wide") == "true" - uc := config.UserConfig{} - _ = uc.ReadFromYAML() - uc.EditorWideWidth = isWide - uc.WriteToYAML() + if err := config.UpdateUserConfig(func(uc *config.UserConfig) error { + uc.EditorWideWidth = isWide + return nil + }); err != nil { + log.Error().Err(err).Msg("Error writing editor width setting") + http.Error(w, "failed to write editor width setting", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]string{"status": "success"}); err != nil { diff --git a/cmd/cli/configure.go b/cmd/cli/configure.go index fab3e6d..88a5b53 100644 --- a/cmd/cli/configure.go +++ b/cmd/cli/configure.go @@ -179,7 +179,10 @@ func CreateUserConfigYAML(username, mode string, dbConfig *config.DBConfig, mile } uc.SetDefaults() - uc.WriteToYAML() + if err := uc.WriteToYAMLError(); err != nil { + utils.PrintErrorln("Error writing user config to YAML") + return + } utils.PrintSuccessln("User config created successfully!") utils.PrintInfof("You can find your config at: %s\n", config.GetUserConfigPath()) } diff --git a/internal/config/config.go b/internal/config/config.go index f854e55..3f5b75e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "fmt" "os" + "path/filepath" "sync" "github.com/joho/godotenv" @@ -73,6 +74,7 @@ type DBConfig struct { var once sync.Once var cfg *Config +var userConfigMu sync.Mutex // InitConfig initializes the global application configuration func InitConfig(name, mode, port string) { @@ -206,17 +208,63 @@ func ValidateUserConfig(cmd *cobra.Command) { // WriteToYAML persists the current UserConfig to a YAML file func (uc UserConfig) WriteToYAML() { + if err := uc.WriteToYAMLError(); err != nil { + utils.PrintErrorln("Error writing user config to YAML") + return + } + utils.PrintSuccessln("User config written to YAML successfully") +} + +// WriteToYAMLError persists the current UserConfig with an atomic replacement. +func (uc UserConfig) WriteToYAMLError() error { marshalled, err := yaml.Marshal(uc) if err != nil { - utils.PrintErrorln("Error marshalling user config to YAML") - return + return fmt.Errorf("marshal user config: %w", err) } - err = os.WriteFile(GetUserConfigPath(), marshalled, 0644) + + path := GetUserConfigPath() + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".user_config.yaml.tmp-*") if err != nil { - utils.PrintErrorln("Error writing user config to file") - return + return fmt.Errorf("create temporary user config: %w", err) } - utils.PrintSuccessln("User config written to YAML successfully") + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if err := tmp.Chmod(0644); err != nil { + _ = tmp.Close() + return fmt.Errorf("set temporary user config permissions: %w", err) + } + if _, err := tmp.Write(marshalled); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temporary user config: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temporary user config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary user config: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replace user config: %w", err) + } + return nil +} + +// UpdateUserConfig serializes read-modify-write updates to the user config. +func UpdateUserConfig(mutate func(*UserConfig) error) error { + userConfigMu.Lock() + defer userConfigMu.Unlock() + + uc := UserConfig{} + if err := uc.ReadFromYAML(); err != nil && !os.IsNotExist(err) { + return err + } + if err := mutate(&uc); err != nil { + return err + } + return uc.WriteToYAMLError() } // ReadFromYAML loads the UserConfig from a YAML file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8a4d510 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "path/filepath" + "sync" + "testing" +) + +func TestWriteToYAMLErrorRoundTripIsAtomic(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Chdir(t.TempDir()) + + want := UserConfig{ + Name: "atomic-user", + Mode: "local", + EditorWideWidth: true, + PointsConfig: PointsConfig{Task: 7}, + } + if err := want.WriteToYAMLError(); err != nil { + t.Fatalf("write user config: %v", err) + } + + var got UserConfig + if err := got.ReadFromYAML(); err != nil { + t.Fatalf("read user config: %v", err) + } + if got.Name != want.Name || got.Mode != want.Mode || !got.EditorWideWidth || got.PointsConfig.Task != want.PointsConfig.Task { + t.Fatalf("round trip mismatch: got %+v, want %+v", got, want) + } + if matches, err := filepath.Glob(".user_config.yaml.tmp-*"); err != nil { + t.Fatalf("find temporary files: %v", err) + } else if len(matches) != 0 { + t.Fatalf("temporary files remain after atomic write: %v", matches) + } +} + +func TestUpdateUserConfigSerializesReadModifyWrite(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Chdir(t.TempDir()) + if err := (UserConfig{Mode: "local"}).WriteToYAMLError(); err != nil { + t.Fatalf("seed user config: %v", err) + } + var baseline UserConfig + if err := baseline.ReadFromYAML(); err != nil { + t.Fatalf("read seeded user config: %v", err) + } + + const updates = 32 + var wg sync.WaitGroup + errs := make(chan error, updates) + for range updates { + wg.Add(1) + go func() { + defer wg.Done() + errs <- UpdateUserConfig(func(uc *UserConfig) error { + uc.PointsConfig.Task++ + return nil + }) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("update user config: %v", err) + } + } + + var got UserConfig + if err := got.ReadFromYAML(); err != nil { + t.Fatalf("read final user config: %v", err) + } + wantTaskPoints := baseline.PointsConfig.Task + updates + if got.PointsConfig.Task != wantTaskPoints { + t.Fatalf("lost concurrent updates: got task points %d, want %d", got.PointsConfig.Task, wantTaskPoints) + } +}