package config import ( "encoding/json" "fmt" "os" "path/filepath" "strings" ) // SaveTarget adds or replaces a named ForwardTarget in the config file at path. // Only JSON config files are written in-place. For other formats an error is // returned describing what to add manually. func SaveTarget(path, name string, target ForwardTarget) error { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read config %s: %w", path, err) } switch ext { case "json": return saveTargetJSON(path, data, name, target) default: snippet, _ := json.MarshalIndent(map[string]ForwardTarget{name: target}, "", " ") return fmt.Errorf( "auto-update not supported for .%s files\n"+ "Add the following to the 'forward.targets' section of %s:\n\n%s", ext, path, snippet, ) } } // RemoveBrokenEndpoints removes failing endpoints from the config file. // broken maps target name → set of failing endpoint URLs. // If all endpoints of a target are removed, the target itself is deleted. // Returns the list of removed items as human-readable strings. func RemoveBrokenEndpoints(path string, broken map[string][]string) ([]string, error) { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) if ext != "json" { return nil, fmt.Errorf("auto-update not supported for .%s files; edit %s manually", ext, path) } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } var removed []string for targetName, failedURLs := range broken { target, ok := cfg.Forward.Targets[targetName] if !ok { continue } failSet := make(map[string]bool, len(failedURLs)) for _, u := range failedURLs { failSet[u] = true } kept := target.Endpoints[:0] for _, ep := range target.Endpoints { if failSet[ep.URL] { removed = append(removed, fmt.Sprintf("endpoint %s (target %q)", ep.URL, targetName)) } else { kept = append(kept, ep) } } if len(kept) == 0 { delete(cfg.Forward.Targets, targetName) removed = append(removed, fmt.Sprintf("target %q (all endpoints failed)", targetName)) if cfg.Forward.Default == targetName { cfg.Forward.Default = "" } } else { target.Endpoints = kept cfg.Forward.Targets[targetName] = target } } if len(removed) == 0 { return nil, nil } out, err := json.MarshalIndent(cfg, "", " ") if err != nil { return nil, fmt.Errorf("marshal config: %w", err) } if err := os.WriteFile(path, append(out, '\n'), 0o600); err != nil { return nil, fmt.Errorf("write %s: %w", path, err) } return removed, nil } // WriteConfig serialises cfg as indented JSON and atomically overwrites path. func WriteConfig(path string, cfg Config) error { out, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("marshal config: %w", err) } if err := os.WriteFile(path, append(out, '\n'), 0o600); err != nil { return fmt.Errorf("write %s: %w", path, err) } return nil } func saveTargetJSON(path string, data []byte, name string, target ForwardTarget) error { var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return fmt.Errorf("parse %s: %w", path, err) } if cfg.Forward.Targets == nil { cfg.Forward.Targets = make(map[string]ForwardTarget) } cfg.Forward.Targets[name] = target if cfg.Forward.Default == "" { cfg.Forward.Default = name } out, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("marshal config: %w", err) } if err := os.WriteFile(path, append(out, '\n'), 0o600); err != nil { return fmt.Errorf("write %s: %w", path, err) } return nil }