1package config
2
3import (
4 "os"
5 "path/filepath"
6 "reflect"
7 "strings"
8 "testing"
9)
10
11func TestResolve(t *testing.T) {
12 tmpDir := t.TempDir()
13 homeDir := filepath.Join(tmpDir, "home")
14 if err := os.MkdirAll(homeDir, 0o755); err != nil {
15 t.Fatalf("creating temp HOME: %v", err)
16 }
17
18 configPath := filepath.Join(tmpDir, "config.toml")
19 if err := os.WriteFile(configPath, []byte(resolveFixtureTOML), 0o600); err != nil {
20 t.Fatalf("writing fixture config: %v", err)
21 }
22
23 t.Setenv("HOME", homeDir)
24 t.Setenv("KELD_CONFIG_FILE", configPath)
25 t.Setenv("KELD_CONFIG_PATHS", "")
26
27 tests := []struct {
28 name string
29 preset string
30 command string
31 overrides map[string][]string
32 wantCommand string
33 wantWorkdir string
34 wantArgs []string
35 wantSections []string
36 wantEnviron map[string]string
37 wantFlags map[string][]string
38 }{
39 {
40 name: "global command only",
41 preset: "",
42 command: "backup",
43 wantCommand: "backup",
44 wantWorkdir: "",
45 wantArgs: nil,
46 wantSections: []string{"global", "global.backup"},
47 wantEnviron: map[string]string{
48 "RESTIC_REPOSITORY": "/repos/global",
49 "RESTIC_PASSWORD_COMMAND": "pass global",
50 },
51 wantFlags: map[string][]string{
52 "--password-file": {"/secrets/global"},
53 "--verbose": {""},
54 "--tag": {"global-one", "global-two"},
55 "--exclude-file": {"/etc/restic/global-excludes"},
56 "--exclude-if-present": {".nobackup"},
57 "--cache-dir": {"/srv/cache"},
58 },
59 },
60 {
61 name: "preset command merge with special keys",
62 preset: "home",
63 command: "backup",
64 wantCommand: "backup",
65 wantWorkdir: filepath.Join(homeDir, "work", "home"),
66 wantArgs: []string{"/home/alice", "/home/shared"},
67 wantSections: []string{"global", "global.backup", "home", "home.backup"},
68 wantEnviron: map[string]string{
69 "RESTIC_REPOSITORY": "/repos/global",
70 "RESTIC_PASSWORD_COMMAND": "pass home",
71 },
72 wantFlags: map[string][]string{
73 "--password-file": {"/secrets/home"},
74 "--verbose": {""},
75 "--tag": {"home-tag"},
76 "--exclude-file": {"/etc/restic/global-excludes"},
77 "--exclude-if-present": {".nobackup"},
78 "--cache-dir": {"/srv/cache"},
79 "--repo": {"/repos/home"},
80 },
81 },
82 {
83 name: "split preset sections",
84 preset: "home@cloud",
85 command: "backup",
86 wantCommand: "backup",
87 wantWorkdir: "",
88 wantArgs: []string{"/data/cloud"},
89 wantSections: []string{"global", "global.backup", "@cloud", "home@", "home@.backup", "home@cloud", "home@cloud.backup"},
90 wantEnviron: map[string]string{
91 "RESTIC_REPOSITORY": "/repos/cloud",
92 "RESTIC_PASSWORD_COMMAND": "pass cloud backup",
93 },
94 wantFlags: map[string][]string{
95 "--password-file": {"/secrets/global"},
96 "--verbose": {""},
97 "--tag": {"cloud-tag"},
98 "--exclude-file": {"/etc/restic/home-split-excludes"},
99 "--exclude-if-present": {".nobackup"},
100 "--cache-dir": {"/srv/cache"},
101 "--repo": {"/repos/home-cloud"},
102 },
103 },
104 {
105 name: "command alias",
106 preset: "archive",
107 command: "backup",
108 wantCommand: "snapshots",
109 wantWorkdir: "",
110 wantArgs: []string{"latest"},
111 wantSections: []string{"global", "global.backup", "archive", "archive.backup"},
112 wantEnviron: map[string]string{
113 "RESTIC_REPOSITORY": "/repos/global",
114 "RESTIC_PASSWORD_COMMAND": "pass global",
115 },
116 wantFlags: map[string][]string{
117 "--password-file": {"/secrets/global"},
118 "--verbose": {""},
119 "--tag": {"global-one", "global-two"},
120 "--exclude-file": {"/etc/restic/global-excludes"},
121 "--exclude-if-present": {".nobackup"},
122 "--cache-dir": {"/srv/cache"},
123 "--json": {""},
124 },
125 },
126 {
127 name: "cli overrides take precedence",
128 preset: "home",
129 command: "backup",
130 overrides: map[string][]string{
131 "tag": {"cli-a", "cli-b"},
132 "password-file": {"/secrets/cli"},
133 "repo": {"/repos/cli"},
134 "json": nil,
135 "_arguments": {"/cli/path"},
136 "_workdir": {"~/work/cli"},
137 "_command": {"backup"},
138 "exclude-if-present": {".override-marker"},
139 },
140 wantCommand: "backup",
141 wantWorkdir: filepath.Join(homeDir, "work", "cli"),
142 wantArgs: []string{"/cli/path"},
143 wantSections: []string{"global", "global.backup", "home", "home.backup"},
144 wantEnviron: map[string]string{
145 "RESTIC_REPOSITORY": "/repos/global",
146 "RESTIC_PASSWORD_COMMAND": "pass home",
147 },
148 wantFlags: map[string][]string{
149 "--password-file": {"/secrets/cli"},
150 "--verbose": {""},
151 "--tag": {"cli-a", "cli-b"},
152 "--exclude-file": {"/etc/restic/global-excludes"},
153 "--exclude-if-present": {".override-marker"},
154 "--cache-dir": {"/srv/cache"},
155 "--repo": {"/repos/cli"},
156 "--json": {""},
157 },
158 },
159 }
160
161 for _, tt := range tests {
162 tt := tt
163 t.Run(tt.name, func(t *testing.T) {
164 cfg, err := Resolve(tt.preset, tt.command, tt.overrides)
165 if err != nil {
166 t.Fatalf("Resolve() error: %v", err)
167 }
168
169 if cfg.Command != tt.wantCommand {
170 t.Fatalf("command mismatch: got %q, want %q", cfg.Command, tt.wantCommand)
171 }
172 if cfg.Workdir != tt.wantWorkdir {
173 t.Fatalf("workdir mismatch: got %q, want %q", cfg.Workdir, tt.wantWorkdir)
174 }
175 if !equalStrings(cfg.Arguments, tt.wantArgs) {
176 t.Fatalf("arguments mismatch: got %#v, want %#v", cfg.Arguments, tt.wantArgs)
177 }
178 if !reflect.DeepEqual(cfg.SectionsRead, tt.wantSections) {
179 t.Fatalf("sections mismatch: got %#v, want %#v", cfg.SectionsRead, tt.wantSections)
180 }
181 if !reflect.DeepEqual(cfg.Environ, tt.wantEnviron) {
182 t.Fatalf("environ mismatch: got %#v, want %#v", cfg.Environ, tt.wantEnviron)
183 }
184
185 for _, f := range cfg.Flags {
186 if !strings.HasPrefix(f.Name, "-") {
187 t.Fatalf("flag name %q is missing CLI prefix", f.Name)
188 }
189 }
190
191 gotFlags := collectFlags(cfg.Flags)
192 if !reflect.DeepEqual(gotFlags, tt.wantFlags) {
193 t.Fatalf("flags mismatch: got %#v, want %#v", gotFlags, tt.wantFlags)
194 }
195 })
196 }
197}
198
199func collectFlags(flags []Flag) map[string][]string {
200 out := make(map[string][]string)
201 for _, flag := range flags {
202 out[flag.Name] = append(out[flag.Name], flag.Value)
203 }
204 return out
205}
206
207func equalStrings(a, b []string) bool {
208 if len(a) == 0 && len(b) == 0 {
209 return true
210 }
211 return reflect.DeepEqual(a, b)
212}
213
214const resolveFixtureTOML = `
215[vars]
216cache-root = "/srv"
217
218[global]
219password-file = "/secrets/global"
220verbose = true
221tag = ["global-one", "global-two"]
222
223[global.backup]
224exclude-file = "/etc/restic/global-excludes"
225exclude-if-present = ".nobackup"
226cache-dir = "${vars.cache-root}/cache"
227
228[global.backup.environ]
229RESTIC_REPOSITORY = "/repos/global"
230RESTIC_PASSWORD_COMMAND = "pass global"
231
232[home]
233repo = "/repos/home"
234_workdir = "~/work/home"
235tag = ["home-tag"]
236
237[home.backup]
238_arguments = ["/home/alice", "/home/shared"]
239password-file = "/secrets/home"
240
241[home.backup.environ]
242RESTIC_PASSWORD_COMMAND = "pass home"
243
244["@cloud"]
245repo = "/repos/cloud-base"
246tag = ["cloud-tag"]
247
248["@cloud".environ]
249RESTIC_REPOSITORY = "/repos/cloud"
250
251["home@"]
252repo = "/repos/home-prefix"
253
254["home@".backup]
255exclude-file = "/etc/restic/home-split-excludes"
256
257["home@".forget]
258keep-last = 7
259
260["home@cloud"]
261repo = "/repos/home-cloud"
262
263["home@cloud".backup]
264_arguments = ["/data/cloud"]
265
266["home@cloud".backup.environ]
267RESTIC_PASSWORD_COMMAND = "pass cloud backup"
268
269[archive.backup]
270_command = "snapshots"
271_arguments = ["latest"]
272json = true
273`