summaryrefslogtreecommitdiff
path: root/internal/config/config.go
blob: 1df8edd6cf1a4cc2d0b8f63ff2ea5fa82ae01cab (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
package config

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"codeberg.org/snonux/loadbars/internal/constants"
)

// Config holds all loadbars configuration (file + CLI).
// Defaults match the Perl Shared.pm %C.
type Config struct {
	Hosts          []string // Each entry is "host" or "host:user"
	Title          string
	BarWidth       int
	CPUAverage     int
	Extended       bool
	HasAgent       bool
	Height         int
	MaxWidth       int
	NetAverage     int
	NetLink        string
	ShowAvgLine    bool
	ShowIOAvgLine  bool
	CPUMode        int // constants.CPUModeAverage / CPUModeCores / CPUModeOff
	ShowMem        bool
	ShowNet        bool
	ShowLoad       bool
	LoadMax        float64 // 0 = auto-scale; >0 = fixed full-height reference value
	ShowSeparators bool
	DiskMode       int     // constants.DiskModeAggregate / DiskModeDevices / DiskModeOff
	DiskMax        float64 // 0 = auto-scale; >0 = fixed bytes/sec reference
	DiskAverage    int     // smoothing sample count (like CPUAverage/NetAverage)
	MaxBarsPerRow  int
	SSHOpts        string
	Cluster        string
}

// Default returns a Config with default values.
func Default() Config {
	return Config{
		BarWidth:      1200,
		CPUAverage:    10,
		Extended:      false,
		HasAgent:      false,
		Height:        150,
		MaxWidth:      1900,
		NetAverage:    15,
		NetLink:       "gbit",
		CPUMode:       constants.CPUModeAverage, // start with aggregate bar only
		ShowMem:       false,
		ShowNet:       false,
		DiskMode:      constants.DiskModeOff,
		DiskMax:       0,
		DiskAverage:   10,
		MaxBarsPerRow: 0,
	}
}

// ConfFilePath returns the full path to the config file (~/.loadbarsrc).
func ConfFilePath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("home dir: %w", err)
	}
	return filepath.Join(home, constants.ConfFile), nil
}

// Load reads config from the config file and merges into c. Unknown keys are ignored.
func (c *Config) Load() error {
	path, err := ConfFilePath()
	if err != nil {
		return err
	}
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return fmt.Errorf("open config: %w", err)
	}
	defer f.Close()
	return c.parseReader(f)
}

// Write saves the current config to the config file (excluding title).
func (c *Config) Write() error {
	path, err := ConfFilePath()
	if err != nil {
		return err
	}
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("create config: %w", err)
	}
	defer f.Close()
	return c.writeTo(f)
}

// GetClusterHosts resolves a cluster name from /etc/clusters into a list of hosts.
func GetClusterHosts(cluster string) ([]string, error) {
	return GetClusterHostsFromFile(cluster, constants.CSSHConfFile)
}

// GetClusterHostsFromFile resolves a cluster from a clusters file (for testing or custom path).
// Supports recursive cluster references with cycle detection.
func GetClusterHostsFromFile(cluster, path string) ([]string, error) {
	return getClusterHostsRec(cluster, path, 1, nil)
}

func (c *Config) parseReader(f *os.File) error {
	validKeys := map[string]bool{
		"title": true, "barwidth": true, "cpuaverage": true, "extended": true,
		"hasagent": true, "height": true, "maxwidth": true, "netaverage": true,
		"netlink": true, "cpumode": true, "showcores": true, "showmem": true,
		"showavgline": true, "showioavgline": true, "shownet": true, "showload": true, "loadmax": true, "showseparators": true,
		"diskmode": true, "diskmax": true, "diskaverage": true,
		"maxbarsperrow": true, "sshopts": true, "cluster": true,
	}
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if idx := strings.Index(line, "#"); idx >= 0 {
			line = strings.TrimSpace(line[:idx])
		}
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "=", 2)
		if len(parts) != 2 {
			continue
		}
		key := strings.TrimSpace(parts[0])
		val := strings.TrimSpace(parts[1])
		if !validKeys[key] {
			continue
		}
		c.set(key, val)
	}
	return scanner.Err()
}

// set applies a single key=value pair to the config, delegating to focused helpers.
func (c *Config) set(key, val string) {
	c.setSizeAndTuning(key, val)
	c.setDisplayFlags(key, val)
}

// setSizeAndTuning handles window dimension, SSH, sampling, and identity keys.
func (c *Config) setSizeAndTuning(key, val string) {
	switch key {
	case "title":
		c.Title = val
	case "barwidth":
		if n, err := strconv.Atoi(val); err == nil {
			c.BarWidth = n
		}
	case "cpuaverage":
		if n, err := strconv.Atoi(val); err == nil {
			c.CPUAverage = n
		}
	case "hasagent":
		c.HasAgent = parseBool(val)
	case "height":
		if n, err := strconv.Atoi(val); err == nil {
			c.Height = n
		}
	case "maxwidth":
		if n, err := strconv.Atoi(val); err == nil {
			c.MaxWidth = n
		}
	case "netaverage":
		if n, err := strconv.Atoi(val); err == nil {
			c.NetAverage = n
		}
	case "netlink":
		c.NetLink = val
	case "maxbarsperrow":
		if n, err := strconv.Atoi(val); err == nil {
			c.MaxBarsPerRow = n
		}
	case "sshopts":
		c.SSHOpts = val
	case "cluster":
		c.Cluster = val
	}
}

// setDisplayFlags handles keys that control what is shown and how CPU/load are scaled.
func (c *Config) setDisplayFlags(key, val string) {
	switch key {
	case "extended":
		c.Extended = parseBool(val)
	case "showavgline":
		c.ShowAvgLine = parseBool(val)
	case "showioavgline":
		c.ShowIOAvgLine = parseBool(val)
	case "cpumode":
		// 0=average, 1=cores, 2=off — clamp to valid range
		if n, err := strconv.Atoi(val); err == nil && n >= 0 && n < constants.CPUModeCount {
			c.CPUMode = n
		}
	case "showcores":
		// Backward-compatible: old boolean showcores maps to CPUMode
		if parseBool(val) {
			c.CPUMode = constants.CPUModeCores
		} else {
			c.CPUMode = constants.CPUModeAverage
		}
	case "showmem":
		c.ShowMem = parseBool(val)
	case "shownet":
		c.ShowNet = parseBool(val)
	case "showload":
		c.ShowLoad = parseBool(val)
	case "loadmax":
		// Accept any non-negative float; 0 means auto-scale.
		if f, err := strconv.ParseFloat(val, 64); err == nil && f >= 0 {
			c.LoadMax = f
		}
	case "showseparators":
		c.ShowSeparators = parseBool(val)
	case "diskmode":
		// 0=aggregate, 1=devices, 2=off — clamp to valid range
		if n, err := strconv.Atoi(val); err == nil && n >= 0 && n < constants.DiskModeCount {
			c.DiskMode = n
		}
	case "diskmax":
		// Accept any non-negative float; 0 means auto-scale.
		if f, err := strconv.ParseFloat(val, 64); err == nil && f >= 0 {
			c.DiskMax = f
		}
	case "diskaverage":
		if n, err := strconv.Atoi(val); err == nil && n > 0 {
			c.DiskAverage = n
		}
	}
}

func (c *Config) writeTo(f *os.File) error {
	w := bufio.NewWriter(f)
	writeInt := func(key string, v int) { fmt.Fprintf(w, "%s=%d\n", key, v) }
	writeStr := func(key, v string) { fmt.Fprintf(w, "%s=%s\n", key, v) }
	// writeFloat uses %g to strip trailing zeros (e.g. 8 → "8", 8.5 → "8.5").
	writeFloat := func(key string, v float64) { fmt.Fprintf(w, "%s=%g\n", key, v) }
	writeBool := func(key string, v bool) {
		val := "0"
		if v {
			val = "1"
		}
		fmt.Fprintf(w, "%s=%s\n", key, val)
	}
	writeInt("barwidth", c.BarWidth)
	writeInt("cpuaverage", c.CPUAverage)
	writeBool("extended", c.Extended)
	writeBool("hasagent", c.HasAgent)
	writeInt("height", c.Height)
	writeInt("maxwidth", c.MaxWidth)
	writeInt("netaverage", c.NetAverage)
	writeStr("netlink", c.NetLink)
	writeBool("showavgline", c.ShowAvgLine)
	writeBool("showioavgline", c.ShowIOAvgLine)
	writeInt("cpumode", c.CPUMode)
	writeBool("showmem", c.ShowMem)
	writeBool("shownet", c.ShowNet)
	writeBool("showload", c.ShowLoad)
	writeFloat("loadmax", c.LoadMax)
	writeBool("showseparators", c.ShowSeparators)
	writeInt("diskmode", c.DiskMode)
	writeFloat("diskmax", c.DiskMax)
	writeInt("diskaverage", c.DiskAverage)
	writeInt("maxbarsperrow", c.MaxBarsPerRow)
	writeStr("sshopts", c.SSHOpts)
	writeStr("cluster", c.Cluster)
	return w.Flush()
}

func parseBool(s string) bool {
	s = strings.TrimSpace(strings.ToLower(s))
	return s == "1" || s == "true" || s == "yes"
}

func getClusterHostsRec(cluster, path string, depth int, seen map[string]bool) ([]string, error) {
	if depth > constants.CSSHMaxRecursion {
		return nil, fmt.Errorf("cluster recursion limit reached in %s (possible cycle)", path)
	}
	if seen == nil {
		seen = make(map[string]bool)
	}
	if seen[cluster] {
		return nil, fmt.Errorf("cluster cycle detected: %s", cluster)
	}

	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open %s: %w", path, err)
	}
	defer f.Close()

	var line string
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		ln := strings.TrimSpace(scanner.Text())
		if ln == "" || strings.HasPrefix(ln, "#") {
			continue
		}
		fields := strings.Fields(ln)
		if len(fields) >= 1 && fields[0] == cluster {
			if len(fields) > 1 {
				line = strings.Join(fields[1:], " ")
			}
			break
		}
	}
	if err := scanner.Err(); err != nil {
		return nil, err
	}

	if line == "" {
		return []string{cluster}, nil
	}

	seen[cluster] = true
	defer delete(seen, cluster)

	var out []string
	for _, part := range strings.Fields(line) {
		hosts, err := getClusterHostsRec(part, path, depth+1, seen)
		if err != nil {
			return nil, err
		}
		out = append(out, hosts...)
	}
	return out, nil
}