1package config
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "math"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12)
13
14// ScaleStore writes output.<name>.scale into the config file, preserving
15// unrelated lines and comments.
16type ScaleStore struct{ Path string }
17
18// SaveOutputScale updates output.<name>.scale in the config file. The file is
19// replaced atomically; a symlinked config is written through to its target.
20func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
21 path := s.Path
22 if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
23 // Replacing a dangling link would lose it: report it instead.
24 if path, err = filepath.EvalSymlinks(path); err != nil {
25 return err
26 }
27 }
28 data, err := os.ReadFile(path)
29 // A new config may hold commands: only its owner reads it.
30 mode := os.FileMode(0o600)
31 switch {
32 case errors.Is(err, os.ErrNotExist):
33 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
34 return err
35 }
36 case err != nil:
37 return err
38 default:
39 if info, err := os.Stat(path); err == nil {
40 mode = info.Mode().Perm()
41 }
42 }
43 return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
44}
45
46// setKey replaces the last active assignment, or enables a matching commented
47// assignment. New keys go beside the closest related key, then at the end.
48func setKey(data []byte, key, value string) []byte {
49 lines := strings.SplitAfter(string(data), "\n")
50 commented := -1
51 for i, line := range lines {
52 body := strings.TrimSpace(line)
53 if !strings.HasPrefix(body, "#") {
54 continue
55 }
56 body = strings.TrimSpace(strings.TrimPrefix(body, "#"))
57 k, _, ok := strings.Cut(body, "=")
58 if ok && strings.TrimSpace(k) == key {
59 commented = i
60 }
61 }
62 for i := len(lines) - 1; i >= 0; i-- {
63 prefix, rest, ok := strings.Cut(lines[i], "=")
64 if !ok || strings.TrimSpace(prefix) != key {
65 continue
66 }
67 lines[i] = replaceValue(prefix+"=", rest, value)
68 return []byte(strings.Join(lines, ""))
69 }
70 if commented >= 0 {
71 line := lines[commented]
72 indent, body, _ := strings.Cut(line, "#")
73 prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
74 lines[commented] = replaceValue(indent+prefix+"=", rest, value)
75 return []byte(strings.Join(lines, ""))
76 }
77 // Match the longest dotted parent first: output.DP-2.scale belongs beside
78 // output.DP-2.*, not merely beside any output.* key.
79 for parent := key; ; {
80 dot := strings.LastIndexByte(parent, '.')
81 if dot < 0 {
82 break
83 }
84 parent = parent[:dot]
85 index := -1
86 for i, line := range lines {
87 k, _, ok := strings.Cut(line, "=")
88 k = strings.TrimSpace(k)
89 if ok && (k == parent || strings.HasPrefix(k, parent+".")) {
90 index = i
91 }
92 }
93 if index < 0 {
94 continue
95 }
96 eol := "\n"
97 if strings.HasSuffix(lines[index], "\r\n") {
98 eol = "\r\n"
99 }
100 if !strings.HasSuffix(lines[index], "\n") {
101 lines[index] += eol
102 }
103 lines = append(lines, "")
104 copy(lines[index+2:], lines[index+1:])
105 lines[index+1] = key + " = " + value + eol
106 return []byte(strings.Join(lines, ""))
107 }
108 var b bytes.Buffer
109 b.Write(data)
110 if len(data) > 0 && data[len(data)-1] != '\n' {
111 b.WriteByte('\n')
112 }
113 fmt.Fprintf(&b, "%s = %s\n", key, value)
114 return b.Bytes()
115}
116
117// replaceValue keeps indentation, spacing and trailing comments in an assignment.
118func replaceValue(prefix, rest, value string) string {
119 plain := strings.TrimRight(rest, "\r\n")
120 eol := rest[len(plain):]
121 old := stripComment(strings.TrimSpace(plain))
122 start := strings.Index(plain, old)
123 return prefix + plain[:start] + value + plain[start+len(old):] + eol
124}
125
126// formatScale writes scales as the config expects them: a decimal when it is
127// exact (1.25), else a fraction (4/3).
128func formatScale(s float64) string {
129 n, d := int(math.Round(s*120)), 120
130 g := gcd(n, d)
131 n, d = n/g, d/g
132 den := d
133 for den%2 == 0 {
134 den /= 2
135 }
136 for den%5 == 0 {
137 den /= 5
138 }
139 if den == 1 {
140 return strconv.FormatFloat(float64(n)/float64(d), 'f', -1, 64)
141 }
142 return fmt.Sprintf("%d/%d", n, d)
143}
144
145func gcd(a, b int) int {
146 for b != 0 {
147 a, b = b, a%b
148 }
149 return a
150}
151
152// writeAtomic replaces path with data: readers and the watcher never see a
153// half-written file.
154func writeAtomic(path string, data []byte, mode os.FileMode) error {
155 tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
156 if err != nil {
157 return err
158 }
159 defer os.Remove(tmp.Name())
160 if _, err := tmp.Write(data); err != nil {
161 tmp.Close()
162 return err
163 }
164 if err := tmp.Chmod(mode); err != nil {
165 tmp.Close()
166 return err
167 }
168 if err := tmp.Sync(); err != nil {
169 tmp.Close()
170 return err
171 }
172 if err := tmp.Close(); err != nil {
173 return err
174 }
175 return os.Rename(tmp.Name(), path)
176}