1package config
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "path/filepath"
8 "regexp"
9 "sort"
10 "strings"
11
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/pelletier/go-toml/v2"
14)
15
16// WriteConfigOrdered writes the configuration to disk with consistent ordering.
17// Uses atomic write (temp file + rename) to prevent race conditions with file watchers.
18func WriteConfigOrdered(cfg *Config, path string) error {
19 if cfg == nil {
20 return fmt.Errorf("config is nil")
21 }
22
23 content, err := encodeConfigToTOML(cfg)
24 if err != nil {
25 return err
26 }
27
28 return atomicWriteFile(path, content, filePerm)
29}
30
31// encodeConfigToTOML encodes the config struct to sorted TOML content.
32func encodeConfigToTOML(cfg *Config) (string, error) {
33 var buf bytes.Buffer
34 enc := toml.NewEncoder(&buf)
35 enc.SetIndentTables(true)
36
37 if err := enc.Encode(cfg); err != nil {
38 return "", fmt.Errorf("failed to encode config: %w", err)
39 }
40
41 return sortTOMLSections(buf.String()), nil
42}
43
44// atomicWriteFile writes content to a file atomically using temp file + rename.
45// This ensures file watchers only see complete content, not partial writes.
46func atomicWriteFile(path, content string, perm os.FileMode) error {
47 dir := filepath.Dir(path)
48 tmpFile, err := os.CreateTemp(dir, ".config-*.tmp")
49 if err != nil {
50 return fmt.Errorf("failed to create temp file: %w", err)
51 }
52 tmpPath := tmpFile.Name()
53
54 // Cleanup on error
55 success := false
56 defer func() {
57 if !success {
58 if removeErr := os.Remove(tmpPath); removeErr != nil {
59 log := logging.NewFromEnv()
60 log.Warn().Err(removeErr).Str("path", tmpPath).Msg("failed to cleanup temp config file")
61 }
62 }
63 }()
64
65 if err := writeAndSync(tmpFile, content); err != nil {
66 return err
67 }
68
69 if err := os.Chmod(tmpPath, perm); err != nil {
70 return fmt.Errorf("failed to set file permissions: %w", err)
71 }
72
73 if err := os.Rename(tmpPath, path); err != nil {
74 return fmt.Errorf("failed to rename temp file: %w", err)
75 }
76
77 success = true
78 return nil
79}
80
81// writeAndSync writes content to file and syncs to disk.
82func writeAndSync(f *os.File, content string) (err error) {
83 // Ensure file is closed, capturing close error if no prior error
84 defer func() {
85 closeErr := f.Close()
86 if err == nil && closeErr != nil {
87 err = fmt.Errorf("failed to close file: %w", closeErr)
88 }
89 }()
90
91 if _, err = f.WriteString(content); err != nil {
92 return fmt.Errorf("failed to write file: %w", err)
93 }
94
95 if err = f.Sync(); err != nil {
96 return fmt.Errorf("failed to sync file: %w", err)
97 }
98
99 return nil
100}
101
102// tomlSection represents a parsed TOML section with its header and content lines.
103type tomlSection struct {
104 header string // Section name without brackets (e.g., "workspace.shortcuts")
105 lines []string // All lines belonging to this section, including the header
106}
107
108// sortTOMLSections sorts TOML content so sections appear in alphabetical order.
109func sortTOMLSections(content string) string {
110 lines := strings.Split(content, "\n")
111 preamble, sections := parseTOMLSections(lines)
112
113 sort.Slice(sections, func(i, j int) bool {
114 return sections[i].header < sections[j].header
115 })
116
117 return buildTOMLOutput(preamble, sections)
118}
119
120// parseTOMLSections parses TOML lines into preamble (top-level keys) and sections.
121func parseTOMLSections(lines []string) (preamble []string, sections []tomlSection) {
122 sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`)
123 var current *tomlSection
124
125 for _, line := range lines {
126 if match := sectionRegex.FindStringSubmatch(line); match != nil {
127 if current != nil {
128 sections = append(sections, *current)
129 }
130 current = &tomlSection{
131 header: match[2],
132 lines: []string{line},
133 }
134 } else if current != nil {
135 current.lines = append(current.lines, line)
136 } else {
137 preamble = append(preamble, line)
138 }
139 }
140
141 if current != nil {
142 sections = append(sections, *current)
143 }
144
145 return preamble, sections
146}
147
148// buildTOMLOutput reconstructs TOML content from preamble and sorted sections.
149func buildTOMLOutput(preamble []string, sections []tomlSection) string {
150 var result strings.Builder
151
152 for _, line := range preamble {
153 result.WriteString(line)
154 result.WriteString("\n")
155 }
156
157 for i, sec := range sections {
158 if i > 0 || len(preamble) > 0 {
159 content := result.String()
160 if !strings.HasSuffix(content, "\n\n") && content != "" {
161 result.WriteString("\n")
162 }
163 }
164
165 for _, line := range sec.lines {
166 result.WriteString(line)
167 result.WriteString("\n")
168 }
169 }
170
171 output := strings.TrimRight(result.String(), "\n")
172 if output != "" {
173 output += "\n"
174 }
175
176 return output
177}