1package config
2
3import (
4 "fmt"
5 "os"
6 "regexp"
7 "strings"
8
9 "github.com/BurntSushi/toml"
10)
11
12// Special keys recognised during option assembly.
13const (
14 keyArguments = "_arguments"
15 keyWorkdir = "_workdir"
16 keyCommand = "_command"
17)
18
19// environSuffix marks a section as containing environment variables.
20const environSuffix = ".environ"
21
22// interpolateRe matches ${section.key} references for cross-section interpolation.
23var interpolateRe = regexp.MustCompile(`\$\{([^.}]+)\.([^}]+)\}`)
24
25// ResolvedConfig holds the fully-merged result of config resolution, ready for
26// the restic exec layer to consume.
27type ResolvedConfig struct {
28 // Command is the restic subcommand to run (may be aliased via _command).
29 Command string
30
31 // Flags maps flag names to their values. Multi-value flags have multiple
32 // entries. Boolean flags (true) are represented as a nil slice.
33 Flags []Flag
34
35 // Arguments are positional args passed after the flags.
36 Arguments []string
37
38 // Workdir is the directory to chdir into before exec, or "" for cwd.
39 Workdir string
40
41 // Environ holds additional environment variables for the restic process.
42 Environ map[string]string
43
44 // SectionsRead lists which config sections contributed to this resolution.
45 SectionsRead []string
46}
47
48// Flag is a single CLI flag to pass to restic.
49type Flag struct {
50 Name string
51 Value string // empty for boolean switches
52}
53
54// rawConfig is the entire parsed TOML file as nested string-keyed maps.
55type rawConfig map[string]any
56
57// Resolve loads all discovered config files, merges sections according to
58// preset/command rules, and returns a ResolvedConfig.
59//
60// cliOverrides are applied last and should use flag names without leading
61// dashes (e.g. "exclude" not "--exclude"). Each key maps to one or more values;
62// boolean switches use a nil/empty slice.
63func Resolve(preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) {
64 files := DiscoverFiles()
65 return resolveFrom(files, preset, command, cliOverrides)
66}
67
68func resolveFrom(files []string, preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) {
69 raw, err := loadFiles(files)
70 if err != nil {
71 return nil, err
72 }
73
74 sections := buildSectionOrder(preset, command)
75 envSections := make([]string, len(sections))
76 for i, s := range sections {
77 envSections[i] = s + environSuffix
78 }
79
80 // Merge option sections in order.
81 merged := make(map[string]any)
82 var sectionsRead []string
83 for _, sect := range sections {
84 tbl, ok := lookupSection(raw, sect)
85 if !ok {
86 continue
87 }
88 // Skip sub-tables (nested commands / environ) — only merge leaf keys.
89 for k, v := range tbl {
90 if _, isMap := v.(map[string]any); isMap {
91 continue
92 }
93 merged[k] = v
94 }
95 sectionsRead = append(sectionsRead, sect)
96 }
97
98 // Merge environ sections.
99 environ := make(map[string]string)
100 for _, sect := range envSections {
101 tbl, ok := lookupSection(raw, sect)
102 if !ok {
103 continue
104 }
105 for k, v := range tbl {
106 environ[k] = ExpandPath(fmt.Sprint(v))
107 }
108 }
109
110 // Perform cross-section interpolation on merged values.
111 interpolate(merged, raw)
112
113 // Apply CLI overrides last.
114 for k, vals := range cliOverrides {
115 if len(vals) == 0 {
116 merged[k] = true
117 } else if len(vals) == 1 {
118 merged[k] = vals[0]
119 } else {
120 iface := make([]any, len(vals))
121 for i, v := range vals {
122 iface[i] = v
123 }
124 merged[k] = iface
125 }
126 }
127
128 return assemble(merged, command, environ, sectionsRead), nil
129}
130
131// loadFiles reads and merges all TOML config files. Later files override
132// earlier ones at the top-level section granularity.
133func loadFiles(files []string) (rawConfig, error) {
134 combined := make(rawConfig)
135 for _, f := range files {
136 data, err := os.ReadFile(f)
137 if err != nil {
138 if os.IsNotExist(err) {
139 continue
140 }
141 return nil, fmt.Errorf("reading config %s: %w", f, err)
142 }
143 var parsed rawConfig
144 if err := toml.Unmarshal(data, &parsed); err != nil {
145 return nil, fmt.Errorf("parsing config %s: %w", f, err)
146 }
147 // Merge top-level keys; later files win.
148 for k, v := range parsed {
149 if existing, ok := combined[k]; ok {
150 if eMap, eOk := existing.(map[string]any); eOk {
151 if vMap, vOk := v.(map[string]any); vOk {
152 for mk, mv := range vMap {
153 eMap[mk] = mv
154 }
155 continue
156 }
157 }
158 }
159 combined[k] = v
160 }
161 }
162 return combined, nil
163}
164
165// buildSectionOrder returns the list of config sections to read, in ascending
166// priority order, for the given preset and command.
167//
168// For a plain preset "foo" with command "backup":
169//
170// [global] -> [global.backup] -> [foo] -> [foo.backup]
171//
172// For a split preset "home@nas" with command "backup":
173//
174// [global] -> [global.backup] -> [@nas] -> [@nas.backup] ->
175// [home@] -> [home@.backup] -> [home@nas] -> [home@nas.backup]
176func buildSectionOrder(preset, command string) []string {
177 sections := []string{"global"}
178 if command != "" {
179 sections = append(sections, "global."+command)
180 }
181
182 if preset == "" {
183 return sections
184 }
185
186 if idx := strings.Index(preset, "@"); idx >= 0 {
187 // Split preset: "prefix@suffix"
188 // Parts in reverse order of the split, then the full preset.
189 parts := splitPreset(preset)
190 for _, part := range parts {
191 sections = append(sections, part)
192 if command != "" {
193 sections = append(sections, part+"."+command)
194 }
195 }
196 // Full preset (only if not already the sole part).
197 if len(parts) != 1 || parts[0] != preset {
198 sections = append(sections, preset)
199 if command != "" {
200 sections = append(sections, preset+"."+command)
201 }
202 }
203 } else {
204 sections = append(sections, preset)
205 if command != "" {
206 sections = append(sections, preset+"."+command)
207 }
208 }
209
210 return sections
211}
212
213// splitPreset splits "prefix@suffix" into ["@suffix", "prefix@"] — the two
214// halves that get their own section lookups before the full preset.
215func splitPreset(preset string) []string {
216 idx := strings.Index(preset, "@")
217 if idx < 0 {
218 return []string{preset}
219 }
220 suffix := preset[idx:] // "@nas"
221 prefix := preset[:idx+1] // "home@"
222
223 return []string{suffix, prefix}
224}
225
226// lookupSection finds a dotted section name in the raw config tree.
227// "global.backup" looks up raw["global"]["backup"].
228func lookupSection(raw rawConfig, section string) (map[string]any, bool) {
229 parts := strings.Split(section, ".")
230 var current any = (map[string]any)(raw)
231 for _, p := range parts {
232 m, ok := current.(map[string]any)
233 if !ok {
234 return nil, false
235 }
236 current, ok = m[p]
237 if !ok {
238 return nil, false
239 }
240 }
241 m, ok := current.(map[string]any)
242 if !ok {
243 return nil, false
244 }
245 return m, true
246}
247
248// interpolate resolves ${section.key} references in merged values.
249func interpolate(merged map[string]any, raw rawConfig) {
250 for k, v := range merged {
251 s, ok := v.(string)
252 if !ok {
253 continue
254 }
255 merged[k] = interpolateRe.ReplaceAllStringFunc(s, func(match string) string {
256 sub := interpolateRe.FindStringSubmatch(match)
257 if len(sub) != 3 {
258 return match
259 }
260 sect, key := sub[1], sub[2]
261 tbl, ok := lookupSection(raw, sect)
262 if !ok {
263 return match
264 }
265 val, ok := tbl[key]
266 if !ok {
267 return match
268 }
269 return fmt.Sprint(val)
270 })
271 }
272}
273
274// assemble converts the merged key-value map into a ResolvedConfig.
275func assemble(merged map[string]any, command string, environ map[string]string, sectionsRead []string) *ResolvedConfig {
276 rc := &ResolvedConfig{
277 Command: command,
278 Environ: environ,
279 SectionsRead: sectionsRead,
280 }
281
282 // Extract special keys.
283 if args, ok := merged[keyArguments]; ok {
284 rc.Arguments = toStringSlice(args)
285 delete(merged, keyArguments)
286 }
287 if wd, ok := merged[keyWorkdir]; ok {
288 rc.Workdir = ExpandPath(fmt.Sprint(wd))
289 delete(merged, keyWorkdir)
290 }
291 if cmd, ok := merged[keyCommand]; ok {
292 rc.Command = fmt.Sprint(cmd)
293 delete(merged, keyCommand)
294 }
295
296 // Build flags.
297 for k, v := range merged {
298 switch val := v.(type) {
299 case bool:
300 if val {
301 rc.Flags = append(rc.Flags, Flag{Name: flagName(k)})
302 }
303 case []any:
304 for _, elem := range val {
305 rc.Flags = append(rc.Flags, Flag{
306 Name: flagName(k),
307 Value: fmt.Sprint(elem),
308 })
309 }
310 default:
311 s := fmt.Sprint(val)
312 // Multi-line string values (newline-separated) become repeated flags.
313 lines := strings.Split(s, "\n")
314 for _, line := range lines {
315 rc.Flags = append(rc.Flags, Flag{
316 Name: flagName(k),
317 Value: ExpandPath(line),
318 })
319 }
320 }
321 }
322
323 // Expand path references in arguments.
324 for i, a := range rc.Arguments {
325 rc.Arguments[i] = ExpandPath(a)
326 }
327
328 return rc
329}
330
331// keyAliases maps TOML config key names that don't match restic's actual CLI
332// flag names. For example, "repository" is a natural config key but restic's
333// flag is "--repo".
334var keyAliases = map[string]string{
335 "repository": "repo",
336}
337
338// flagName returns the CLI flag form of a key: single-char keys get "-k",
339// longer keys get "--key". Known aliases are resolved first.
340func flagName(key string) string {
341 if alias, ok := keyAliases[key]; ok {
342 key = alias
343 }
344 if len(key) == 1 {
345 return "-" + key
346 }
347 return "--" + key
348}
349
350// toStringSlice coerces a value (string or []any) into []string.
351func toStringSlice(v any) []string {
352 switch val := v.(type) {
353 case string:
354 return strings.Fields(val)
355 case []any:
356 out := make([]string, 0, len(val))
357 for _, elem := range val {
358 out = append(out, fmt.Sprint(elem))
359 }
360 return out
361 default:
362 return []string{fmt.Sprint(v)}
363 }
364}
365
366// IsDryRun reports whether the KELD_DRYRUN environment variable is set.
367func IsDryRun() bool {
368 return os.Getenv("KELD_DRYRUN") != ""
369}