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
79 changes: 43 additions & 36 deletions api/v1/handlers_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v1
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion cmd/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
60 changes: 54 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"sync"

"github.com/joho/godotenv"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}