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 ShowCores bool ShowMem bool ShowNet bool ShowSeparators bool 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", ShowCores: false, ShowMem: false, ShowNet: false, } } // 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, "showcores": true, "showmem": true, "showavgline": true, "showioavgline": true, "shownet": true, "showseparators": 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() } func (c *Config) set(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 "extended": c.Extended = parseBool(val) 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 "showavgline": c.ShowAvgLine = parseBool(val) case "showioavgline": c.ShowIOAvgLine = parseBool(val) case "showcores": c.ShowCores = parseBool(val) case "showmem": c.ShowMem = parseBool(val) case "shownet": c.ShowNet = parseBool(val) case "showseparators": c.ShowSeparators = parseBool(val) case "sshopts": c.SSHOpts = val case "cluster": c.Cluster = val } } func parseBool(s string) bool { s = strings.TrimSpace(strings.ToLower(s)) return s == "1" || s == "true" || s == "yes" } 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) } 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) writeBool("showcores", c.ShowCores) writeBool("showmem", c.ShowMem) writeBool("shownet", c.ShowNet) writeBool("showseparators", c.ShowSeparators) writeStr("sshopts", c.SSHOpts) writeStr("cluster", c.Cluster) return w.Flush() } 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 }